@brutalsystems/muster 0.7.16 → 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
@@ -892,8 +896,12 @@ selected external tool may have its own write or network capabilities.
892
896
  Installed deliberately, in the one session that should hold spawn authority —
893
897
  never at user scope.
894
898
 
895
- Muster exposes `run`, `list`, `stop`, and `output` over stdio. No arguments or
896
- `mcp` starts the server. Diagnostics go to stderr, never protocol stdout.
899
+ Muster exposes `run`, `list`, `stop`, and `output` over stdio. `mcp` starts the
900
+ server, and so does no arguments when stdin is not a terminal — which is what an
901
+ MCP client provides. Typing `muster` at a terminal prints the usage text and a
902
+ short status instead, because a server waiting for JSON-RPC on stdin is
903
+ indistinguishable from a hang. `muster mcp` starts the server either way, so it
904
+ can still be driven by hand to debug it. Diagnostics go to stderr, never protocol stdout.
897
905
  Schemas match the CLI (`args` is the array of optional runtime arguments).
898
906
 
899
907
  For a single Codex session, use per-invocation configuration:
package/dist/format.js CHANGED
@@ -100,3 +100,29 @@ export function formatHuman(value, color = false) {
100
100
  : "No Muster runs.\n";
101
101
  return recordHuman(value, color) + "\n";
102
102
  }
103
+ /**
104
+ * What a human sees when they type `muster` with no arguments at a terminal.
105
+ *
106
+ * With no arguments Muster starts the MCP server, which from a terminal looks
107
+ * exactly like a hang — the process is waiting for JSON-RPC on stdin and has
108
+ * nothing to say. The server path is reached by a pipe, never a TTY, so a
109
+ * terminal can be answered without changing what any MCP client gets.
110
+ *
111
+ * The session count is what the registry recorded, not what is running: it is
112
+ * read without the refresh `list` performs, because that refresh also reaps a
113
+ * terminal entry's identity copy and reaches an OpenCode session over HTTP.
114
+ * Neither belongs in the output of typing a bare command, so the wording says
115
+ * "recorded" and points at `list` for the authoritative answer.
116
+ */
117
+ export function statusText(identities, sessions) {
118
+ const lines = [];
119
+ if (!identities.length)
120
+ lines.push("No identities yet — add one with:", " muster setup-identity --identity NAME --agent <codex|claude|opencode> --interactive");
121
+ else
122
+ lines.push(`${identities.length} ${identities.length === 1 ? "identity" : "identities"} — ` +
123
+ identities.map((i) => `${i.name} ${i.auth.state}`).join(", "));
124
+ lines.push(sessions === 0
125
+ ? "No recorded sessions."
126
+ : `${sessions} recorded session${sessions === 1 ? "" : "s"} (muster list refreshes)`, "", "Starting the MCP server? Use `muster mcp`.");
127
+ return lines.join("\n") + "\n";
128
+ }
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
@@ -7,67 +7,19 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
7
7
  import { z } from "zod";
8
8
  import { Muster } from "./run.js";
9
9
  import { delay } from "./identity/processes.js";
10
- import { formatHuman, humanColorEnabled } from "./format.js";
10
+ import { formatHuman, humanColorEnabled, statusText } from "./format.js";
11
11
  import { checkPlugins, npmRegistry } from "./doctor.js";
12
12
  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,21 +47,53 @@ 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") {
102
62
  process.stdout.write(`${version}\n`);
103
63
  return;
104
64
  }
105
- const mcp = argv.length === 0 || argv[0] === "mcp";
106
- if (mcp && argv.length > 1)
65
+ // No arguments starts the MCP server: a documented entry point, and how a
66
+ // client configured with `command: "muster"` and no args reaches it. From a
67
+ // terminal that is indistinguishable from a hang — the process is waiting for
68
+ // JSON-RPC on stdin and has nothing to say. A client always arrives down a
69
+ // pipe and never a TTY, so a person can be answered without changing what any
70
+ // client gets. `muster mcp` stays unconditional, so the server can still be
71
+ // driven by hand in a terminal to debug it.
72
+ const bareAtTerminal = argv.length === 0 && Boolean(process.stdin.isTTY);
73
+ const mcp = !bareAtTerminal && (argv.length === 0 || argv[0] === "mcp");
74
+ if (argv[0] === "mcp" && argv.length > 1)
107
75
  throw new Error("mcp takes no arguments");
108
76
  const muster = await Muster.create();
109
77
  let shutdown;
110
78
  const close = () => (shutdown ??= (async () => {
111
79
  await muster.close();
112
80
  })());
81
+ if (bareAtTerminal) {
82
+ try {
83
+ const cfg = await loadConfig();
84
+ const view = await describeIdentities(musterHome(), process.env, Object.fromEntries(Object.entries(cfg.requester_profiles).map(([n, p]) => [
85
+ n,
86
+ p.identities,
87
+ ])));
88
+ process.stdout.write(topLevelHelp() +
89
+ "\n" +
90
+ statusText(view.identities, (await muster.summary()).sessions));
91
+ }
92
+ finally {
93
+ await close();
94
+ }
95
+ return;
96
+ }
113
97
  for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"])
114
98
  process.once(signal, () => {
115
99
  void close().then(() => process.exit(0), (e) => {
@@ -239,9 +223,23 @@ async function main() {
239
223
  else if (command === "setup-identity") {
240
224
  if (positionals.length)
241
225
  throw new Error("setup-identity takes no positional arguments");
242
- const { identity, agent, "token-env": tokenEnv, } = values;
243
- if (!identity || !agent)
244
- 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
+ }
245
243
  if (values.interactive) {
246
244
  // Refused, not ranked. These are the two mutually exclusive credential
247
245
  // routes, and the interactive branch is taken before tokenEnv is read:
@@ -300,7 +298,7 @@ async function main() {
300
298
  process.stdout.write(await muster.output(id));
301
299
  }
302
300
  else
303
- throw new Error(help);
301
+ throw new Error(topLevelHelp());
304
302
  }
305
303
  finally {
306
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/dist/run.js CHANGED
@@ -823,6 +823,21 @@ export class Muster {
823
823
  * with the supported entry point (#44), which is also what would need to
824
824
  * choose between hiding other requesters' entries and redacting them.
825
825
  */
826
+ /**
827
+ * Counts for the status line a bare `muster` prints at a terminal.
828
+ *
829
+ * Deliberately not `list`. That refreshes every entry, and a refresh removes
830
+ * a terminal entry's identity copy and MCP config and reaches an OpenCode
831
+ * session over HTTP — none of which belongs in the output of typing a bare
832
+ * command. This reads the registry and changes nothing, so what it reports is
833
+ * what was recorded rather than what is running, and the caller says so.
834
+ */
835
+ async summary() {
836
+ const entries = await this.registry.all();
837
+ return {
838
+ sessions: entries.filter((e) => e.kind === "session" && ["starting", "running"].includes(e.status)).length,
839
+ };
840
+ }
826
841
  async list(kind) {
827
842
  const entries = await this.registry.all();
828
843
  const result = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.7.16",
3
+ "version": "0.7.18",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {