@brutalsystems/muster 0.7.17 → 0.7.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -882,6 +882,10 @@ any non-empty plugin list, so the child also loads whatever plugins the target
882
882
  repository ships. It globs both spellings — `.opencode/plugin` and
883
883
  `.opencode/plugins` — so auditing only one of them misses half of what a
884
884
  repository can load. Select plugins only for repositories trusted with that.
885
+ `--plugin NAME` is repeatable and replaces whatever plugins the configuration
886
+ selects by default, rather than adding to them; `--no-plugin` selects none and
887
+ is how a launch gets the `--pure` treatment above when the configuration would
888
+ otherwise have chosen some. Both apply to OpenCode only.
885
889
  Claude connection specifications are stored with owner-only permissions under
886
890
  `~/.muster/mcp`, removed on stop, task completion, or when `list` observes an ended
887
891
  session. MCP server access is separate from filesystem sandbox permissions: a
package/dist/help.js ADDED
@@ -0,0 +1,195 @@
1
+ const WIDTH = 80;
2
+ const INDENT = " ";
3
+ const GUTTER = 2;
4
+ function wrap(text, width) {
5
+ const words = text.split(/\s+/).filter(Boolean);
6
+ const lines = [];
7
+ let line = "";
8
+ for (const word of words) {
9
+ if (line && line.length + 1 + word.length > width) {
10
+ lines.push(line);
11
+ line = word;
12
+ }
13
+ else
14
+ line = line ? `${line} ${word}` : word;
15
+ }
16
+ if (line)
17
+ lines.push(line);
18
+ return lines.length ? lines : [""];
19
+ }
20
+ /** Two columns, with a label too wide for its column taking a line of its own
21
+ * rather than pushing every description of that row out past the margin. */
22
+ function table(rows) {
23
+ const width = Math.min(26, Math.max(...rows.map(([label]) => label.length), 0));
24
+ const lead = INDENT.length + width + GUTTER;
25
+ return rows.flatMap(([label, description]) => {
26
+ const body = wrap(description, WIDTH - lead);
27
+ if (label.length > width)
28
+ return [INDENT + label, ...body.map((l) => " ".repeat(lead) + l)];
29
+ return body.map((line, i) => i === 0
30
+ ? INDENT + label.padEnd(width) + " ".repeat(GUTTER) + line
31
+ : " ".repeat(lead) + line);
32
+ });
33
+ }
34
+ export const COMMANDS = [
35
+ {
36
+ name: "run",
37
+ summary: "launch an agent session or task",
38
+ synopsis: "muster run <codex|claude|opencode> --prompt TEXT [options]",
39
+ options: [
40
+ ["--prompt TEXT", "the instruction to launch with"],
41
+ ["--cwd DIR", "the directory to launch in"],
42
+ ["--project NAME", "a directory named under [projects]; not with --cwd"],
43
+ ["--kind KIND", "session (default) or task"],
44
+ ["--host ID", "tmux, pty, or auto (default: tmux, then pty)"],
45
+ ["--identity NAME", "run as a configured identity"],
46
+ ["--open", "display the tmux session in a terminal (macOS)"],
47
+ ["--terminal APP", "auto, terminal, iterm2, or ghostty; needs --open"],
48
+ [
49
+ "--level LEVEL",
50
+ "read, work, or open: a legal permissions and sandbox pair",
51
+ ],
52
+ ["--permissions MODE", "auto, deny, or bypass"],
53
+ ["--sandbox MODE", "read-only, workspace-write, or full-access"],
54
+ ["--mcp NAME", "repeatable; replaces configured defaults"],
55
+ ["--no-mcp", "launch with no MCP servers"],
56
+ ["--plugin NAME", "repeatable; replaces configured defaults (OpenCode)"],
57
+ ["--no-plugin", "launch with no plugins"],
58
+ ["--model PROVIDER/MODEL", "select the OpenCode model"],
59
+ [
60
+ "--request-key KEY",
61
+ "name the request; sending it again returns the same launch",
62
+ ],
63
+ ["--format FORMAT", "json (default) or human"],
64
+ ["-- RUNTIME_OPTIONS", "everything after -- is passed to the runtime"],
65
+ ],
66
+ notes: [
67
+ "Naming both --level and --permissions or --sandbox is an error rather than a precedence rule.",
68
+ "A pty launch keeps this process running and Ctrl-C stops it; a tmux launch survives this process exiting.",
69
+ ],
70
+ examples: [
71
+ "muster run claude --prompt 'Review this project' --cwd .",
72
+ "muster run codex --identity work --level work --prompt 'Ship it'",
73
+ ],
74
+ },
75
+ {
76
+ name: "list",
77
+ summary: "show sessions and tasks",
78
+ synopsis: "muster list [--kind session|task] [--format json|human]",
79
+ options: [
80
+ ["--kind KIND", "show only session or only task entries"],
81
+ ["--format FORMAT", "json (default) or human"],
82
+ ],
83
+ notes: [
84
+ "Refreshes live metadata, and removes the per-launch identity copy of any entry that has ended.",
85
+ ],
86
+ examples: ["muster list --format human"],
87
+ },
88
+ {
89
+ name: "stop",
90
+ summary: "stop a session or task",
91
+ synopsis: "muster stop <id> [--format json|human]",
92
+ options: [["--format FORMAT", "json (default) or human"]],
93
+ examples: ["muster stop 6fae755b-8f5d-4425-8ed7-69e84bfac8c5"],
94
+ },
95
+ {
96
+ name: "output",
97
+ summary: "print a task's captured output",
98
+ synopsis: "muster output <id>",
99
+ options: [],
100
+ notes: ["Prints raw text; --format does not apply."],
101
+ examples: ["muster output 6fae755b-8f5d-4425-8ed7-69e84bfac8c5"],
102
+ },
103
+ {
104
+ name: "identities",
105
+ summary: "show configured identities",
106
+ synopsis: "muster identities [--format json|human]",
107
+ options: [["--format FORMAT", "json (default) or human"]],
108
+ examples: ["muster identities --format human"],
109
+ },
110
+ {
111
+ name: "setup-identity",
112
+ summary: "create or configure an identity",
113
+ synopsis: "muster setup-identity --identity NAME --agent AGENT [options]",
114
+ options: [
115
+ ["--identity NAME", "the identity to create or configure"],
116
+ ["--agent AGENT", "codex, claude, or opencode"],
117
+ ["--interactive", "run the login here and store what it returns"],
118
+ [
119
+ "--token-env VAR",
120
+ "read the token from that variable instead (Claude only)",
121
+ ],
122
+ ],
123
+ notes: [
124
+ "--interactive and --token-env are the two credential routes and cannot be combined; an identity holds one at a time.",
125
+ "Without --interactive the command prints the login for you to run yourself.",
126
+ ],
127
+ examples: [
128
+ "muster setup-identity --identity work --agent claude --interactive",
129
+ "muster setup-identity --identity ci --agent claude --token-env CLAUDE_TOKEN",
130
+ ],
131
+ },
132
+ {
133
+ name: "doctor",
134
+ summary: "check path-configured plugins for staleness",
135
+ synopsis: "muster doctor [--format json|human]",
136
+ options: [["--format FORMAT", "json (default) or human"]],
137
+ notes: [
138
+ "Compares each copy's mtime against the package it came from, and exits 1 when any is stale. Plugins named by npm are not checked.",
139
+ ],
140
+ examples: ["muster doctor --format human"],
141
+ },
142
+ {
143
+ name: "mcp",
144
+ summary: "start the MCP server on stdio",
145
+ synopsis: "muster mcp",
146
+ options: [],
147
+ notes: [
148
+ "Exposes run, list, stop and output. No arguments does the same when stdin is not a terminal, which is what an MCP client provides.",
149
+ ],
150
+ examples: ["muster mcp"],
151
+ },
152
+ ];
153
+ export function topLevelHelp() {
154
+ const lines = [
155
+ "Usage: muster <command> [options]",
156
+ "",
157
+ "Commands:",
158
+ ...table(COMMANDS.map((c) => [c.name, c.summary])),
159
+ "",
160
+ "Options:",
161
+ ...table([
162
+ ["--format FORMAT", "json (default) or human, where a command takes it"],
163
+ ["--version", "print the version"],
164
+ ["--help", "print this, or a command's own help"],
165
+ ]),
166
+ "",
167
+ "See `muster <command> --help` for a command's options.",
168
+ "",
169
+ // Kept here rather than moved to the README: this is a safety statement
170
+ // about who gets launch authority, not reference detail, and it is only
171
+ // load-bearing where someone is about to install.
172
+ ...wrap("MCP: install deliberately in the one authorized session, never at user scope.", WIDTH),
173
+ ];
174
+ return lines.join("\n") + "\n";
175
+ }
176
+ export function commandHelp(name) {
177
+ const command = COMMANDS.find((c) => c.name === name);
178
+ if (!command)
179
+ return undefined;
180
+ const lines = [
181
+ `Usage: ${command.synopsis}`,
182
+ "",
183
+ ...wrap(command.summary, WIDTH),
184
+ ];
185
+ if (command.options.length)
186
+ lines.push("", "Options:", ...table(command.options));
187
+ for (const note of command.notes ?? [])
188
+ lines.push("", ...wrap(note, WIDTH));
189
+ if (command.examples.length) {
190
+ lines.push("", "Examples:");
191
+ for (const example of command.examples)
192
+ lines.push(INDENT + example);
193
+ }
194
+ return lines.join("\n") + "\n";
195
+ }
package/dist/muster.js CHANGED
@@ -13,61 +13,13 @@ import { loadConfig, musterHome } from "./config.js";
13
13
  import { describeIdentities, interactiveSetup, setupIdentity, } from "./identity-cli.js";
14
14
  import { IdentityError } from "./identity-store.js";
15
15
  import { MCP_TOOLS } from "./mcp-tools.js";
16
+ import { commandHelp, topLevelHelp } from "./help.js";
17
+ import { promptForMissing, terminalReader } from "./prompt.js";
16
18
  const listSchema = z
17
19
  .object({ kind: z.enum(["session", "task"]).optional() })
18
20
  .strict();
19
21
  const idSchema = z.object({ id: z.string().min(1) }).strict();
20
22
  const { version } = createRequire(import.meta.url)("../package.json");
21
- const help = `Usage:
22
- muster run <codex|claude|opencode> --prompt TEXT [--cwd DIR] [--kind session|task] [--host ID] [--open] [--terminal APP] [--request-key KEY] [-- RUNTIME_OPTIONS]
23
- muster list [--kind session|task] [--format json|human]
24
- muster stop <id> [--format json|human]
25
- muster output <id>
26
- muster doctor [--format json|human]
27
- muster setup-identity --identity NAME --agent <codex|claude|opencode> [--token-env VAR] [--interactive]
28
- muster identities [--format json|human]
29
- muster mcp
30
- muster --version
31
-
32
- run also accepts --format json|human, --permissions auto|deny|bypass,
33
- and --sandbox read-only|workspace-write|full-access before -- RUNTIME_OPTIONS.
34
- --level read|work|open names a legal permissions and sandbox pair: read is
35
- deny + read-only, work is auto + workspace-write, open is bypass + full-access
36
- and still requires allow_dangerous_flags. Naming both a level and either flag
37
- is an error rather than a precedence rule.
38
- --open displays a tmux session on macOS. --terminal accepts auto (Terminal.app),
39
- terminal, iterm2, or ghostty, and requires --open.
40
- --mcp NAME is repeatable and replaces configured session defaults; --no-mcp
41
- clears them. Tasks use no MCP defaults; select servers explicitly.
42
- --plugin NAME is repeatable and replaces configured session defaults;
43
- --no-plugin clears them. OpenCode only: selected plugins load in the child,
44
- which also admits any plugin the target repository ships.
45
- --model PROVIDER/MODEL selects the OpenCode model, overriding the configured
46
- [opencode] model. Providers defined under [opencode.provider] are supplied to
47
- the child, so launches do not depend on the user's own OpenCode config.
48
- --project NAME launches in a directory named under [projects] in config.toml,
49
- instead of --cwd; naming both is an error. When allowed_roots or [projects] are
50
- configured, a launch outside all of them is refused.
51
- --request-key KEY names the launch request itself. Sending the same key again
52
- returns the launch it already produced rather than starting a second one, and
53
- sending it with different parameters is refused rather than answered.
54
- setup-identity takes either --interactive (Muster runs the Claude login and
55
- stores the token in the identity) or --token-env VAR (the identity reads its
56
- token from that variable instead, keeping nothing at rest). They are the two
57
- mutually exclusive credential routes; passing both is an error rather than a
58
- precedence rule.
59
- run, list and stop default to JSON. output prints raw task text.
60
-
61
- doctor checks path-configured plugins against the packages they came from: a
62
- copied file goes stale silently while the package moves on. It reads the copy's
63
- mtime, so a re-copied old file reads as current. Exits 1 when any plugin is
64
- stale. Plugins named by npm are not checked — OpenCode caches those instead,
65
- and the README covers clearing that cache.
66
-
67
- pty launches print their record and keep this process running. Ctrl-C stops the
68
- owned pty session. tmux launches survive this process exiting.
69
- MCP: install deliberately in the one authorized session, never at user scope.
70
- `;
71
23
  const text = (value) => ({
72
24
  content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
73
25
  });
@@ -95,7 +47,15 @@ function identitiesHuman(view, color) {
95
47
  async function main() {
96
48
  const argv = process.argv.slice(2);
97
49
  if (argv[0] === "--help" || argv[0] === "-h") {
98
- process.stdout.write(help);
50
+ process.stdout.write(topLevelHelp());
51
+ return;
52
+ }
53
+ // Recognised before parsing, not during it: the parser is strict and has no
54
+ // `help` option, so `muster setup-identity --help` used to be answered with
55
+ // "Unknown option '--help'". Only in second position, so a `--help` that is
56
+ // some flag's value still reaches the command it was written for.
57
+ if ((argv[1] === "--help" || argv[1] === "-h") && commandHelp(argv[0])) {
58
+ process.stdout.write(commandHelp(argv[0]));
99
59
  return;
100
60
  }
101
61
  if (argv[0] === "--version" || argv[0] === "-v") {
@@ -125,7 +85,7 @@ async function main() {
125
85
  n,
126
86
  p.identities,
127
87
  ])));
128
- process.stdout.write(help +
88
+ process.stdout.write(topLevelHelp() +
129
89
  "\n" +
130
90
  statusText(view.identities, (await muster.summary()).sessions));
131
91
  }
@@ -263,9 +223,23 @@ async function main() {
263
223
  else if (command === "setup-identity") {
264
224
  if (positionals.length)
265
225
  throw new Error("setup-identity takes no positional arguments");
266
- const { identity, agent, "token-env": tokenEnv, } = values;
267
- if (!identity || !agent)
268
- throw new Error("setup-identity requires --identity and --agent");
226
+ const { "token-env": tokenEnv } = values;
227
+ let { identity, agent } = values;
228
+ if (!identity || !agent) {
229
+ // Asking is what `--interactive` already implied. Without it the
230
+ // command is the unattended route and still requires both flags, and
231
+ // with it but no terminal there is nobody to ask — which is a different
232
+ // problem from having forgotten a flag, so it says so.
233
+ if (!values.interactive)
234
+ throw new Error("setup-identity requires --identity and --agent");
235
+ if (!process.stdin.isTTY)
236
+ throw new Error("--interactive needs a terminal to ask for --identity and --agent; pass both as flags for an unattended run");
237
+ ({ identity, agent } = await promptForMissing({
238
+ identity,
239
+ agent,
240
+ ask: terminalReader(),
241
+ }));
242
+ }
269
243
  if (values.interactive) {
270
244
  // Refused, not ranked. These are the two mutually exclusive credential
271
245
  // routes, and the interactive branch is taken before tokenEnv is read:
@@ -324,7 +298,7 @@ async function main() {
324
298
  process.stdout.write(await muster.output(id));
325
299
  }
326
300
  else
327
- throw new Error(help);
301
+ throw new Error(topLevelHelp());
328
302
  }
329
303
  finally {
330
304
  await close();
package/dist/prompt.js ADDED
@@ -0,0 +1,50 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ export const AGENTS = ["codex", "claude", "opencode"];
3
+ /** The store's own rule (`identity-store.ts`), repeated here so a name this
4
+ * accepts cannot be rejected a moment later by the thing it is typed for. */
5
+ const NAME = /^[a-zA-Z0-9_-]+$/;
6
+ const ATTEMPTS = 3;
7
+ async function until(ask, question, parse, complaint, write) {
8
+ for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
9
+ const value = parse((await ask(question)).trim());
10
+ if (value !== undefined)
11
+ return value;
12
+ if (attempt < ATTEMPTS)
13
+ write(`${complaint}\n`);
14
+ }
15
+ throw new Error(`${complaint} — no valid answer after three attempts.`);
16
+ }
17
+ /**
18
+ * Fills in what `setup-identity --interactive` was not given.
19
+ *
20
+ * `--interactive` used to mean only that the login runs here; `--identity` and
21
+ * `--agent` were still required, so the way a person naturally types the
22
+ * command was answered with "setup-identity requires --identity and --agent".
23
+ * Asking is what "interactive" already implied. Anything passed as a flag is
24
+ * kept as-is and not asked about, so a scripted invocation is unchanged.
25
+ */
26
+ export async function promptForMissing(opts) {
27
+ const write = opts.write ?? ((text) => process.stderr.write(text));
28
+ const identity = opts.identity ??
29
+ (await until(opts.ask, "Identity name: ", (answer) => (NAME.test(answer) ? answer : undefined), "An identity name is letters, digits, dash or underscore", write));
30
+ const agent = opts.agent ??
31
+ (await until(opts.ask, `Agent [${AGENTS.join("/")}]: `, (answer) => AGENTS.includes(answer)
32
+ ? answer
33
+ : undefined, `An agent is one of ${AGENTS.join(", ")}`, write));
34
+ return { identity, agent };
35
+ }
36
+ /** The real terminal. Kept apart from the logic above so the logic needs none. */
37
+ export function terminalReader() {
38
+ return async (question) => {
39
+ const rl = createInterface({
40
+ input: process.stdin,
41
+ output: process.stderr,
42
+ });
43
+ try {
44
+ return await rl.question(question);
45
+ }
46
+ finally {
47
+ rl.close();
48
+ }
49
+ };
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.7.17",
3
+ "version": "0.7.18",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {