@brutalsystems/muster 0.7.17 → 0.7.19
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 +4 -0
- package/dist/help.js +195 -0
- package/dist/identity-cli.js +20 -9
- package/dist/identity-login.js +28 -3
- package/dist/muster.js +40 -58
- package/dist/prompt.js +50 -0
- package/package.json +3 -2
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/identity-cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { identityAuthState } from "./identity-auth.js";
|
|
2
2
|
import { IdentityError, identityDirExists, identityMetaSchema, identityNameSchema, identityPath, listIdentities, readIdentity, writeIdentity, } from "./identity-store.js";
|
|
3
3
|
import { removeStoredToken, writeStoredToken, } from "./identity-token.js";
|
|
4
|
-
import { captureClaudeToken, redactToken, } from "./identity-login.js";
|
|
4
|
+
import { captureClaudeToken, redactToken, resolveOnPath, } from "./identity-login.js";
|
|
5
5
|
/**
|
|
6
6
|
* The one-time login for each agent. Muster never performs it: each is an
|
|
7
7
|
* interactive OAuth flow, and a command that appeared to do it for you would be
|
|
@@ -25,7 +25,12 @@ export async function setupIdentity(opts) {
|
|
|
25
25
|
// a codex or opencode identity, where the credential is an auth file the
|
|
26
26
|
// agent's own login writes into the template, it has exactly one effect: a new
|
|
27
27
|
// way to fail. Refused where the operator types it.
|
|
28
|
-
|
|
28
|
+
// Checked against undefined, not truthiness: `--token-env ""` is falsy, so a
|
|
29
|
+
// truthiness test let an empty name past every guard and then dropped it,
|
|
30
|
+
// leaving the identity on neither credential route without saying so (#69).
|
|
31
|
+
if (opts.tokenEnv !== undefined && opts.tokenEnv.trim() === "")
|
|
32
|
+
throw new IdentityError(opts.name, "--token-env takes the name of an environment variable holding the token, not the token and not an empty value");
|
|
33
|
+
if (opts.tokenEnv !== undefined && opts.agent !== "claude")
|
|
29
34
|
throw new IdentityError(name, `--token-env applies only to a claude identity; ${opts.agent} authenticates from an auth file in the template, and a token variable here would only add a way for the launch to fail`);
|
|
30
35
|
// Distinguish "no such identity" from "an identity that is there but unusable".
|
|
31
36
|
// Overwriting the second would silently repurpose a directory that still holds
|
|
@@ -150,24 +155,30 @@ export async function interactiveSetup(opts) {
|
|
|
150
155
|
stored: false,
|
|
151
156
|
note: `Run this once, then check with \`muster identities\`:\n\n ${created.login}\n`,
|
|
152
157
|
};
|
|
158
|
+
// Asked before anything is spawned, so "could not be started" is a fact about
|
|
159
|
+
// the filesystem rather than an inference from what the child printed. That
|
|
160
|
+
// inference was platform-dependent: a missing executable emits nothing under
|
|
161
|
+
// macOS and an error into the pty under Linux (#69).
|
|
162
|
+
if (!(await resolveOnPath("claude", opts.env ?? process.env)))
|
|
163
|
+
throw new IdentityError(opts.name, "`claude` is not on PATH, so the login could not be started; nothing was changed");
|
|
153
164
|
write(`Running: claude setup-token\n${"─".repeat(50)}\n`);
|
|
154
165
|
const result = await captureClaudeToken({
|
|
155
166
|
dir: created.path,
|
|
156
167
|
env: opts.env ?? process.env,
|
|
157
168
|
onData: write,
|
|
158
|
-
|
|
169
|
+
stdin: opts.stdin,
|
|
159
170
|
});
|
|
160
171
|
write(`${"─".repeat(50)}\n`);
|
|
161
172
|
// Checked BEFORE the token branch, deliberately: a login that failed writes
|
|
162
173
|
// no credential even if something token-shaped went past on the way down.
|
|
163
174
|
//
|
|
164
|
-
//
|
|
165
|
-
// not throw for a missing executable — it exits 1 having emitted zero
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
175
|
+
// Two different events land here and they need different explanations. node-pty
|
|
176
|
+
// does not throw for a missing executable — it exits 1 having emitted zero
|
|
177
|
+
// bytes — which is exactly the exit shape of a login that failed on its own or
|
|
178
|
+
// that the operator interrupted. The exit code cannot tell them apart; whether
|
|
179
|
+
// anything was ever printed can (#69).
|
|
169
180
|
if (result.exitCode !== 0)
|
|
170
|
-
throw new IdentityError(opts.name, `\`claude setup-token\`
|
|
181
|
+
throw new IdentityError(opts.name, `\`claude setup-token\` ended without storing a credential (exit ${result.exitCode}); its own output above is the error. If you interrupted it, nothing was stored and nothing was changed.`);
|
|
171
182
|
if (result.token) {
|
|
172
183
|
const { clearedRoute } = await storeIdentityToken({
|
|
173
184
|
home: opts.home,
|
package/dist/identity-login.js
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
import { access, constants } from "node:fs/promises";
|
|
2
|
+
import { delimiter, join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Where `name` is on PATH, or undefined. The same lookup `tmuxExecutable` does,
|
|
5
|
+
* and the reason the login asks it: whether a child "could not be started" has
|
|
6
|
+
* to be decided before spawning, not inferred from what it printed. node-pty
|
|
7
|
+
* does not throw for a missing executable — it exits 1, emitting nothing on
|
|
8
|
+
* macOS and an error into the pty on Linux, so output was never the discriminator
|
|
9
|
+
* it appeared to be (#69).
|
|
10
|
+
*/
|
|
11
|
+
export async function resolveOnPath(name, env) {
|
|
12
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
13
|
+
if (!dir)
|
|
14
|
+
continue;
|
|
15
|
+
const candidate = join(dir, name);
|
|
16
|
+
try {
|
|
17
|
+
await access(candidate, constants.X_OK);
|
|
18
|
+
return candidate;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Not here, or not executable. Keep looking.
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
1
26
|
/**
|
|
2
27
|
* A Claude long-lived OAuth token, as `claude setup-token` prints it. The length
|
|
3
28
|
* floor is what separates a real token from the literal word in help text.
|
|
@@ -257,8 +282,8 @@ function attachInput(pty, input, signals) {
|
|
|
257
282
|
* status quo.
|
|
258
283
|
*/
|
|
259
284
|
export async function captureClaudeToken(opts) {
|
|
260
|
-
const
|
|
261
|
-
const pty =
|
|
285
|
+
const spawn = opts.spawn ?? (await import("node-pty")).spawn;
|
|
286
|
+
const pty = spawn("claude", ["setup-token"], {
|
|
262
287
|
cwd: opts.dir,
|
|
263
288
|
env: { ...opts.env, CLAUDE_CONFIG_DIR: opts.dir },
|
|
264
289
|
name: "xterm-256color",
|
|
@@ -278,7 +303,7 @@ export async function captureClaudeToken(opts) {
|
|
|
278
303
|
// up before rethrowing, and enters raw mode last of all.
|
|
279
304
|
let detachInput = () => { };
|
|
280
305
|
try {
|
|
281
|
-
detachInput = attachInput(pty, opts.stdin
|
|
306
|
+
detachInput = attachInput(pty, opts.stdin, opts.signals ?? process);
|
|
282
307
|
const filter = createTokenFilter(opts.onData);
|
|
283
308
|
const dataSub = pty.onData((chunk) => filter.push(chunk));
|
|
284
309
|
const exitCode = await new Promise((resolve) => {
|
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(
|
|
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(
|
|
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 {
|
|
267
|
-
|
|
268
|
-
|
|
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:
|
|
@@ -273,13 +247,21 @@ async function main() {
|
|
|
273
247
|
// year-long token to disk on the machine the operator explicitly asked
|
|
274
248
|
// to keep no credential at rest, while never recording the variable
|
|
275
249
|
// they named.
|
|
276
|
-
if (tokenEnv)
|
|
250
|
+
if (tokenEnv !== undefined && agent !== "claude")
|
|
251
|
+
// Not the mutual-exclusion message: for codex and opencode
|
|
252
|
+
// `--interactive` captures nothing and `--token-env` does not apply
|
|
253
|
+
// at all, so describing them as two routes to choose between would
|
|
254
|
+
// be describing Claude's behaviour at a different agent (#69).
|
|
255
|
+
throw new IdentityError(identity, `--token-env applies only to a claude identity; ${agent} authenticates from an auth file in the template, and a token variable here would only add a way for the launch to fail`);
|
|
256
|
+
if (tokenEnv !== undefined)
|
|
277
257
|
throw new IdentityError(identity, "--interactive and --token-env are the two mutually exclusive credential routes: --interactive runs the login and stores a token in the identity, --token-env records the name of a variable to read one from instead. Pass one, not both.");
|
|
278
258
|
const result = await interactiveSetup({
|
|
279
259
|
home: musterHome(),
|
|
280
260
|
name: identity,
|
|
281
261
|
agent: agent,
|
|
282
262
|
isTTY: Boolean(process.stdin.isTTY),
|
|
263
|
+
// The one place the operator's real keyboard is named.
|
|
264
|
+
stdin: process.stdin,
|
|
283
265
|
});
|
|
284
266
|
process.stdout.write(`\n${result.note}\n`);
|
|
285
267
|
return;
|
|
@@ -291,7 +273,7 @@ async function main() {
|
|
|
291
273
|
home: musterHome(),
|
|
292
274
|
name: identity,
|
|
293
275
|
agent: agent,
|
|
294
|
-
...(tokenEnv ? { tokenEnv } : {}),
|
|
276
|
+
...(tokenEnv !== undefined ? { tokenEnv } : {}),
|
|
295
277
|
})) + "\n");
|
|
296
278
|
}
|
|
297
279
|
else if (command === "identities") {
|
|
@@ -324,7 +306,7 @@ async function main() {
|
|
|
324
306
|
process.stdout.write(await muster.output(id));
|
|
325
307
|
}
|
|
326
308
|
else
|
|
327
|
-
throw new Error(
|
|
309
|
+
throw new Error(topLevelHelp());
|
|
328
310
|
}
|
|
329
311
|
finally {
|
|
330
312
|
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.
|
|
3
|
+
"version": "0.7.19",
|
|
4
4
|
"description": "Launch instructed, reachable local agent sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"scripts": {
|
|
13
13
|
"build": "tsc && node scripts/prepare-bin.mjs",
|
|
14
14
|
"test": "vitest run",
|
|
15
|
-
"
|
|
15
|
+
"typecheck": "tsc -p tsconfig.check.json",
|
|
16
|
+
"check": "npm run typecheck && npm test && node scripts/check-tarball.mjs",
|
|
16
17
|
"test:contract": "MUSTER_CONTRACT=1 vitest run test/contract.test.ts",
|
|
17
18
|
"postinstall": "node scripts/prepare-pty.mjs",
|
|
18
19
|
"pretest": "npm run build",
|