@scp-dev/cli 0.0.0-stage → 0.1.2

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.
@@ -0,0 +1,359 @@
1
+ // src/term.ts
2
+ import { spawnSync } from "child_process";
3
+ import { CLI_NAME } from "@scp-dev/core";
4
+ var FAILED = 125;
5
+ var SELF = [process.execPath, ...process.versions.bun ? ["--no-env-file", "--config=/dev/null"] : [], process.argv[1]];
6
+ function capture(argv, env) {
7
+ const result = spawnSync(argv[0], argv.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], ...env ? { env } : {} });
8
+ return result.status === 0 ? result.stdout : void 0;
9
+ }
10
+ function note(message) {
11
+ process.stderr.write(`${message.startsWith(`${CLI_NAME} `) ? "" : `${CLI_NAME}: `}${message}
12
+ `);
13
+ }
14
+ async function exitFlushed(code, waitMs = 1e4) {
15
+ const ended = (stream) => new Promise((resolve2) => {
16
+ try {
17
+ stream.end(() => resolve2());
18
+ } catch {
19
+ resolve2();
20
+ }
21
+ });
22
+ let timer;
23
+ await Promise.race([Promise.all([ended(process.stdout), ended(process.stderr)]), new Promise((resolve2) => timer = setTimeout(resolve2, waitMs))]);
24
+ clearTimeout(timer);
25
+ process.exit(code);
26
+ }
27
+ function printTable(rows) {
28
+ const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
29
+ for (const row of rows)
30
+ console.log(
31
+ row.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd()
32
+ );
33
+ }
34
+ var DURATION_PART = /(\d+(?:\.\d+)?)(ms|s|m|h|d)/g;
35
+ var DURATION_UNITS = { ms: 1e-3, s: 1, m: 60, h: 3600, d: 86400 };
36
+ function parseSeconds(text, flag) {
37
+ const trimmed = text.trim();
38
+ let seconds;
39
+ if (/^\d+(?:\.\d+)?$/.test(trimmed)) seconds = Number(trimmed);
40
+ else {
41
+ seconds = 0;
42
+ let consumed = "";
43
+ for (const match of trimmed.matchAll(DURATION_PART)) {
44
+ seconds += Number(match[1]) * DURATION_UNITS[match[2]];
45
+ consumed += match[0];
46
+ }
47
+ if (consumed !== trimmed || consumed === "") {
48
+ throw new Error(`${flag} ${JSON.stringify(text)} is not a duration like 90s, 30m or 1h30m`);
49
+ }
50
+ }
51
+ if (!(seconds > 0)) throw new Error(`${flag} must be more than zero`);
52
+ return Math.max(1, Math.ceil(seconds));
53
+ }
54
+
55
+ // src/identity.ts
56
+ import { existsSync, realpathSync } from "fs";
57
+ import { tmpdir } from "os";
58
+ import { basename, dirname, isAbsolute, join, resolve } from "path";
59
+ import { shortHostname } from "@scp-dev/broker";
60
+ import { LIMITS, validHandle, validHarness } from "@scp-dev/core";
61
+ var SHORT = { "claude-code": "claude" };
62
+ var SHELLS = /* @__PURE__ */ new Set(["sh", "bash", "zsh", "dash", "fish"]);
63
+ var LABEL_KEY = /^[a-z0-9][a-z0-9._-]{0,31}$/;
64
+ function detectHarness(env) {
65
+ if (env.GROK_SESSION_ID || env.GROK_HOOK_EVENT) return "grok";
66
+ if (env.MUSE_SESSION_ID) return "muse";
67
+ if (env.OPENCODE === "1") return "opencode";
68
+ if (env.ANTIGRAVITY_AGENT === "1" || env.ANTIGRAVITY_CONVERSATION_ID) return "agy";
69
+ if (env.CLAUDECODE === "1") return "claude-code";
70
+ if (env.CODEX_THREAD_ID) return "codex";
71
+ return void 0;
72
+ }
73
+ function normalizeHarness(raw, fallback) {
74
+ const name = (raw ?? "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+/, "").slice(0, 32).replace(/-+$/, "");
75
+ if (name === "claude") return "claude-code";
76
+ return validHarness(name) ? name : fallback;
77
+ }
78
+ function hookHarness(argument, env) {
79
+ if (!argument) return detectHarness(env) ?? "hook";
80
+ const named = normalizeHarness(argument, "hook");
81
+ return named === "claude-code" && (env.GROK_SESSION_ID || env.GROK_HOOK_EVENT) ? "grok" : named;
82
+ }
83
+ function resolveHarness(explicit, env, fallback) {
84
+ const named = explicit ?? env.SCP_HARNESS;
85
+ return named ? normalizeHarness(named, fallback) : detectHarness(env) ?? normalizeHarness(fallback, "mcp");
86
+ }
87
+ function gitToplevel(cwd) {
88
+ for (let dir = resolve(cwd); ; dir = dirname(dir)) {
89
+ if (existsSync(join(dir, ".git"))) return dir;
90
+ if (dirname(dir) === dir) return void 0;
91
+ }
92
+ }
93
+ function toHandle(raw) {
94
+ let handle = raw.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+/, "").slice(0, 63).replace(/[-.]+$/, "");
95
+ if (!handle) handle = "agent";
96
+ if (!validHandle(handle)) handle = `${handle.slice(0, 56)}-agent`;
97
+ return handle;
98
+ }
99
+ function deriveHandle(harness, cwd) {
100
+ const top = gitToplevel(cwd) ?? resolve(cwd);
101
+ return toHandle(`${SHORT[harness] ?? harness}-${basename(top) || "root"}`);
102
+ }
103
+ function parseLabels(value) {
104
+ const labels = {};
105
+ for (const pair of (value ?? "").split(",")) {
106
+ const eq = pair.indexOf("=");
107
+ const key = pair.slice(0, eq).trim();
108
+ if (eq > 0 && LABEL_KEY.test(key)) labels[key] = pair.slice(eq + 1).trim();
109
+ }
110
+ return labels;
111
+ }
112
+ function labelsFor(env, host = shortHostname()) {
113
+ const labels = parseLabels(env.SCP_LABELS);
114
+ labels.machine ??= host;
115
+ return labels;
116
+ }
117
+ function processKey(harnessPid2, startOf = psStart) {
118
+ const started = startOf(harnessPid2);
119
+ return started ? `pid:${harnessPid2}:${started}` : `pid:${harnessPid2}`;
120
+ }
121
+ function sessionKey(env, harnessProcess) {
122
+ return env.SCP_SESSION ? `env:${env.SCP_SESSION}` : harnessProcess;
123
+ }
124
+ function psStart(pid) {
125
+ try {
126
+ const started = (capture(["ps", "-o", "lstart=", "-p", String(pid)], { PATH: process.env.PATH ?? "/usr/bin:/bin", LC_ALL: "C" }) ?? "").trim().replace(/\s+/g, "-");
127
+ return /^[A-Za-z0-9:-]{1,64}$/.test(started) ? started : void 0;
128
+ } catch {
129
+ return void 0;
130
+ }
131
+ }
132
+ function parsePsCommTable(text) {
133
+ const table = /* @__PURE__ */ new Map();
134
+ for (const line of text.split("\n")) {
135
+ const match = /^\s*(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(\S.*?)\s*$/.exec(line);
136
+ if (match) table.set(Number(match[1]), { ppid: Number(match[2]), start: match[3].replace(/\s+/g, "-"), args: match[4] });
137
+ }
138
+ return table;
139
+ }
140
+ function readPsTable() {
141
+ try {
142
+ const table = capture(["ps", "-ww", "-axo", "pid=,ppid=,lstart=,args="], { PATH: process.env.PATH ?? "/usr/bin:/bin", LC_ALL: "C" });
143
+ return table === void 0 ? void 0 : parsePsCommTable(table);
144
+ } catch {
145
+ return void 0;
146
+ }
147
+ }
148
+ var PASS_THROUGH = /* @__PURE__ */ new Set([
149
+ ...SHELLS,
150
+ "ash",
151
+ "ksh",
152
+ "env",
153
+ "timeout",
154
+ "nohup",
155
+ "nice",
156
+ "time",
157
+ "stdbuf",
158
+ "xargs",
159
+ "script",
160
+ "caffeinate",
161
+ "flock",
162
+ "sandbox-exec",
163
+ "bwrap",
164
+ "codex-linux-sandbox",
165
+ "codex-linux-san"
166
+ ]);
167
+ var INTERPRETERS = /* @__PURE__ */ new Set(["node", "nodejs", "bun", "deno", "ruby", "perl"]);
168
+ var RUNNERS = /* @__PURE__ */ new Set(["npm", "pnpm", "yarn"]);
169
+ var FETCHERS = /* @__PURE__ */ new Set(["npx", "bunx", "pnpx", "uvx"]);
170
+ var SCRIPT_RUNNERS = /* @__PURE__ */ new Set(["tsx", "ts-node", "vite-node", "esno"]);
171
+ var INLINE = /* @__PURE__ */ new Set(["-c", "-e", "--eval", "-p", "--print", "-"]);
172
+ var VALUED = /* @__PURE__ */ new Set(["-r", "--require", "--import", "--loader", "--experimental-loader", "-I", "-M", "-W", "-X", "-C", "--conditions", "--preload"]);
173
+ var HARNESSES = /* @__PURE__ */ new Set([
174
+ "claude",
175
+ "claude-code",
176
+ "codex",
177
+ "opencode",
178
+ "opencode-ai",
179
+ "grok",
180
+ "grok-cli",
181
+ "muse",
182
+ "agy",
183
+ "antigravity",
184
+ "gemini",
185
+ "gemini-cli"
186
+ ]);
187
+ var INSTALLED = /(^|\/)(node_modules|site-packages|dist-packages|\.npm|\.bun|\.cache|\.local|\.cargo|\.volta|\.nvm|\.pyenv|\.rbenv|Cellar|Applications)(\/|$)|^\/(opt|usr|Library|nix)\//;
188
+ function command(args) {
189
+ return basename(args.split(/\s+/)[0].replace(/^-/, "")).toLowerCase();
190
+ }
191
+ var namesHarness = (words) => words.some((word) => !word.startsWith("-") && word.split("/").some((part) => HARNESSES.has(part.toLowerCase().replace(/\..*$/, ""))));
192
+ var roots;
193
+ function workspaceRoots() {
194
+ if (roots) return roots;
195
+ const real = (path) => {
196
+ try {
197
+ return realpathSync(path);
198
+ } catch {
199
+ return path;
200
+ }
201
+ };
202
+ const found = [process.cwd(), tmpdir(), "/tmp", "/private/tmp", "/var/folders", "/private/var/folders"];
203
+ try {
204
+ const top = capture(["git", "rev-parse", "--show-toplevel"]);
205
+ if (top !== void 0) found.push(top.trim());
206
+ } catch {
207
+ }
208
+ roots = [...new Set(found.filter(Boolean).map(real))];
209
+ return roots;
210
+ }
211
+ function ownScript(operand) {
212
+ if (operand === void 0 || operand === "-") return true;
213
+ if (/^[a-z][a-z0-9+.-]*:/i.test(operand) || operand.startsWith("~") || INSTALLED.test(operand)) return false;
214
+ if (!isAbsolute(operand)) return !operand.split("/").includes("..");
215
+ let path = operand;
216
+ try {
217
+ path = realpathSync(operand);
218
+ } catch {
219
+ }
220
+ if (INSTALLED.test(path)) return false;
221
+ return workspaceRoots().some((root) => path === root || path.startsWith(`${root}/`));
222
+ }
223
+ function helper(args) {
224
+ const words = args.split(/\s+/);
225
+ if (namesHarness(words.slice(1))) return false;
226
+ const name = command(args);
227
+ let rest = words.slice(1);
228
+ if (FETCHERS.has(name)) {
229
+ while (rest[0]?.startsWith("-")) rest = rest.slice(1);
230
+ return SCRIPT_RUNNERS.has(rest[0] ?? "") && rest.slice(1).every((word) => word.startsWith("-") || ownScript(word));
231
+ }
232
+ if (name === "uv" || RUNNERS.has(name)) {
233
+ if (rest[0] !== "run") return false;
234
+ if (name === "uv") {
235
+ rest = rest.slice(1);
236
+ while (rest[0]?.startsWith("-")) rest = rest.slice(1);
237
+ if (rest[0] === void 0) return true;
238
+ const inner = rest.join(" ");
239
+ return /^python[0-9.]*$/.test(command(inner)) || INTERPRETERS.has(command(inner)) ? helper(inner) : ownScript(rest[0]);
240
+ }
241
+ return true;
242
+ }
243
+ if (!INTERPRETERS.has(name) && !/^python[0-9.]*$/.test(name)) return false;
244
+ if (name === "bun" && rest[0] === "x") return false;
245
+ if ((name === "bun" || name === "deno") && rest[0] === "run") {
246
+ rest = rest.slice(1);
247
+ if (name === "deno") while (rest[0]?.startsWith("-")) rest = rest.slice(1);
248
+ const target = rest.find((word) => !word.startsWith("-"));
249
+ if (target !== void 0 && !/[/.]/.test(target)) return false;
250
+ }
251
+ for (let i = 0; i < rest.length; i++) {
252
+ const word = rest[i];
253
+ if (word === "-m") return false;
254
+ if (INLINE.has(word)) return true;
255
+ if (VALUED.has(word)) {
256
+ if (!ownScript(rest[++i])) return false;
257
+ continue;
258
+ }
259
+ if (!word.startsWith("-")) return ownScript(word);
260
+ }
261
+ return true;
262
+ }
263
+ function harnessLineage(pid, table = readPsTable()) {
264
+ const keys = lineage(pid, table);
265
+ let first = -1;
266
+ for (let i = 0, at = pid; i < keys.length; i++, at = table.get(at).ppid) {
267
+ const args = table.get(at).args;
268
+ if (PASS_THROUGH.has(command(args))) continue;
269
+ if (!helper(args)) return keys.slice(0, i + 1);
270
+ if (first < 0) first = i;
271
+ }
272
+ return first < 0 ? [] : keys;
273
+ }
274
+ var INITS = /* @__PURE__ */ new Set(["init", "systemd", "launchd"]);
275
+ function hiddenHarness(pid, table = readPsTable()) {
276
+ if (!table || harnessLineage(pid, table).length) return false;
277
+ const seen = /* @__PURE__ */ new Set();
278
+ let at = pid;
279
+ while (at > 1 && !seen.has(at)) {
280
+ seen.add(at);
281
+ const entry = table.get(at);
282
+ if (!entry) return false;
283
+ at = entry.ppid;
284
+ }
285
+ const init = table.get(1);
286
+ return at === 1 && init !== void 0 && !INITS.has(command(init.args));
287
+ }
288
+ function lineage(harnessPid2, table = readPsTable()) {
289
+ const keys = [];
290
+ const seen = /* @__PURE__ */ new Set();
291
+ for (let pid = harnessPid2; pid > 1 && keys.length < LIMITS.lineageMax && !seen.has(pid); ) {
292
+ const entry = table?.get(pid);
293
+ if (!entry) break;
294
+ seen.add(pid);
295
+ keys.push(/^[A-Za-z0-9:-]{1,64}$/.test(entry.start) ? `pid:${pid}:${entry.start}` : `pid:${pid}`);
296
+ pid = entry.ppid;
297
+ }
298
+ return keys;
299
+ }
300
+ function spawnerKey(pid = process.pid, table = readPsTable()) {
301
+ const key = lineage(pid, table)[0];
302
+ return key?.startsWith(`pid:${pid}:`) ? key : void 0;
303
+ }
304
+ function psParent(pid) {
305
+ const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(capture(["ps", "-o", "ppid=,comm=", "-p", String(pid)], { PATH: process.env.PATH ?? "/usr/bin:/bin" }) ?? "");
306
+ return match ? { ppid: Number(match[1]), comm: match[2] } : void 0;
307
+ }
308
+ function harnessPid(ppid = process.ppid, parentOf = psParent) {
309
+ const parent = parentOf(ppid);
310
+ if (parent && parent.ppid > 1 && SHELLS.has(basename(parent.comm.replace(/^-/, "")))) return parent.ppid;
311
+ return ppid;
312
+ }
313
+ function hookHarnessPid(env, harness, ppid = process.ppid, parentOf = psParent) {
314
+ const named = harness === "claude-code" ? env.CLAUDE_PID : harness === "opencode" ? env.OPENCODE_PID : void 0;
315
+ const pid = Number(named);
316
+ if (Number.isInteger(pid) && pid > 1) return pid;
317
+ return harnessPid(ppid, parentOf);
318
+ }
319
+ function relayIdentity(input) {
320
+ const harness = resolveHarness(input.harness, input.env, input.fallbackHarness);
321
+ const cwd = isAbsolute(input.cwd) ? input.cwd : resolve(input.cwd);
322
+ const handle = input.as ?? input.env.SCP_NAME ?? deriveHandle(harness, cwd);
323
+ const pid = input.harnessPid(harness);
324
+ const harnessProcess = processKey(pid, input.startOf);
325
+ const keys = input.lineageOf ? input.lineageOf(pid) : lineage(pid);
326
+ return {
327
+ handle,
328
+ harness,
329
+ cwd,
330
+ session: sessionKey(input.env, harnessProcess),
331
+ process: harnessProcess,
332
+ labels: labelsFor(input.env),
333
+ ...input.env.SCP_PARENT ? { parent: input.env.SCP_PARENT } : {},
334
+ ...keys.length ? { lineage: keys } : {},
335
+ ...input.env.SCP_GRANT ? { grant: input.env.SCP_GRANT } : {}
336
+ };
337
+ }
338
+
339
+ export {
340
+ FAILED,
341
+ SELF,
342
+ note,
343
+ exitFlushed,
344
+ printTable,
345
+ parseSeconds,
346
+ shortHostname,
347
+ detectHarness,
348
+ hookHarness,
349
+ gitToplevel,
350
+ labelsFor,
351
+ readPsTable,
352
+ harnessLineage,
353
+ hiddenHarness,
354
+ lineage,
355
+ spawnerKey,
356
+ harnessPid,
357
+ hookHarnessPid,
358
+ relayIdentity
359
+ };
@@ -0,0 +1,41 @@
1
+ // src/help-spawn.ts
2
+ import { CLI_NAME, LIMITS } from "@scp-dev/core";
3
+ var SPAWN_HELP = `${CLI_NAME} spawn [flags] [--] <harness cmd...>: run a worker agent under you with a relay grant (never printed)
4
+
5
+ e.g. ${CLI_NAME} spawn --name rev codex exec "review src/judge.ts, end with your findings"
6
+ ${CLI_NAME} spawn claude -p "list the open TODOs in packages/cli"
7
+
8
+ --name N its address (default <harness>-<5 random characters>)
9
+ --title T its title until it sets its own (default the prompt's first line)
10
+ --no-push your stop does not wait on it; its messages and handback still reach you after your tool
11
+ calls or with another worker's push
12
+ --bg | --fg --bg (the default in an agent's shell or without a terminal on stdout) returns at once;
13
+ --fg (the default at a human's terminal) runs it in the foreground, passing on its output
14
+ and exit code: scripts that need the exit code pass --fg
15
+ --handback-bytes N hand back only the last N bytes of its final output (1 to ${LIMITS.handbackBytes})
16
+ --peek local peekable from its start
17
+ --scope P --rules S --in S --out S --allow S --group G --under EP --ttl S --labels K=V,...
18
+ its scope and grant, as for ${CLI_NAME} spawn-env (default scope: parent)
19
+ The command starts after -- or at the first word that is not one of these flags.
20
+
21
+ It returns at once, printing the worker's name and its runner's pid: do not background it (no &, no
22
+ run_in_background). A failure before the harness starts (bad flags, a refused grant, no such harness) fails the
23
+ spawn itself. Your stop waits for the worker's messages (hooks install --push-wait, default 600 s).
24
+ Handback: when the harness exits you get its exit code and its reply, cleaned and redacted (a claude -p worker:
25
+ all its replies since its prompt, from its transcript; other harnesses: their final output), and each of your
26
+ messages it never applied as steer not applied: <text>. A worker that never joined or exited non-zero also hands
27
+ back its last ${LIMITS.stderrBytes / 1024} KB of stderr.
28
+ Steer: ${CLI_NAME} send <name> <text>; a claude -p or codex exec prompt gets a note naming you and your steer key.
29
+ A codex exec worker that exited before a late steer is resumed with it (codex exec resume, at most twice).
30
+ Harnesses: codex gets -c overrides that hand it the grant (also behind env, timeout, nohup or nice); a ${CLI_NAME}
31
+ command from its workspace-write sandbox needs -c sandbox_workspace_write.network_access=true. codex exec
32
+ gets --skip-git-repo-check outside a git repo, and with its prompt in argv piped stdin only under --fg. Workers
33
+ message with the relay MCP tools (relay_send, relay_inbox, relay_reply). claude -p and grok -p get those tools allowed
34
+ unless you pass a permission flag. muse runs with --contributor-mode off unless you pass on. agy needs the allow rules ${CLI_NAME}
35
+ setup writes.
36
+
37
+ Full reference: ${CLI_NAME} help --all.`;
38
+
39
+ export {
40
+ SPAWN_HELP
41
+ };