@sigrun/sigrun 0.0.0-stage → 0.1.1

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.
Files changed (130) hide show
  1. package/CHANGELOG.md +214 -0
  2. package/CONTEXT.md +163 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -2
  5. package/THIRD_PARTY_NOTICES.md +28 -0
  6. package/dist/backup/marker.js +80 -0
  7. package/dist/backup/run-backup.js +339 -0
  8. package/dist/cli.js +952 -0
  9. package/dist/commands/expand.js +245 -0
  10. package/dist/commands/templates.js +272 -0
  11. package/dist/config.js +1146 -0
  12. package/dist/extension.d.ts +431 -0
  13. package/dist/harness/activity.js +42 -0
  14. package/dist/harness/ambient.js +261 -0
  15. package/dist/harness/auth.js +130 -0
  16. package/dist/harness/boot.js +865 -0
  17. package/dist/harness/builtins.js +191 -0
  18. package/dist/harness/event-bus.js +1203 -0
  19. package/dist/harness/health.js +30 -0
  20. package/dist/harness/identity/index.js +37 -0
  21. package/dist/harness/identity/invites.js +94 -0
  22. package/dist/harness/identity/json-store.js +108 -0
  23. package/dist/harness/identity/setup-code.js +81 -0
  24. package/dist/harness/identity/tokens.js +101 -0
  25. package/dist/harness/identity/users.js +93 -0
  26. package/dist/harness/interactions.js +283 -0
  27. package/dist/harness/loader.js +1236 -0
  28. package/dist/harness/memory-default.js +35 -0
  29. package/dist/harness/naming.js +193 -0
  30. package/dist/harness/packages.js +1002 -0
  31. package/dist/harness/providers/auth-bridge.js +60 -0
  32. package/dist/harness/providers/index.js +277 -0
  33. package/dist/harness/providers/models-store.js +105 -0
  34. package/dist/harness/providers/refresh.js +161 -0
  35. package/dist/harness/providers/scheduler.js +89 -0
  36. package/dist/harness/registry.js +9 -0
  37. package/dist/harness/reserved-sections.js +24 -0
  38. package/dist/harness/seam-host.js +239 -0
  39. package/dist/harness/sections/context-files.js +78 -0
  40. package/dist/harness/sections/model-guidance.js +77 -0
  41. package/dist/harness/settings.js +1053 -0
  42. package/dist/harness/stores.js +450 -0
  43. package/dist/harness/tools.js +500 -0
  44. package/dist/harness/workspace.js +73 -0
  45. package/dist/index.js +39 -0
  46. package/dist/jobs/docs.js +257 -0
  47. package/dist/jobs/fire.js +342 -0
  48. package/dist/jobs/index.js +228 -0
  49. package/dist/jobs/observe.js +165 -0
  50. package/dist/jobs/reconcile.js +245 -0
  51. package/dist/jobs/schedule.js +77 -0
  52. package/dist/jobs/spec.js +248 -0
  53. package/dist/jobs/task.js +190 -0
  54. package/dist/jobs/transfer.js +53 -0
  55. package/dist/serve.js +164 -0
  56. package/dist/server/auth/claim-lock.js +28 -0
  57. package/dist/server/auth/ip.js +51 -0
  58. package/dist/server/auth/login-limiter.js +52 -0
  59. package/dist/server/auth/loopback.js +47 -0
  60. package/dist/server/auth/pipeline.js +330 -0
  61. package/dist/server/auth/principal.js +26 -0
  62. package/dist/server/commands.js +198 -0
  63. package/dist/server/content-parts.js +142 -0
  64. package/dist/server/context.js +13 -0
  65. package/dist/server/conversation-handler.js +61 -0
  66. package/dist/server/conversation-ops.js +310 -0
  67. package/dist/server/events/hub.js +39 -0
  68. package/dist/server/events/types.js +8 -0
  69. package/dist/server/helpers.js +351 -0
  70. package/dist/server/host-bridge.js +131 -0
  71. package/dist/server/index.js +232 -0
  72. package/dist/server/notifications.js +185 -0
  73. package/dist/server/ownership.js +67 -0
  74. package/dist/server/readiness.js +36 -0
  75. package/dist/server/routes/auth.js +172 -0
  76. package/dist/server/routes/conversations.js +341 -0
  77. package/dist/server/routes/events.js +161 -0
  78. package/dist/server/routes/extension-routes.js +127 -0
  79. package/dist/server/routes/extensions.js +168 -0
  80. package/dist/server/routes/files.js +308 -0
  81. package/dist/server/routes/interactions.js +260 -0
  82. package/dist/server/routes/jobs.js +271 -0
  83. package/dist/server/routes/meta.js +146 -0
  84. package/dist/server/routes/metrics.js +219 -0
  85. package/dist/server/routes/onboarding.js +268 -0
  86. package/dist/server/routes/probes.js +53 -0
  87. package/dist/server/routes/providers.js +198 -0
  88. package/dist/server/routes/settings.js +48 -0
  89. package/dist/server/routes/stream.js +131 -0
  90. package/dist/server/routes/submissions.js +317 -0
  91. package/dist/server/routes/users.js +169 -0
  92. package/dist/server/schema-fixture.js +950 -0
  93. package/dist/server/session-meta.js +247 -0
  94. package/dist/server/static.js +169 -0
  95. package/dist/server/views.js +71 -0
  96. package/dist/skills/index.js +418 -0
  97. package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
  98. package/node_modules/@sigrun/shared/LICENSE +21 -0
  99. package/node_modules/@sigrun/shared/README.md +10 -0
  100. package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
  101. package/node_modules/@sigrun/shared/dist/access.js +72 -0
  102. package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
  103. package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
  104. package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
  105. package/node_modules/@sigrun/shared/dist/commands.js +25 -0
  106. package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
  107. package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
  108. package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
  109. package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
  110. package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
  111. package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
  112. package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
  113. package/node_modules/@sigrun/shared/dist/json.js +13 -0
  114. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
  115. package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
  116. package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
  117. package/node_modules/@sigrun/shared/dist/settings.js +19 -0
  118. package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
  119. package/node_modules/@sigrun/shared/dist/themes.js +156 -0
  120. package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
  121. package/node_modules/@sigrun/shared/dist/turns.js +166 -0
  122. package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
  123. package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
  124. package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
  125. package/node_modules/@sigrun/shared/dist/views.js +38 -0
  126. package/node_modules/@sigrun/shared/package.json +83 -0
  127. package/package.json +89 -5
  128. package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
  129. package/webui/dist/assets/index-rOiARZLF.js +50 -0
  130. package/webui/dist/index.html +37 -0
package/dist/cli.js ADDED
@@ -0,0 +1,952 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The CLI entrypoints (Task 5): `sigrun` / `sigrun serve` / `sigrun attach`
4
+ * — the package's `bin` (`"sigrun": "dist/cli.js"`, plain node — build
5
+ * Task 3; the tsx shebang served the from-source era).
6
+ *
7
+ * - `sigrun` (run): boot harness+server IN-PROCESS (the `src/serve.ts`
8
+ * composition) and launch the TUI against the loopback URL of the bound
9
+ * port. The spec's lifecycle rule: the agent dies with the TUI — when the
10
+ * TUI exits, the server+harness shut down. SIGTERM/SIGINT also stop the
11
+ * in-process server — and, in run mode, exit the process once shutdown
12
+ * completes (fix wave A8: the TUI must never run against a dead server).
13
+ * - `sigrun serve`: the deploy composition minus the TUI — literally the
14
+ * `src/index.ts` entry (same graceful shutdown, same fail-loud boot).
15
+ * - `sigrun attach`: no harness boot; launch the TUI against the resolved
16
+ * server URL, carrying the resolved user into the client config.
17
+ *
18
+ * Build-and-webui Task 2: the TUI is a SPAWNED package, not an in-process
19
+ * import. `run`/`attach` spawn `node <resolved @sigrun/tui bin>` as a
20
+ * child process, passing the resolved server URL, user, and tui.json UI
21
+ * fields as `--flags` (the bin, `tui/dist/launch.js`, parses them itself).
22
+ * The parent watches the child's exit: exit → shutdown (agent-dies-with-
23
+ * tui), nonzero exit → exit 1. A package-resolution failure surfaces as
24
+ * an actionable `install @sigrun/tui` message, never a module-not-found
25
+ * crash — the core install is server-only unless the companion tui
26
+ * package is present. The old lazy `import("../tui/src/launch.js")` is
27
+ * gone: an in-process import would drag the tui workspace's runtime
28
+ * (pi-tui) into the core's resolution graph.
29
+ *
30
+ * Resolution chains (both `attach`; `run` resolves `user` the same way):
31
+ * - server: `--server` > tui.json `server` > `http://localhost:$SIGRUN_PORT`
32
+ * (default 3000)
33
+ * - user: `--user` > `SIGRUN_USER` > tui.json `user`
34
+ *
35
+ * The TUI entry (Task 6) is the `@sigrun/tui` package's `sigrun-tui` bin
36
+ * (`tui/dist/launch.js` — build-and-webui Task 2) — the CLI's default
37
+ * `launchTui` spawns it; everything else is real and tested through the
38
+ * seams (`test/cli.test.ts` injects factories — process-launch stays out
39
+ * of the unit tests, and `npm start` → src/index.ts is unchanged for the
40
+ * cluster's monolith).
41
+ */
42
+ import { spawn as nodeSpawn } from "node:child_process";
43
+ import { readFileSync, realpathSync } from "node:fs";
44
+ import { mkdir, readFile } from "node:fs/promises";
45
+ import { createRequire } from "node:module";
46
+ import { dirname, join } from "node:path";
47
+ import * as readline from "node:readline/promises";
48
+ import { Writable } from "node:stream";
49
+ import { resolveStoreRoots, writeFileAtomic } from "./harness/stores.js";
50
+ import { openIdentityStores } from "./harness/identity/index.js";
51
+ import { pathToFileURL } from "node:url";
52
+ import { serve } from "./serve.js";
53
+ import { ACCENT_NAMES, PALETTE_NAMES, isAccentName, isPaletteName, } from "@sigrun/shared/themes";
54
+ export const USAGE_TEXT = `usage: sigrun [--user <name>]
55
+ sigrun serve
56
+ sigrun attach [--server <url>] [--user <name>]
57
+ sigrun token create <name> --user <user>
58
+ sigrun token list
59
+ sigrun token revoke <id>
60
+ sigrun user add <user> [--admin] [--name <n>] [--email <e>]
61
+ sigrun user list
62
+ sigrun provider list [--url <base>] [--token <token>]
63
+ sigrun provider key <id> [--url <base>] [--token <token>]
64
+ sigrun provider clear <id> [--url <base>] [--token <token>]
65
+
66
+ provider commands are API clients: they talk to a running sigrun server
67
+ (--url <base>, default http://localhost:$SIGRUN_PORT or 3000) and never
68
+ start one; provider key reads the api key from stdin, never argv`;
69
+ /** The marker prefix on usage-error messages. */
70
+ const USAGE_ERROR_PREFIX = "usage:";
71
+ /**
72
+ * The pattern matching a usage error's message (tests assert parseArgs
73
+ * throws it; the dispatch prints the message + usage and exits 1).
74
+ */
75
+ export const USAGE_ERROR_PATTERN = /^usage:/;
76
+ function usageError(message) {
77
+ return new Error(`${USAGE_ERROR_PREFIX} ${message}\n${USAGE_TEXT}`);
78
+ }
79
+ /**
80
+ * Parse `token <sub> …` (Task 7): `create <name> --user <u>`, `list`,
81
+ * `revoke <id>`. The positional/flag grammar is strict — anything else
82
+ * is a usage error.
83
+ */
84
+ function parseTokenArgs(argv) {
85
+ const sub = argv[0];
86
+ if (sub === "list") {
87
+ if (argv.length !== 1) {
88
+ throw usageError("token list takes no arguments");
89
+ }
90
+ return { command: "token", sub: "list" };
91
+ }
92
+ if (sub === "revoke") {
93
+ if (argv.length !== 2) {
94
+ throw usageError("token revoke takes exactly one <id>");
95
+ }
96
+ return { command: "token", sub: "revoke", id: argv[1] };
97
+ }
98
+ if (sub === "create") {
99
+ let tokenName;
100
+ let user;
101
+ let index = 1;
102
+ while (index < argv.length) {
103
+ const arg = argv[index];
104
+ if (arg === "--user") {
105
+ const value = argv[index + 1];
106
+ if (value === undefined || value.startsWith("--")) {
107
+ throw usageError("--user requires a value");
108
+ }
109
+ user = value;
110
+ index += 2;
111
+ continue;
112
+ }
113
+ if (tokenName === undefined && !arg.startsWith("--")) {
114
+ tokenName = arg;
115
+ index += 1;
116
+ continue;
117
+ }
118
+ throw usageError(`unexpected argument ${JSON.stringify(arg)}`);
119
+ }
120
+ if (tokenName === undefined) {
121
+ throw usageError("token create requires a <name>");
122
+ }
123
+ if (user === undefined) {
124
+ throw usageError("token create requires --user");
125
+ }
126
+ return { command: "token", sub: "create", tokenName, user };
127
+ }
128
+ throw usageError(`unknown token subcommand ${JSON.stringify(sub ?? "")} (create, list, revoke)`);
129
+ }
130
+ /**
131
+ * Parse `user <sub> …` (Task 7): `add <user> [--admin] [--name <n>]
132
+ * [--email <e>]`, `list`. One positional (the username), flags anywhere.
133
+ */
134
+ function parseUserArgs(argv) {
135
+ const sub = argv[0];
136
+ if (sub === "list") {
137
+ if (argv.length !== 1) {
138
+ throw usageError("user list takes no arguments");
139
+ }
140
+ return { command: "user", sub: "list" };
141
+ }
142
+ if (sub === "add") {
143
+ let user;
144
+ let admin = false;
145
+ let name;
146
+ let email;
147
+ let index = 1;
148
+ while (index < argv.length) {
149
+ const arg = argv[index];
150
+ if (arg === "--admin") {
151
+ admin = true;
152
+ index += 1;
153
+ continue;
154
+ }
155
+ if (arg === "--name" || arg === "--email") {
156
+ const value = argv[index + 1];
157
+ if (value === undefined || value.startsWith("--")) {
158
+ throw usageError(`${arg} requires a value`);
159
+ }
160
+ if (arg === "--name")
161
+ name = value;
162
+ else
163
+ email = value;
164
+ index += 2;
165
+ continue;
166
+ }
167
+ if (user === undefined && !arg.startsWith("--")) {
168
+ user = arg;
169
+ index += 1;
170
+ continue;
171
+ }
172
+ throw usageError(`unexpected argument ${JSON.stringify(arg)}`);
173
+ }
174
+ if (user === undefined) {
175
+ throw usageError("user add requires a <user>");
176
+ }
177
+ return {
178
+ command: "user",
179
+ sub: "add",
180
+ user,
181
+ ...(admin ? { admin } : {}),
182
+ ...(name === undefined ? {} : { name }),
183
+ ...(email === undefined ? {} : { email }),
184
+ };
185
+ }
186
+ throw usageError(`unknown user subcommand ${JSON.stringify(sub ?? "")} (add, list)`);
187
+ }
188
+ /**
189
+ * Parse `provider <sub> …` (release 0.1 Task 2b): `list`, `key <id>`,
190
+ * `clear <id>`, each accepting `--url <base>` (a remote server; the
191
+ * default is `http://localhost:$SIGRUN_PORT` or 3000 — the attach
192
+ * shape) and `--token <t>` (the bearer for a token-method server).
193
+ * These commands are API clients: they NEVER start a server (the usage
194
+ * note says so).
195
+ */
196
+ function parseProviderArgs(argv) {
197
+ const sub = argv[0];
198
+ if (sub !== "list" && sub !== "key" && sub !== "clear") {
199
+ throw usageError(`unknown provider subcommand ${JSON.stringify(sub ?? "")} (list, key, clear)`);
200
+ }
201
+ let id;
202
+ let url;
203
+ let token;
204
+ let index = 1;
205
+ while (index < argv.length) {
206
+ const arg = argv[index];
207
+ if (arg === "--url" || arg === "--token") {
208
+ const value = argv[index + 1];
209
+ if (value === undefined || value.startsWith("--")) {
210
+ throw usageError(`${arg} requires a value`);
211
+ }
212
+ if (arg === "--url")
213
+ url = value;
214
+ else
215
+ token = value;
216
+ index += 2;
217
+ continue;
218
+ }
219
+ if (id === undefined && !arg.startsWith("--")) {
220
+ id = arg;
221
+ index += 1;
222
+ continue;
223
+ }
224
+ // NEVER echo the offending token (secret-hygiene, review P2): a
225
+ // `provider key acme THEKEY` typo puts the key on stderr — stderr
226
+ // lands in CI logs, issue reports and scrollback where shell history
227
+ // does not. Name the problem + the accepted shape, reproduce nothing.
228
+ throw usageError("unexpected argument — `provider <sub> [<id>] [--url <base>] [--token <token>]` takes one id at most, and reads the api key from stdin");
229
+ }
230
+ if (sub === "list" && id !== undefined) {
231
+ throw usageError("provider list takes no arguments");
232
+ }
233
+ if (sub !== "list" && id === undefined) {
234
+ throw usageError(`provider ${sub} requires an <id>`);
235
+ }
236
+ return {
237
+ command: "provider",
238
+ sub,
239
+ ...(id === undefined ? {} : { id }),
240
+ ...(url === undefined ? {} : { url }),
241
+ ...(token === undefined ? {} : { token }),
242
+ };
243
+ }
244
+ /**
245
+ * Parse argv (post-node slices — no `node`/`cli.ts` entries). A pure
246
+ * function: bare → `run`; `serve`/`attach` as subcommands; `token`/
247
+ * `user`/`provider` dispatch to their own sub-parsers (Task 7 + release
248
+ * 0.1 Task 2b — they must be the FIRST argument); `--user` is valid on
249
+ * every command, `--server` only on `attach`. Unknown subcommands/flags
250
+ * and missing flag values throw usage errors.
251
+ */
252
+ export function parseArgs(argv) {
253
+ if (argv[0] === "token")
254
+ return parseTokenArgs(argv.slice(1));
255
+ if (argv[0] === "user")
256
+ return parseUserArgs(argv.slice(1));
257
+ if (argv[0] === "provider")
258
+ return parseProviderArgs(argv.slice(1));
259
+ let command;
260
+ let server;
261
+ let user;
262
+ let index = 0;
263
+ while (index < argv.length) {
264
+ const arg = argv[index];
265
+ if (arg === "run" || arg === "serve" || arg === "attach") {
266
+ if (command !== undefined) {
267
+ throw usageError(`unexpected extra command ${JSON.stringify(arg)}`);
268
+ }
269
+ command = arg;
270
+ index += 1;
271
+ continue;
272
+ }
273
+ if (arg === "--server" || arg === "--user") {
274
+ const value = argv[index + 1];
275
+ if (value === undefined || value.startsWith("--")) {
276
+ throw usageError(`${arg} requires a value`);
277
+ }
278
+ if (arg === "--server")
279
+ server = value;
280
+ else
281
+ user = value;
282
+ index += 2;
283
+ continue;
284
+ }
285
+ throw usageError(`unknown argument ${JSON.stringify(arg)}`);
286
+ }
287
+ if (server !== undefined && command !== "attach") {
288
+ throw usageError("--server is only valid for attach");
289
+ }
290
+ return {
291
+ command: command ?? "run",
292
+ ...(server === undefined ? {} : { server }),
293
+ ...(user === undefined ? {} : { user }),
294
+ };
295
+ }
296
+ /**
297
+ * The config dir: the ONE config-root chain (`resolveStoreRoots`,
298
+ * spec Decision 3) — `SIGRUN_CONFIG_DIR` → `$XDG_CONFIG_HOME`/sigrun
299
+ * → `~/.config/sigrun`, identical to `defaultSettingsDir()` in
300
+ * src/harness/settings.ts (both delegate to the same resolver, so
301
+ * settings/auth/packages/git can never split from the store roots);
302
+ * `SIGRUN_CONFIG_DIR` itself stays env-only.
303
+ */
304
+ export function defaultConfigDir(env) {
305
+ return resolveStoreRoots(env).configDir;
306
+ }
307
+ /** Read `tui.json` (see {@link TuiFile}); `env` defaults to process.env. */
308
+ export async function loadTuiFile(env) {
309
+ const path = join(defaultConfigDir(env), "tui.json");
310
+ let raw;
311
+ try {
312
+ raw = await readFile(path, "utf8");
313
+ }
314
+ catch {
315
+ // Absent file is the normal case (ENOENT); any read failure is
316
+ // treated as absent — a malformed FILE is the fail-loud case below.
317
+ return {};
318
+ }
319
+ let parsed;
320
+ try {
321
+ parsed = JSON.parse(raw);
322
+ }
323
+ catch (cause) {
324
+ throw new Error(`tui.json: ${path} is not valid JSON (${cause instanceof Error ? cause.message : String(cause)})`);
325
+ }
326
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
327
+ throw new Error(`tui.json: ${path} must contain a JSON object`);
328
+ }
329
+ const record = parsed;
330
+ const user = record.user;
331
+ const server = record.server;
332
+ const token = record.token;
333
+ if (user !== undefined && typeof user !== "string") {
334
+ throw new Error(`tui.json: user must be a string`);
335
+ }
336
+ if (server !== undefined && typeof server !== "string") {
337
+ throw new Error(`tui.json: server must be a string`);
338
+ }
339
+ if (token !== undefined && typeof token !== "string") {
340
+ throw new Error(`tui.json: token must be a string`);
341
+ }
342
+ // UI refresh (C1): validate each client field, failing loud naming the
343
+ // key — the shared themes module owns the palette/accent name sets.
344
+ const palette = record.palette;
345
+ if (palette !== undefined && !isPaletteName(palette)) {
346
+ throw new Error(`tui.json: palette must be one of ${PALETTE_NAMES.join(", ")}`);
347
+ }
348
+ const accent = record.accent;
349
+ if (accent !== undefined && !isAccentName(accent)) {
350
+ throw new Error(`tui.json: accent must be one of ${ACCENT_NAMES.join(", ")}`);
351
+ }
352
+ const mode = record.mode;
353
+ if (mode !== undefined && mode !== "dark" && mode !== "light") {
354
+ throw new Error(`tui.json: mode must be one of dark, light`);
355
+ }
356
+ const keymap = record.keymap;
357
+ if (keymap !== undefined &&
358
+ keymap !== "standard" &&
359
+ keymap !== "vim" &&
360
+ keymap !== "emacs") {
361
+ throw new Error(`tui.json: keymap must be one of standard, vim, emacs`);
362
+ }
363
+ // Any string: the editor command's existence/validation is C4's job.
364
+ const editor = record.editor;
365
+ if (editor !== undefined && typeof editor !== "string") {
366
+ throw new Error(`tui.json: editor must be a string`);
367
+ }
368
+ return {
369
+ ...(user === undefined ? {} : { user }),
370
+ ...(server === undefined ? {} : { server }),
371
+ ...(token === undefined ? {} : { token }),
372
+ ...(palette === undefined ? {} : { palette }),
373
+ ...(accent === undefined ? {} : { accent }),
374
+ ...(mode === undefined ? {} : { mode }),
375
+ ...(keymap === undefined ? {} : { keymap }),
376
+ ...(editor === undefined ? {} : { editor }),
377
+ };
378
+ }
379
+ /**
380
+ * Merge `update` into `tui.json` and write it atomically at mode 0600
381
+ * (Task 7: the onboarding claim stores its `token` here — credential
382
+ * material never lands world-readable, and a pre-existing wider mode is
383
+ * tightened by the rewrite). The file's other fields (theme, keymap,
384
+ * editor) survive verbatim.
385
+ */
386
+ export async function writeTuiFile(update, env) {
387
+ const dir = defaultConfigDir(env);
388
+ await mkdir(dir, { recursive: true });
389
+ const merged = { ...(await loadTuiFile(env)), ...update };
390
+ await writeFileAtomic(join(dir, "tui.json"), `${JSON.stringify(merged, null, "\t")}\n`, 0o600);
391
+ }
392
+ /**
393
+ * Resolve the `@sigrun/tui` bin: the package manifest through its
394
+ * `./package.json` exports entry (a createRequire rooted at THIS module —
395
+ * never a relative path into the workspace), joined with the `sigrun-tui`
396
+ * bin the manifest declares. Any resolution failure (package absent,
397
+ * manifest shape, bin missing on disk) throws — callers translate it into
398
+ * the `install @sigrun/tui` error.
399
+ */
400
+ export function resolveTuiBin() {
401
+ const require = createRequire(import.meta.url);
402
+ const manifestPath = require.resolve("@sigrun/tui/package.json");
403
+ // The manifest path is read DIRECTLY (never wrapped in a file:// URL —
404
+ // interpolating a filesystem path into a URL mis-parses on `#`, `?`,
405
+ // and `%`, and WHATWG quirks are exactly how the T2 reviewer's
406
+ // mis-parse class happens; a plain path is what readFileSync takes).
407
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
408
+ const binField = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.["sigrun-tui"];
409
+ if (binField === undefined) {
410
+ throw new Error(`@sigrun/tui/package.json declares no sigrun-tui bin`);
411
+ }
412
+ return join(dirname(manifestPath), binField);
413
+ }
414
+ /** The tui.json UI fields as `--flag value` argv pairs (Task 2: the tui
415
+ * is spawned; the fields the old in-process call threaded now ride the
416
+ * child's argv, parsed by the bin's own `parseTuiLaunchArgs`). */
417
+ function tuiArgv(options) {
418
+ return [
419
+ "--server",
420
+ options.baseUrl,
421
+ ...(options.user === undefined ? [] : ["--user", options.user]),
422
+ ...(options.palette === undefined ? [] : ["--palette", options.palette]),
423
+ ...(options.accent === undefined ? [] : ["--accent", options.accent]),
424
+ ...(options.mode === undefined ? [] : ["--mode", options.mode]),
425
+ ...(options.keymap === undefined ? [] : ["--keymap", options.keymap]),
426
+ ...(options.editor === undefined ? [] : ["--editor", options.editor]),
427
+ ];
428
+ }
429
+ /**
430
+ * The default `launchTui`: spawn `node <resolved sigrun-tui bin>` as a
431
+ * child process (stdio inherited — the TUI owns the terminal) and resolve
432
+ * when the child exits. A bin-resolution failure is wrapped as the
433
+ * actionable `install @sigrun/tui` message; a nonzero child exit rejects
434
+ * (the CLI prints it and exits 1).
435
+ */
436
+ export async function launchTuiProcess(options, seams) {
437
+ const doSpawn = seams?.spawn ??
438
+ ((command, args, spawnOptions) => nodeSpawn(command, [...args], {
439
+ stdio: "inherit",
440
+ ...(spawnOptions?.env === undefined
441
+ ? {}
442
+ : { env: spawnOptions.env }),
443
+ }));
444
+ const resolveBin = seams?.resolveTuiBin ?? resolveTuiBin;
445
+ let bin;
446
+ try {
447
+ bin = resolveBin();
448
+ }
449
+ catch (cause) {
450
+ throw new Error(`sigrun-tui is not installed; install @sigrun/tui to use the terminal client (${cause instanceof Error ? cause.message : String(cause)})`);
451
+ }
452
+ const child = doSpawn(process.execPath, [bin, ...tuiArgv(options)],
453
+ // The token's ONLY route into the child: the environment (an
454
+ // explicit env REPLACES process.env in node's spawn — spread the
455
+ // parent's through so PATH and friends survive).
456
+ options.token === undefined
457
+ ? {}
458
+ : { env: { ...process.env, SIGRUN_TOKEN: options.token } });
459
+ const code = await new Promise((resolve, reject) => {
460
+ child.on("exit", (exitCode) => resolve(exitCode));
461
+ child.on("error", (error) => reject(error));
462
+ });
463
+ if (code !== 0) {
464
+ throw new Error(`sigrun-tui exited with ${code ?? "no exit code (terminated)"}`);
465
+ }
466
+ }
467
+ /**
468
+ * The production prompt (Task 7): one readline question per call on the
469
+ * real stdin/stdout. `secret: true` answers with the echo muted — the
470
+ * question itself still prints, the typed text does not.
471
+ */
472
+ export async function ttyPrompt(question, opts) {
473
+ if (opts?.secret === true) {
474
+ process.stdout.write(question);
475
+ // A black-hole output stream: readline echoes what it writes to its
476
+ // output — pointing that at a sink mutes the typing without raw-mode
477
+ // games (the standard readline muted-input idiom).
478
+ const muted = new Writable({
479
+ write: (_chunk, _encoding, callback) => {
480
+ callback();
481
+ },
482
+ });
483
+ const rl = readline.createInterface({ input: process.stdin, output: muted });
484
+ try {
485
+ return await rl.question("");
486
+ }
487
+ finally {
488
+ rl.close();
489
+ process.stdout.write("\n");
490
+ }
491
+ }
492
+ const rl = readline.createInterface({
493
+ input: process.stdin,
494
+ output: process.stdout,
495
+ });
496
+ try {
497
+ return await rl.question(question);
498
+ }
499
+ finally {
500
+ rl.close();
501
+ }
502
+ }
503
+ /** Resolve the attach/run user: `--user` > `SIGRUN_USER` > tui.json. */
504
+ function resolveUser(parsed, env, file) {
505
+ return parsed.user ?? env.SIGRUN_USER ?? file.user;
506
+ }
507
+ /**
508
+ * The tui.json UI refresh fields (C1) as a spreadable launch fragment —
509
+ * the same five keys for both dispatch paths (attach + run).
510
+ */
511
+ function tuiThemeFields(file) {
512
+ return {
513
+ ...(file.palette === undefined ? {} : { palette: file.palette }),
514
+ ...(file.accent === undefined ? {} : { accent: file.accent }),
515
+ ...(file.mode === undefined ? {} : { mode: file.mode }),
516
+ ...(file.keymap === undefined ? {} : { keymap: file.keymap }),
517
+ ...(file.editor === undefined ? {} : { editor: file.editor }),
518
+ };
519
+ }
520
+ /** Parse a port env value: undefined when absent/empty, else a positive
521
+ * integer — anything else fails loud naming the variable (D1: extracted
522
+ * from resolveServer's lazy IIFE). */
523
+ export function parsePort(raw) {
524
+ if (raw === undefined || raw === "")
525
+ return undefined;
526
+ const port = Number(raw);
527
+ if (!Number.isInteger(port) || port <= 0) {
528
+ throw new Error(`SIGRUN_PORT must be a positive integer, got ${raw}`);
529
+ }
530
+ return port;
531
+ }
532
+ /** Resolve the attach server: `--server` > tui.json > localhost default. */
533
+ function resolveServer(parsed, env, file) {
534
+ if (parsed.server !== undefined)
535
+ return parsed.server;
536
+ if (file.server !== undefined)
537
+ return file.server;
538
+ return `http://localhost:${parsePort(env.SIGRUN_PORT) ?? 3000}`;
539
+ }
540
+ /**
541
+ * Read the api key for `provider key` (release 0.1 Task 2b): a muted
542
+ * prompt when stdin is a TTY (the typed key never echoes), otherwise
543
+ * ALL of piped stdin, trimmed (`printf 'k' | sigrun provider key …`).
544
+ * The value returns to the caller only — it is never printed, logged,
545
+ * or carried on argv.
546
+ */
547
+ export async function readCliSecret() {
548
+ if (process.stdin.isTTY === true) {
549
+ return (await ttyPrompt("api key: ", { secret: true })).trim();
550
+ }
551
+ process.stdin.setEncoding("utf8");
552
+ let text = "";
553
+ for await (const chunk of process.stdin) {
554
+ text += chunk;
555
+ }
556
+ return text.trim();
557
+ }
558
+ /**
559
+ * Run the `provider` subcommands (release 0.1 Task 2b, spec §7.6): API
560
+ * clients over the RUNNING server's credential surface — `list` prints
561
+ * the readiness report, `key` PUTs a secret read from stdin, `clear`
562
+ * DELETEs one. The command NEVER starts a server: `--url` targets a
563
+ * remote base, and the default is the localhost shape `attach` resolves
564
+ * (`SIGRUN_PORT` or 3000); nothing listening fails loud naming the URL.
565
+ * Output is id, readiness, model count — one tab-separated row per
566
+ * provider (the identity rows' style). The key rides the PUT body ONLY:
567
+ * it never appears in any output, error, or log line.
568
+ */
569
+ async function runProviderCommand(parsed, env, io, fetchImpl, readSecret) {
570
+ const base = parsed.url ?? `http://localhost:${parsePort(env.SIGRUN_PORT) ?? 3000}`;
571
+ /** One route call: bearer when --token was given, fail-loud on non-2xx
572
+ * (the status + the server's error code — never any body value). */
573
+ const call = async (method, path, body) => {
574
+ let response;
575
+ try {
576
+ response = await fetchImpl(`${base}${path}`, {
577
+ method,
578
+ headers: {
579
+ accept: "application/json",
580
+ ...(parsed.token === undefined
581
+ ? {}
582
+ : { authorization: `Bearer ${parsed.token}` }),
583
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
584
+ },
585
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
586
+ });
587
+ }
588
+ catch (cause) {
589
+ throw new Error(`cannot reach a sigrun server at ${base} (${cause instanceof Error ? cause.message : String(cause)}) — provider commands are API clients and never start a server; start one (sigrun serve) or pass --url`);
590
+ }
591
+ const payload = (await response.json().catch(() => undefined));
592
+ if (!response.ok) {
593
+ throw new Error(`server rejected ${method} ${path}: status ${response.status}${typeof payload?.error === "string" ? ` (${payload.error})` : ""}`);
594
+ }
595
+ return (payload ?? {});
596
+ };
597
+ /** One readiness row: id, status, model count — tab-separated. */
598
+ const rowOf = (entry) => [
599
+ String(entry.provider ?? "?"),
600
+ String(entry.status ?? "?"),
601
+ String(entry.models ?? "?"),
602
+ ].join("\t");
603
+ switch (parsed.sub) {
604
+ case "list": {
605
+ const report = await call("GET", "/v1/providers");
606
+ const providers = report.providers;
607
+ if (!Array.isArray(providers)) {
608
+ throw new Error("server returned a malformed providers report");
609
+ }
610
+ for (const entry of providers) {
611
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
612
+ throw new Error("server returned a malformed providers report");
613
+ }
614
+ io.out?.(rowOf(entry));
615
+ }
616
+ return 0;
617
+ }
618
+ case "key": {
619
+ const secret = (await readSecret()).trim();
620
+ if (secret === "") {
621
+ throw new Error("provider key: no api key was provided — pipe it on stdin or type it at the prompt");
622
+ }
623
+ const entry = await call("PUT", `/v1/providers/${encodeURIComponent(parsed.id)}/credential`, { apiKey: secret });
624
+ io.out?.(rowOf(entry));
625
+ return 0;
626
+ }
627
+ case "clear": {
628
+ const entry = await call("DELETE", `/v1/providers/${encodeURIComponent(parsed.id)}/credential`);
629
+ io.out?.(rowOf(entry));
630
+ return 0;
631
+ }
632
+ }
633
+ // parseArgs guarantees one of the cases above.
634
+ throw new Error(`unhandled provider subcommand`);
635
+ }
636
+ /**
637
+ * Run the `token`/`user` subcommands (Task 7, spec §5.4): the identity
638
+ * stores DIRECTLY over the resolved roots — no server needed, honored
639
+ * live by a running one (the stores re-read on mtime change).
640
+ *
641
+ * - `token create` checks the user exists first (a token IS its user);
642
+ * - `token list`/`user list` print one row per record, no secrets (the
643
+ * secret exists only at create time);
644
+ * - `token revoke` fails loud on an unknown id.
645
+ */
646
+ async function runIdentityCommand(parsed, env, io) {
647
+ const stores = openIdentityStores(resolveStoreRoots(env));
648
+ if (parsed.command === "token") {
649
+ switch (parsed.sub) {
650
+ case "create": {
651
+ const user = parsed.user;
652
+ if (stores.users.get(user) === undefined) {
653
+ throw new Error(`unknown user "${user}" — add it first: sigrun user add ${user}`);
654
+ }
655
+ const { token } = await stores.tokens.create(parsed.tokenName, user);
656
+ // The one-time secret: printed ONCE, never stored.
657
+ io.out?.(token);
658
+ return 0;
659
+ }
660
+ case "list": {
661
+ for (const record of stores.tokens.list()) {
662
+ io.out?.([
663
+ record.name,
664
+ record.user,
665
+ record.id,
666
+ new Date(record.createdAt).toISOString(),
667
+ ].join("\t"));
668
+ }
669
+ return 0;
670
+ }
671
+ case "revoke": {
672
+ const revoked = await stores.tokens.revoke(parsed.id);
673
+ if (!revoked) {
674
+ throw new Error(`no token with id ${parsed.id}`);
675
+ }
676
+ return 0;
677
+ }
678
+ }
679
+ }
680
+ switch (parsed.sub) {
681
+ case "add": {
682
+ await stores.users.add({
683
+ user: parsed.user,
684
+ admin: parsed.admin ?? false,
685
+ ...(parsed.name === undefined ? {} : { name: parsed.name }),
686
+ ...(parsed.email === undefined ? {} : { email: parsed.email }),
687
+ source: "cli",
688
+ });
689
+ return 0;
690
+ }
691
+ case "list": {
692
+ for (const record of stores.users.list()) {
693
+ io.out?.([
694
+ record.admin ? "admin" : "-",
695
+ record.user,
696
+ ...(record.name === undefined ? [] : [record.name]),
697
+ ...(record.email === undefined ? [] : [record.email]),
698
+ ].join("\t"));
699
+ }
700
+ return 0;
701
+ }
702
+ }
703
+ // parseArgs guarantees one of the cases above.
704
+ throw new Error(`unhandled ${parsed.command} subcommand`);
705
+ }
706
+ /**
707
+ * The pre-launch onboarding flow (Task 7, spec §5.8): `attach`/`run`
708
+ * ask the server its state; an UNCLAIMED server gets the interactive
709
+ * claim — setup code (when the GET said one is required), username,
710
+ * name, email — then `POST /v1/onboarding/claim {…, issueToken: true}`.
711
+ *
712
+ * Returns `undefined` whenever the flow does not apply (claimed server,
713
+ * unreachable server, a non-setup GET failure): the TUI launches on the
714
+ * old path and surfaces whatever the server says. A failed CLAIM is
715
+ * loud (the server's error body, exit 1) — the operator re-runs.
716
+ */
717
+ async function onboardIfUnclaimed(server, prompt, fetchImpl) {
718
+ let state;
719
+ try {
720
+ const response = await fetchImpl(`${server}/v1/onboarding`, {
721
+ method: "GET",
722
+ headers: { accept: "application/json" },
723
+ });
724
+ if (response.ok) {
725
+ state = (await response.json());
726
+ }
727
+ else if (response.status === 403) {
728
+ // "a setup_required arrives later": a public-path GET that still
729
+ // answers setup_required means unclaimed with a code required.
730
+ const body = (await response.json().catch(() => undefined));
731
+ if (body?.error !== "setup_required")
732
+ return undefined;
733
+ state = { state: "unclaimed", methods: [], codeRequired: true };
734
+ }
735
+ else {
736
+ return undefined;
737
+ }
738
+ }
739
+ catch {
740
+ // Unreachable server: not this flow's business — the TUI reports it.
741
+ return undefined;
742
+ }
743
+ if (state.state !== "unclaimed")
744
+ return undefined;
745
+ let code;
746
+ if (state.codeRequired) {
747
+ code = (await prompt("setup code: ")).trim();
748
+ if (code === "") {
749
+ throw new Error("onboarding: a setup code is required for this server");
750
+ }
751
+ }
752
+ const user = (await prompt("username: ")).trim();
753
+ if (user === "") {
754
+ throw new Error("onboarding: a username is required");
755
+ }
756
+ const name = (await prompt("name (optional): ")).trim();
757
+ const email = (await prompt("email (optional): ")).trim();
758
+ const response = await fetchImpl(`${server}/v1/onboarding/claim`, {
759
+ method: "POST",
760
+ headers: { "content-type": "application/json" },
761
+ body: JSON.stringify({
762
+ ...(code === undefined ? {} : { code }),
763
+ user,
764
+ ...(name === "" ? {} : { name }),
765
+ ...(email === "" ? {} : { email }),
766
+ issueToken: true,
767
+ }),
768
+ });
769
+ if (!response.ok) {
770
+ const body = (await response.json().catch(() => undefined));
771
+ throw new Error(`onboarding claim failed: ${typeof body?.error === "string" ? body.error : `status ${response.status}`}`);
772
+ }
773
+ const claimed = (await response.json());
774
+ return {
775
+ user: claimed.user.user,
776
+ ...(claimed.token === undefined ? {} : { token: claimed.token }),
777
+ };
778
+ }
779
+ /**
780
+ * The dispatch. Returns the intended process exit code (0 clean, 1 on a
781
+ * usage error or a boot/launch failure) — the module scope below maps it
782
+ * onto `process.exit`. Env and deps are injectable: unit tests pass a
783
+ * hermetic env and stub factories.
784
+ */
785
+ export async function cliMain(argv, env = process.env, deps, io = {
786
+ error: (text) => console.error(text),
787
+ out: (text) => console.log(text),
788
+ }) {
789
+ const realDeps = {
790
+ ...(deps ?? {}),
791
+ serve: deps?.serve ??
792
+ (async (serveEnv) => {
793
+ const booted = await serve(serveEnv);
794
+ const stop = () => {
795
+ void booted.shutdownOnce().then(() => process.exit(0), (error) => {
796
+ console.error("sigrun shutdown error:", error);
797
+ process.exit(1);
798
+ });
799
+ };
800
+ process.on("SIGTERM", stop);
801
+ process.on("SIGINT", stop);
802
+ // Stay pending until a signal runs `stop` (which exits the
803
+ // process): cliMain's caller maps this promise's RESOLUTION onto
804
+ // `process.exit(0)` — a serve that returned after attaching the
805
+ // handlers would exit the process the instant the server started
806
+ // listening (the built-bin test caught exactly that: boot, print
807
+ // the listening line, exit 0, serve nothing).
808
+ await new Promise(() => { });
809
+ }),
810
+ boot: deps?.boot ?? ((bootEnv, bootOptions) => serve(bootEnv, bootOptions)),
811
+ };
812
+ // The resolved TUI launch: an injected `launchTui` wins (cli.test.ts's
813
+ // in-process stubs); otherwise the SPAWN default, composed with the
814
+ // injected child seams (cli-spawn.test.ts fakes the child while running
815
+ // this real wiring).
816
+ const launchTui = realDeps.launchTui ??
817
+ ((options) => launchTuiProcess(options, {
818
+ ...(realDeps.spawn === undefined ? {} : { spawn: realDeps.spawn }),
819
+ ...(realDeps.resolveTuiBin === undefined
820
+ ? {}
821
+ : { resolveTuiBin: realDeps.resolveTuiBin }),
822
+ }));
823
+ const prompt = realDeps.prompt ??
824
+ ((question, opts) => ttyPrompt(question, opts));
825
+ const fetchImpl = realDeps.fetch ??
826
+ ((url, init) => globalThis.fetch(url, init));
827
+ const readSecret = realDeps.readSecret ?? readCliSecret;
828
+ let parsed;
829
+ try {
830
+ parsed = parseArgs(argv);
831
+ }
832
+ catch (error) {
833
+ io.error(error instanceof Error ? error.message : String(error));
834
+ return 1;
835
+ }
836
+ try {
837
+ switch (parsed.command) {
838
+ case "serve": {
839
+ await realDeps.serve(env);
840
+ return 0;
841
+ }
842
+ case "token":
843
+ case "user": {
844
+ // The identity commands (Task 7): stores directly, server or not.
845
+ return await runIdentityCommand(parsed, env, io);
846
+ }
847
+ case "provider": {
848
+ // The provider commands (release 0.1 Task 2b): API clients over
849
+ // the running server — never a boot, never a local store.
850
+ return await runProviderCommand(parsed, env, io, fetchImpl, readSecret);
851
+ }
852
+ case "attach": {
853
+ const file = await loadTuiFile(env);
854
+ const server = resolveServer(parsed, env, file);
855
+ const onboarded = await onboardIfUnclaimed(server, prompt, fetchImpl);
856
+ if (onboarded !== undefined) {
857
+ // The claim's carry-home: identity + where it was claimed + the
858
+ // one-time token (0600, merged over any existing fields).
859
+ await writeTuiFile({
860
+ user: onboarded.user,
861
+ server,
862
+ ...(onboarded.token === undefined ? {} : { token: onboarded.token }),
863
+ }, env);
864
+ }
865
+ // Token precedence (spec §5.4): env SIGRUN_TOKEN > tui.json > claim.
866
+ const token = env.SIGRUN_TOKEN ?? file.token ?? onboarded?.token;
867
+ const user = resolveUser(parsed, env, file) ?? onboarded?.user;
868
+ await launchTui({
869
+ baseUrl: server,
870
+ ...(user === undefined ? {} : { user }),
871
+ ...(token === undefined || token === "" ? {} : { token }),
872
+ ...tuiThemeFields(file),
873
+ });
874
+ return 0;
875
+ }
876
+ case "run": {
877
+ // The flags are parsed; the model spec fails loud inside boot
878
+ // (loadConfig names SIGRUN_DEFAULT_MODEL).
879
+ const file = await loadTuiFile(env);
880
+ // Workstation mode binds loopback only: the TUI dials
881
+ // 127.0.0.1, and the naive-identity API has no business
882
+ // answering the LAN.
883
+ const booted = await realDeps.boot(env, { host: "127.0.0.1" });
884
+ const exit = realDeps.exit ?? ((exitCode) => process.exit(exitCode));
885
+ const stop = () => {
886
+ // Fix wave A8: once shutdown completes, run mode EXITS — the
887
+ // TUI must never run against a dead server (a signal is the
888
+ // operator's stop request; the serve entry keeps its own
889
+ // semantics in src/index.ts, unchanged).
890
+ void booted.shutdownOnce().then(() => exit(0), (error) => {
891
+ console.error("sigrun shutdown error:", error);
892
+ exit(1);
893
+ });
894
+ };
895
+ process.on("SIGTERM", stop);
896
+ process.on("SIGINT", stop);
897
+ try {
898
+ const server = `http://localhost:${booted.port}`;
899
+ // Loopback onboarding (Task 7): no code (loopback is the trust);
900
+ // tui.json gains ONLY the user — workstation run stays
901
+ // unauthenticated, so there is no token to keep.
902
+ const onboarded = await onboardIfUnclaimed(server, prompt, fetchImpl);
903
+ if (onboarded !== undefined) {
904
+ await writeTuiFile({ user: onboarded.user }, env);
905
+ }
906
+ const user = resolveUser(parsed, env, file) ?? onboarded?.user;
907
+ await launchTui({
908
+ baseUrl: server,
909
+ ...(user === undefined ? {} : { user }),
910
+ ...tuiThemeFields(file),
911
+ });
912
+ }
913
+ finally {
914
+ // The lifecycle rule: the agent dies with the TUI.
915
+ await booted.shutdownOnce();
916
+ }
917
+ return 0;
918
+ }
919
+ }
920
+ }
921
+ catch (error) {
922
+ const message = error instanceof Error ? error.message : String(error);
923
+ // The launchTui placeholder message already carries the prefix.
924
+ io.error(message.startsWith("sigrun:") ? message : `sigrun: ${message}`);
925
+ return 1;
926
+ }
927
+ }
928
+ // Script detection (fix wave A7 + build Task 3): the exact entry-module
929
+ // comparison — the old endsWith("cli.ts") fired for ANY cli.ts basename.
930
+ // The argv side is REALPATH'd first (the T2 fix, mirrored for the core
931
+ // bin): npm bin entries are symlinks (…/.bin/sigrun → dist/cli.js), node
932
+ // realpaths the main module (import.meta.url is the real path) while
933
+ // process.argv[1] keeps the invoked symlink — a raw comparison mismatches
934
+ // and the bin exits 0 SILENTLY.
935
+ function invokedEntryPath() {
936
+ if (process.argv[1] === undefined)
937
+ return undefined;
938
+ try {
939
+ return realpathSync(process.argv[1]);
940
+ }
941
+ catch {
942
+ return undefined;
943
+ }
944
+ }
945
+ const invokedEntry = invokedEntryPath();
946
+ const invokedAsScript = invokedEntry !== undefined &&
947
+ import.meta.url === pathToFileURL(invokedEntry).href;
948
+ if (invokedAsScript) {
949
+ void cliMain(process.argv.slice(2)).then((code) => {
950
+ process.exit(code);
951
+ });
952
+ }