spur-board 0.1.0

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/dist/cli.mjs ADDED
@@ -0,0 +1,799 @@
1
+ import { o as OPERATIONS } from "./src-CqH_W7D8.mjs";
2
+ import { a as renderCliError, i as errorExitCode, n as requestTokenEnrollment, o as escapeTerminalControls, r as CliError, t as requestOperation } from "./client-C1hJzdYE.mjs";
3
+ import { n as loadEnrollmentConfig, t as loadClientConfig } from "./config-DQmktVi-.mjs";
4
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
5
+ import { chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { homedir, hostname, userInfo } from "node:os";
7
+ import { dirname, isAbsolute, join, resolve } from "node:path";
8
+ import { Command, CommanderError } from "@commander-js/extra-typings";
9
+ import { z } from "zod";
10
+ import YAML from "yaml";
11
+ import { spawn } from "node:child_process";
12
+ import { access, cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
13
+ import { fileURLToPath } from "node:url";
14
+ //#region src/manifest.ts
15
+ const botScopes = [
16
+ "chat:write",
17
+ "chat:write.customize",
18
+ "groups:history",
19
+ "groups:read",
20
+ "app_mentions:read",
21
+ "commands",
22
+ "reactions:write"
23
+ ];
24
+ function renderSlackManifest(options = {}) {
25
+ const appName = (options.appName ?? "spur-board").trim();
26
+ const command = options.command ?? "/board";
27
+ if (!appName) throw new CliError("usage", void 0, void 0, "Set a nonempty --app-name.");
28
+ if (!/^\/[a-z0-9_-]+$/.test(command)) throw new CliError("usage", void 0, void 0, "Set --command to a slash command such as /board.");
29
+ return YAML.stringify({
30
+ _metadata: {
31
+ major_version: 1,
32
+ minor_version: 1
33
+ },
34
+ display_information: {
35
+ name: appName,
36
+ description: "Board updates and commands"
37
+ },
38
+ features: {
39
+ bot_user: { display_name: appName },
40
+ slash_commands: [{
41
+ command,
42
+ description: "Show board tasks and command help",
43
+ usage_hint: "[task <KEY> [history]]",
44
+ should_escape: false
45
+ }]
46
+ },
47
+ oauth_config: { scopes: { bot: [...botScopes] } },
48
+ settings: {
49
+ event_subscriptions: { bot_events: ["message.groups", "app_mention"] },
50
+ interactivity: { is_enabled: true },
51
+ socket_mode_enabled: true
52
+ }
53
+ }, { lineWidth: 0 });
54
+ }
55
+ //#endregion
56
+ //#region src/service.ts
57
+ const serviceName = "spur-board.service";
58
+ function configHome(env, home) {
59
+ const configured = env["XDG_CONFIG_HOME"];
60
+ return configured && isAbsolute(configured) ? configured : join(home, ".config");
61
+ }
62
+ function servicePaths(options = {}) {
63
+ const home = resolve(options.home ?? homedir());
64
+ const env = options.env ?? process.env;
65
+ const configDir = configHome(env, home);
66
+ const boardHome = env["BOARD_HOME"];
67
+ const secretsDir = boardHome ? resolve(boardHome) : join(configDir, "spur-board");
68
+ const environment = boardHome ? [`BOARD_HOME=${secretsDir}`] : [...env["XDG_CONFIG_HOME"] && isAbsolute(env["XDG_CONFIG_HOME"]) ? [`XDG_CONFIG_HOME=${configDir}`] : [], ...env["XDG_DATA_HOME"] && isAbsolute(env["XDG_DATA_HOME"]) ? [`XDG_DATA_HOME=${env["XDG_DATA_HOME"]}`] : []];
69
+ const packedDaemon = fileURLToPath(new URL("./daemon.mjs", import.meta.url));
70
+ return {
71
+ nodePath: options.nodePath ?? process.execPath,
72
+ daemonEntry: options.daemonEntry ?? (existsSync(packedDaemon) ? packedDaemon : fileURLToPath(new URL("../../daemon/src/main.ts", import.meta.url))),
73
+ secretsFile: join(secretsDir, "secrets.env"),
74
+ unitFile: join(configDir, "systemd", "user", serviceName),
75
+ environment
76
+ };
77
+ }
78
+ function unitArgument(value) {
79
+ return `"${value.replaceAll("%", "%%").replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`;
80
+ }
81
+ function renderUserUnit(paths) {
82
+ return [
83
+ "[Unit]",
84
+ "Description=spur-board daemon",
85
+ "After=network-online.target",
86
+ "Wants=network-online.target",
87
+ "",
88
+ "[Service]",
89
+ "Type=simple",
90
+ `ExecStart=${unitArgument(paths.nodePath)} ${unitArgument(paths.daemonEntry)}`,
91
+ ...(paths.environment ?? []).map((entry) => `Environment=${unitArgument(entry)}`),
92
+ `EnvironmentFile=${unitArgument(paths.secretsFile)}`,
93
+ "Restart=on-failure",
94
+ "RestartSec=5s",
95
+ "TimeoutStopSec=15s",
96
+ "",
97
+ "[Install]",
98
+ "WantedBy=default.target",
99
+ ""
100
+ ].join("\n");
101
+ }
102
+ async function runCommand(command, args) {
103
+ return new Promise((resolveResult, reject) => {
104
+ const child = spawn(command, [...args], { stdio: [
105
+ "ignore",
106
+ "pipe",
107
+ "pipe"
108
+ ] });
109
+ let stdout = "";
110
+ let stderr = "";
111
+ child.stdout.setEncoding("utf8");
112
+ child.stderr.setEncoding("utf8");
113
+ child.stdout.on("data", (chunk) => {
114
+ stdout += chunk;
115
+ });
116
+ child.stderr.on("data", (chunk) => {
117
+ stderr += chunk;
118
+ });
119
+ child.on("error", reject);
120
+ child.on("close", (code) => {
121
+ resolveResult({
122
+ exitCode: code ?? 1,
123
+ stdout,
124
+ stderr
125
+ });
126
+ });
127
+ });
128
+ }
129
+ async function requireSuccess(runner, command, args) {
130
+ const result = await runner(command, args);
131
+ if (result.exitCode !== 0) {
132
+ const detail = result.stderr.trim() || result.stdout.trim() || `exit ${String(result.exitCode)}`;
133
+ throw new Error(`${command} ${args.join(" ")} failed: ${detail}`);
134
+ }
135
+ return result;
136
+ }
137
+ async function preflight(paths) {
138
+ for (const [label, path] of [["Node executable", paths.nodePath], ["daemon entry", paths.daemonEntry]]) {
139
+ if (!isAbsolute(path)) throw new Error(`${label} path must be absolute: ${path}`);
140
+ try {
141
+ const file = await stat(path);
142
+ if (!file.isFile() || (file.mode & 292) === 0) throw new Error("not a readable file");
143
+ if (label === "Node executable" && (file.mode & 73) === 0) throw new Error("not executable");
144
+ await access(path, label === "Node executable" ? constants.R_OK | constants.X_OK : constants.R_OK);
145
+ } catch {
146
+ throw new Error(`${label} is ${label === "Node executable" ? "missing, unreadable, or not executable" : "missing or unreadable"}: ${path}`);
147
+ }
148
+ }
149
+ try {
150
+ const secrets = await stat(paths.secretsFile);
151
+ if (!secrets.isFile() || (secrets.mode & 511) !== 384) throw new Error("invalid mode");
152
+ await access(paths.secretsFile, constants.R_OK);
153
+ } catch {
154
+ throw new Error(`Secrets file must exist as a readable mode 0600 file: ${paths.secretsFile}`);
155
+ }
156
+ }
157
+ async function existingUnit(path) {
158
+ try {
159
+ return await readFile(path, "utf8");
160
+ } catch (error) {
161
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return void 0;
162
+ throw error;
163
+ }
164
+ }
165
+ async function installService(options = {}) {
166
+ const paths = servicePaths(options);
167
+ await preflight(paths);
168
+ const runner = options.runner ?? runCommand;
169
+ const unit = renderUserUnit(paths);
170
+ const previousUnit = await existingUnit(paths.unitFile);
171
+ const changed = previousUnit !== unit;
172
+ let wasActive = false;
173
+ if (changed) {
174
+ await mkdir(dirname(paths.unitFile), { recursive: true });
175
+ await writeFile(paths.unitFile, unit);
176
+ await requireSuccess(runner, "systemctl", ["--user", "daemon-reload"]);
177
+ if (previousUnit !== void 0) {
178
+ const state = await runner("systemctl", [
179
+ "--user",
180
+ "is-active",
181
+ serviceName
182
+ ]);
183
+ wasActive = state.exitCode === 0 && state.stdout.trim() === "active";
184
+ }
185
+ }
186
+ await requireSuccess(runner, "systemctl", [
187
+ "--user",
188
+ "enable",
189
+ "--now",
190
+ serviceName
191
+ ]);
192
+ if (wasActive) await requireSuccess(runner, "systemctl", [
193
+ "--user",
194
+ "restart",
195
+ serviceName
196
+ ]);
197
+ return {
198
+ unitFile: paths.unitFile,
199
+ changed
200
+ };
201
+ }
202
+ async function restartService(options = {}) {
203
+ await requireSuccess(options.runner ?? runCommand, "systemctl", [
204
+ "--user",
205
+ "restart",
206
+ serviceName
207
+ ]);
208
+ }
209
+ async function statusService(options = {}) {
210
+ const runner = options.runner ?? runCommand;
211
+ const active = await runner("systemctl", [
212
+ "--user",
213
+ "is-active",
214
+ serviceName
215
+ ]);
216
+ const enabled = await runner("systemctl", [
217
+ "--user",
218
+ "is-enabled",
219
+ serviceName
220
+ ]);
221
+ const linger = await requireSuccess(runner, "loginctl", [
222
+ "show-user",
223
+ options.username ?? userInfo().username,
224
+ "--property=Linger",
225
+ "--value"
226
+ ]);
227
+ return {
228
+ active: active.stdout.trim() || "unknown",
229
+ enabled: enabled.stdout.trim() || "unknown",
230
+ lingering: linger.stdout.trim() === "yes"
231
+ };
232
+ }
233
+ async function uninstallService(options = {}) {
234
+ const unitFile = servicePaths(options).unitFile;
235
+ if (await existingUnit(unitFile) === void 0) return { changed: false };
236
+ const runner = options.runner ?? runCommand;
237
+ await requireSuccess(runner, "systemctl", [
238
+ "--user",
239
+ "disable",
240
+ "--now",
241
+ serviceName
242
+ ]);
243
+ await rm(unitFile);
244
+ await requireSuccess(runner, "systemctl", ["--user", "daemon-reload"]);
245
+ return { changed: true };
246
+ }
247
+ //#endregion
248
+ //#region src/skills.ts
249
+ const stampName = ".spur-board-install.json";
250
+ const installId = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
251
+ const skills = [{
252
+ source: "spur-board",
253
+ name: "spur-board"
254
+ }, {
255
+ source: "setup",
256
+ name: "spur-board-setup"
257
+ }];
258
+ const agents = [".claude", ".codex"];
259
+ async function packageVersion(packageRoot) {
260
+ const manifest = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
261
+ if (typeof manifest.version !== "string" || !manifest.version) throw new Error("The spur-board package has no version.");
262
+ return manifest.version;
263
+ }
264
+ async function requireSource(path) {
265
+ try {
266
+ const source = await stat(path);
267
+ const skillFile = await stat(join(path, "SKILL.md"));
268
+ if (source.isDirectory() && skillFile.isFile()) return;
269
+ } catch {}
270
+ throw new Error(`Bundled skill is missing or invalid: ${path}`);
271
+ }
272
+ async function currentVersion(path, version) {
273
+ let content;
274
+ try {
275
+ content = await readFile(join(path, stampName), "utf8");
276
+ } catch (error) {
277
+ if (typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "EISDIR")) return false;
278
+ throw error;
279
+ }
280
+ try {
281
+ const stamp = JSON.parse(content);
282
+ return stamp.version === version && typeof stamp.installedAt === "string" && new Date(stamp.installedAt).toISOString() === stamp.installedAt;
283
+ } catch {
284
+ return false;
285
+ }
286
+ }
287
+ async function targetDirectory(path) {
288
+ try {
289
+ const entry = await lstat(path);
290
+ if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(`Skill install target must be a directory, not a symlink: ${path}`);
291
+ return true;
292
+ } catch (error) {
293
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
294
+ throw error;
295
+ }
296
+ }
297
+ async function checkTarget(home, agent, destination) {
298
+ const parent = join(home, agent, "skills");
299
+ await targetDirectory(join(home, agent));
300
+ await targetDirectory(parent);
301
+ return targetDirectory(destination);
302
+ }
303
+ async function withInstallLock(home, action) {
304
+ const fd = openSync(join(home, ".spur-board-skills-install.lock"), constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 384);
305
+ try {
306
+ await new Promise((resolveLock, reject) => {
307
+ const child = spawn("flock", [
308
+ "-x",
309
+ "-w",
310
+ "30",
311
+ "3"
312
+ ], { stdio: [
313
+ "ignore",
314
+ "ignore",
315
+ "pipe",
316
+ fd
317
+ ] });
318
+ let stderr = "";
319
+ child.stderr?.setEncoding("utf8");
320
+ child.stderr?.on("data", (chunk) => {
321
+ stderr += chunk;
322
+ });
323
+ child.once("error", (error) => {
324
+ reject(/* @__PURE__ */ new Error(`flock from util-linux is required for skill installation: ${error.message}`));
325
+ });
326
+ child.once("close", (code) => {
327
+ if (code === 0) resolveLock();
328
+ else reject(/* @__PURE__ */ new Error(`Could not lock skill installation: ${stderr.trim() || `exit ${String(code)}`}`));
329
+ });
330
+ });
331
+ return await action();
332
+ } finally {
333
+ closeSync(fd);
334
+ }
335
+ }
336
+ async function recoverSkill(parent, destination, name) {
337
+ if (!await targetDirectory(parent)) return;
338
+ const entries = await readdir(parent);
339
+ const backupPrefix = `.${name}-backup-`;
340
+ const backups = [];
341
+ for (const entry of entries) {
342
+ if (!entry.startsWith(backupPrefix) || !entry.endsWith(".owner")) continue;
343
+ const id = entry.slice(backupPrefix.length, -6);
344
+ if (!installId.test(id)) continue;
345
+ const owner = join(parent, entry);
346
+ const ownerStat = await lstat(owner);
347
+ if (!ownerStat.isFile() || ownerStat.isSymbolicLink()) continue;
348
+ const content = await readFile(owner, "utf8");
349
+ const prefix = `spur-board backup ${id} `;
350
+ if (!content.startsWith(prefix) || !/^\d+:\d+\n$/.test(content.slice(prefix.length))) continue;
351
+ const path = join(parent, `${backupPrefix}${id}`);
352
+ if (!await targetDirectory(path)) {
353
+ await rm(owner);
354
+ continue;
355
+ }
356
+ const backupStat = await lstat(path, { bigint: true });
357
+ if (content !== `${prefix}${String(backupStat.dev)}:${String(backupStat.ino)}\n`) continue;
358
+ backups.push({
359
+ path,
360
+ owner
361
+ });
362
+ }
363
+ const exists = await targetDirectory(destination);
364
+ if (!exists && backups.length > 1) throw new Error(`Multiple interrupted backups exist for ${destination}`);
365
+ if (!exists && backups[0]) {
366
+ await rename(backups[0].path, destination);
367
+ await rm(backups[0].owner);
368
+ } else if (exists) for (const backup of backups) {
369
+ await rm(backup.path, { recursive: true });
370
+ await rm(backup.owner);
371
+ }
372
+ const stagePrefix = `.${name}-stage-`;
373
+ for (const entry of entries) {
374
+ if (!entry.startsWith(stagePrefix) || !entry.endsWith(".owner")) continue;
375
+ const id = entry.slice(stagePrefix.length, -6);
376
+ if (!installId.test(id)) continue;
377
+ const owner = join(parent, entry);
378
+ const ownerStat = await lstat(owner);
379
+ if (!ownerStat.isFile() || ownerStat.isSymbolicLink()) continue;
380
+ if (await readFile(owner, "utf8") !== `spur-board stage ${id}\n`) continue;
381
+ const staged = join(parent, `${stagePrefix}${id}`);
382
+ if (await targetDirectory(staged)) await rm(staged, { recursive: true });
383
+ await rm(owner);
384
+ }
385
+ }
386
+ async function installIntoHome(home, packageRoot, version, options) {
387
+ for (const agent of agents) for (const skill of skills) await checkTarget(home, agent, join(home, agent, "skills", skill.name));
388
+ const installedAt = (options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
389
+ const results = [];
390
+ for (const agent of agents) {
391
+ const parent = join(home, agent, "skills");
392
+ for (const skill of skills) {
393
+ const destination = join(parent, skill.name);
394
+ await recoverSkill(parent, destination, skill.name);
395
+ const changed = !await currentVersion(destination, version);
396
+ if (changed) {
397
+ await mkdir(parent, { recursive: true });
398
+ await checkTarget(home, agent, destination);
399
+ const stageId = randomUUID();
400
+ const staged = join(parent, `.${skill.name}-stage-${stageId}`);
401
+ const owner = `${staged}.owner`;
402
+ await writeFile(owner, `spur-board stage ${stageId}\n`, {
403
+ flag: "wx",
404
+ mode: 384
405
+ });
406
+ let stagedCreated = false;
407
+ try {
408
+ await mkdir(staged, { mode: 448 });
409
+ stagedCreated = true;
410
+ const source = join(packageRoot, "skills", skill.source);
411
+ if (options.copySkill) await options.copySkill(source, staged);
412
+ else await cp(source, staged, { recursive: true });
413
+ const stamp = {
414
+ version,
415
+ installedAt
416
+ };
417
+ await writeFile(join(staged, stampName), `${JSON.stringify(stamp, null, 2)}\n`);
418
+ if (await checkTarget(home, agent, destination)) {
419
+ const backupId = randomUUID();
420
+ const backup = join(parent, `.${skill.name}-backup-${backupId}`);
421
+ const backupOwner = `${backup}.owner`;
422
+ const original = await lstat(destination, { bigint: true });
423
+ await writeFile(backupOwner, `spur-board backup ${backupId} ${`${String(original.dev)}:${String(original.ino)}`}\n`, {
424
+ flag: "wx",
425
+ mode: 384
426
+ });
427
+ let backupContainsSkill = false;
428
+ try {
429
+ if (await targetDirectory(backup)) throw new Error(`Skill backup path already exists: ${backup}`);
430
+ await rename(destination, backup);
431
+ backupContainsSkill = true;
432
+ try {
433
+ await rename(staged, destination);
434
+ } catch (error) {
435
+ await rename(backup, destination);
436
+ backupContainsSkill = false;
437
+ throw error;
438
+ }
439
+ await rm(backup, { recursive: true });
440
+ backupContainsSkill = false;
441
+ } finally {
442
+ if (!backupContainsSkill) await rm(backupOwner);
443
+ }
444
+ } else await rename(staged, destination);
445
+ } finally {
446
+ if (stagedCreated) await rm(staged, {
447
+ recursive: true,
448
+ force: true
449
+ });
450
+ await rm(owner);
451
+ }
452
+ }
453
+ results.push({
454
+ agent: agent === ".claude" ? "claude" : "codex",
455
+ skill: skill.name,
456
+ changed
457
+ });
458
+ }
459
+ }
460
+ return results;
461
+ }
462
+ async function installSkills(options = {}) {
463
+ const packageRoot = resolve(options.packageRoot ?? fileURLToPath(new URL("../", import.meta.url)));
464
+ const home = await realpath(resolve(options.home ?? homedir()));
465
+ const version = await packageVersion(packageRoot);
466
+ await Promise.all(skills.map((skill) => requireSource(join(packageRoot, "skills", skill.source))));
467
+ return withInstallLock(home, () => installIntoHome(home, packageRoot, version, options));
468
+ }
469
+ //#endregion
470
+ //#region src/cli.ts
471
+ const processIo = {
472
+ stdout: (text) => process.stdout.write(text),
473
+ stderr: (text) => process.stderr.write(text),
474
+ isTTY: process.stdout.isTTY
475
+ };
476
+ function branches(input) {
477
+ if (input instanceof z.ZodDiscriminatedUnion) return [...input.options];
478
+ return [input];
479
+ }
480
+ function fieldSchema(op, name) {
481
+ for (const branch of branches(op.input)) if (name in branch.shape) return branch.shape[name];
482
+ }
483
+ function bareSchema(schema) {
484
+ return schema instanceof z.ZodOptional ? schema.unwrap() : schema;
485
+ }
486
+ function revisionEntryArray(schema) {
487
+ if (schema === void 0) return false;
488
+ const bare = bareSchema(schema);
489
+ if (!(bare instanceof z.ZodArray)) return false;
490
+ return (bare.element instanceof z.ZodUnion ? bare.element.options : [bare.element]).some((shape) => shape instanceof z.ZodObject && Object.keys(shape.shape).sort().join(",") === "id,revision");
491
+ }
492
+ function positionalInput(op, field, value) {
493
+ if (!revisionEntryArray(fieldSchema(op, field)) || !Array.isArray(value)) return value;
494
+ return value.map((item) => {
495
+ if (typeof item !== "string") return item;
496
+ if (/^B[1-9][0-9]*$/.test(item)) return { id: item };
497
+ const match = /^([^@]+)@([1-9][0-9]*)$/.exec(item);
498
+ const id = match?.[1];
499
+ const rawRevision = match?.[2];
500
+ if (id === void 0 || rawRevision === void 0) return item;
501
+ const revision = Number(rawRevision);
502
+ return Number.isSafeInteger(revision) ? {
503
+ id,
504
+ revision
505
+ } : item;
506
+ });
507
+ }
508
+ function positionalExample(op, field, value) {
509
+ const values = Array.isArray(value) ? value : [value];
510
+ const isEntryArray = revisionEntryArray(fieldSchema(op, field));
511
+ return values.map((item) => {
512
+ if (isEntryArray && item !== null && typeof item === "object" && "id" in item && "revision" in item) return shellValue(`${String(item.id)}@${String(item.revision)}`);
513
+ if (isEntryArray && item !== null && typeof item === "object" && "id" in item) return shellValue(item.id);
514
+ return shellValue(item);
515
+ });
516
+ }
517
+ function optionName(field) {
518
+ return `--${field.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
519
+ }
520
+ function optionField(flag) {
521
+ return flag.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
522
+ }
523
+ function shellValue(value) {
524
+ const text = String(value);
525
+ return /^[A-Za-z0-9_./#:@-]+$/.test(text) ? text : JSON.stringify(text);
526
+ }
527
+ function exampleCommand(op, input) {
528
+ const words = ["board", op.cli.command];
529
+ for (const arg of op.cli.args) {
530
+ const value = input[arg];
531
+ if (value !== void 0) words.push(...positionalExample(op, arg, value));
532
+ }
533
+ const mode = op.cli.modes?.find((item) => item.mode === input["mode"]);
534
+ if (mode) {
535
+ words.push(mode.flag);
536
+ if (mode.value && input[mode.value] !== void 0) words.push(shellValue(input[mode.value]));
537
+ }
538
+ for (const [field, value] of Object.entries(input)) {
539
+ if (op.cli.args.includes(field) || field === "mode" || mode?.value === field) continue;
540
+ words.push(optionName(field));
541
+ if (Array.isArray(value)) words.push(...value.map(shellValue));
542
+ else if (typeof value !== "boolean") words.push(shellValue(value));
543
+ }
544
+ return words.join(" ");
545
+ }
546
+ function formatHuman(value, indent = 0) {
547
+ const prefix = " ".repeat(indent);
548
+ if (Array.isArray(value)) return value.map((item) => `${prefix}- ${formatHuman(item, indent + 2).trimStart()}`).join("\n");
549
+ if (value && typeof value === "object") {
550
+ const object = value;
551
+ return Object.entries(object).map(([key, item]) => {
552
+ if (key === "text" && object["untrusted"] === true && typeof item === "string") return `${prefix}${escapeTerminalControls(key)}:\n${item.split("\n").map((line) => `${prefix}> ${escapeTerminalControls(line)}`).join("\n")}`;
553
+ if (item && typeof item === "object") return `${prefix}${escapeTerminalControls(key)}:\n${formatHuman(item, indent + 2)}`;
554
+ return `${prefix}${escapeTerminalControls(key)}: ${escapeTerminalControls(String(item))}`;
555
+ }).join("\n");
556
+ }
557
+ return `${prefix}${escapeTerminalControls(String(value))}`;
558
+ }
559
+ function commandFor(root, path) {
560
+ let parent = root;
561
+ for (const segment of path.split(" ")) {
562
+ let child = parent.commands.find((item) => item.name() === segment);
563
+ if (!child) child = parent.command(segment);
564
+ parent = child;
565
+ }
566
+ return parent;
567
+ }
568
+ function registerOptions(command, op) {
569
+ for (const arg of op.cli.args) {
570
+ const schema = fieldSchema(op, arg);
571
+ if (!schema) throw new Error(`Catalog argument ${arg} is missing from ${op.id}.`);
572
+ command.argument(bareSchema(schema) instanceof z.ZodArray ? `<${arg}...>` : `<${arg}>`);
573
+ }
574
+ const modeValues = new Set(op.cli.modes?.flatMap((mode) => mode.value ? [mode.value] : []));
575
+ const fields = /* @__PURE__ */ new Map();
576
+ for (const branch of branches(op.input)) for (const [name, schema] of Object.entries(branch.shape)) fields.set(name, schema);
577
+ for (const [field, schema] of fields) {
578
+ if (op.cli.args.includes(field) || field === "mode" || modeValues.has(field)) continue;
579
+ const bare = bareSchema(schema);
580
+ const flag = optionName(field);
581
+ const description = schema.description ?? field;
582
+ if (bare instanceof z.ZodBoolean) command.option(flag, description);
583
+ else if (bare instanceof z.ZodArray) command.option(`${flag} <values...>`, description);
584
+ else if (bare instanceof z.ZodNumber) command.option(`${flag} <value>`, description, (value) => Number(value));
585
+ else if (bare instanceof z.ZodString || bare instanceof z.ZodEnum || bare instanceof z.ZodLiteral) command.option(`${flag} <value>`, description);
586
+ else throw new Error(`Unsupported CLI field ${field} in ${op.id}.`);
587
+ }
588
+ for (const mode of op.cli.modes ?? []) if (mode.value === null) command.option(mode.flag);
589
+ else {
590
+ const schema = fieldSchema(op, mode.value);
591
+ if (!schema) throw new Error(`Catalog mode value ${mode.value} is missing from ${op.id}.`);
592
+ const value = schema instanceof z.ZodOptional ? `[${mode.value}]` : `<${mode.value}>`;
593
+ command.option(`${mode.flag} ${value}`);
594
+ }
595
+ if (op.kind === "mutation") command.option("--key <key>", "Idempotency key for this action.");
596
+ command.option("--json", "Print JSON output or a JSON error.");
597
+ }
598
+ function buildInput(op, args, options) {
599
+ const input = {};
600
+ for (const [index, field] of op.cli.args.entries()) input[field] = positionalInput(op, field, args[index]);
601
+ for (const branch of branches(op.input)) for (const field of Object.keys(branch.shape)) {
602
+ if (field === "mode" || field in input) continue;
603
+ const flag = optionField(optionName(field));
604
+ if (options[flag] !== void 0) input[field] = options[flag];
605
+ }
606
+ const selected = op.cli.modes?.filter((mode) => options[optionField(mode.flag)] !== void 0);
607
+ if (selected && selected.length !== 1) throw new CliError("usage");
608
+ if (selected?.[0]) {
609
+ input["mode"] = selected[0].mode;
610
+ if (selected[0].value) {
611
+ const value = options[optionField(selected[0].flag)];
612
+ if (value !== true) input[selected[0].value] = value;
613
+ }
614
+ }
615
+ const parsed = op.input.safeParse(input);
616
+ if (!parsed.success) throw new CliError("usage", void 0, void 0, z.prettifyError(parsed.error));
617
+ return parsed.data;
618
+ }
619
+ function registerSetupCommands(program, options, io, env) {
620
+ const serviceOptions = {
621
+ ...options.setup,
622
+ env
623
+ };
624
+ const print = (value) => {
625
+ io.stdout(`${program.opts()["json"] || !io.isTTY ? JSON.stringify(value) : formatHuman(value)}\n`);
626
+ };
627
+ const runSetup = async (action) => {
628
+ try {
629
+ return await action();
630
+ } catch (error) {
631
+ if (error instanceof CliError) throw error;
632
+ if (error instanceof Error) throw new CliError("config", void 0, void 0, error.message);
633
+ throw error;
634
+ }
635
+ };
636
+ commandFor(program, "skills install").description("Install bundled skills for Claude Code and Codex.").action(async () => {
637
+ print(await runSetup(() => installSkills(options.setup)));
638
+ });
639
+ commandFor(program, "service install").description("Install and start the systemd user service.").action(async () => {
640
+ print(await runSetup(() => installService(serviceOptions)));
641
+ });
642
+ commandFor(program, "service restart").description("Restart the systemd user service.").action(async () => {
643
+ await runSetup(() => restartService(serviceOptions));
644
+ print({
645
+ unit: serviceName,
646
+ restarted: true
647
+ });
648
+ });
649
+ commandFor(program, "service status").description("Show the service state and whether lingering is enabled.").action(async () => {
650
+ print({
651
+ unit: serviceName,
652
+ ...await runSetup(() => statusService(serviceOptions))
653
+ });
654
+ });
655
+ commandFor(program, "service uninstall").description("Stop and remove the systemd user service.").action(async () => {
656
+ print({
657
+ unit: serviceName,
658
+ ...await runSetup(() => uninstallService(serviceOptions))
659
+ });
660
+ });
661
+ const manifest = commandFor(program, "slack manifest");
662
+ manifest.description("Print a Slack app manifest.");
663
+ manifest.option("--app-name <name>", "Slack app name.");
664
+ manifest.option("--command <command>", "Slash command, including the leading slash.");
665
+ manifest.action(() => {
666
+ const appName = manifest.opts()["appName"];
667
+ const command = manifest.opts()["command"];
668
+ if (appName !== void 0 && typeof appName !== "string" || command !== void 0 && typeof command !== "string") throw new CliError("usage");
669
+ io.stdout(renderSlackManifest({
670
+ ...appName === void 0 ? {} : { appName },
671
+ ...command === void 0 ? {} : { command }
672
+ }));
673
+ });
674
+ }
675
+ function createProgram(options = {}) {
676
+ const io = options.io ?? processIo;
677
+ const env = options.env ?? process.env;
678
+ const program = new Command();
679
+ program.name("board").description("Use the board from a shell.");
680
+ program.option("--json", "Print JSON output or a JSON error.");
681
+ program.configureOutput({
682
+ writeOut: (text) => {
683
+ io.stdout(text);
684
+ },
685
+ writeErr: () => {}
686
+ });
687
+ program.exitOverride();
688
+ registerSetupCommands(program, options, io, env);
689
+ for (const op of OPERATIONS) {
690
+ const command = commandFor(program, op.cli.command);
691
+ command.description(op.description);
692
+ registerOptions(command, op);
693
+ command.addHelpText("after", `\nExamples:\n${op.examples.map((example) => ` ${exampleCommand(op, example.input)} — ${example.summary}`).join("\n")}\n`);
694
+ command.action(async (...parts) => {
695
+ const invoked = parts.at(-1);
696
+ const json = Boolean(invoked.opts()["json"] || program.opts()["json"]);
697
+ const input = buildInput(op, parts.slice(0, -1), invoked.opts());
698
+ const keyOption = invoked.opts()["key"];
699
+ if (keyOption !== void 0 && typeof keyOption !== "string") throw new CliError("usage");
700
+ if (keyOption === "") throw new CliError("usage");
701
+ if (keyOption !== void 0 && escapeTerminalControls(keyOption) !== keyOption) throw new CliError("usage", void 0, void 0, "The idempotency key must contain only printable characters.");
702
+ if (op.kind === "mutation" && env["SPUR_SESSION"] && !keyOption) throw new CliError("usage");
703
+ const config = options.clientConfig ?? loadClientConfig(env);
704
+ const key = op.kind === "mutation" ? keyOption || randomUUID() : void 0;
705
+ if (key) io.stderr(`key: ${escapeTerminalControls(key)}\n`);
706
+ try {
707
+ const result = await requestOperation(op, input, key, config);
708
+ io.stdout(`${json || !io.isTTY ? JSON.stringify(result) : formatHuman(result)}\n`);
709
+ } catch (error) {
710
+ if (error instanceof CliError) {
711
+ io.stderr(`${renderCliError(error, json, key)}\n`);
712
+ throw new CommanderError(errorExitCode(error), "board.error", error.message);
713
+ }
714
+ throw error;
715
+ }
716
+ });
717
+ }
718
+ const create = commandFor(program, "token create");
719
+ create.description("Request approval for a new token in a chosen folder.");
720
+ create.requiredOption("--name <name>", "Unique token name.");
721
+ create.requiredOption("--into <dir>", "Existing folder to hold the token file.");
722
+ create.option("--json", "Print JSON output or a JSON error.");
723
+ create.action(async (...parts) => {
724
+ const flags = parts.at(-1).opts();
725
+ const json = Boolean(flags["json"] || program.opts()["json"]);
726
+ const name = flags["name"];
727
+ const into = flags["into"];
728
+ if (typeof name !== "string" || !name.trim() || typeof into !== "string") throw new CliError("usage");
729
+ const config = loadEnrollmentConfig(env);
730
+ let folder;
731
+ try {
732
+ folder = realpathSync(resolve(into));
733
+ if (folder === realpathSync(options.home ?? homedir())) throw new CliError("usage", void 0, void 0, "Choose a folder below home, not home itself.");
734
+ } catch (error) {
735
+ if (error instanceof CliError) throw error;
736
+ throw new CliError("usage", void 0, void 0, "Choose an existing folder.");
737
+ }
738
+ const tokenFolder = join(folder, ".spur-board");
739
+ const file = join(tokenFolder, "token");
740
+ const secret = randomBytes(32).toString("base64url");
741
+ let created = false;
742
+ let descriptor;
743
+ try {
744
+ mkdirSync(tokenFolder, {
745
+ recursive: true,
746
+ mode: 448
747
+ });
748
+ const directory = lstatSync(tokenFolder);
749
+ if (!directory.isDirectory() || directory.uid !== process.getuid?.() || (directory.mode & 63) !== 0 || realpathSync(tokenFolder) !== tokenFolder) throw new Error("Token directory must be owned by the caller and private.");
750
+ descriptor = openSync(file, "wx", 384);
751
+ created = true;
752
+ writeFileSync(descriptor, `${secret}\n`);
753
+ closeSync(descriptor);
754
+ descriptor = void 0;
755
+ chmodSync(file, 384);
756
+ } catch {
757
+ if (descriptor !== void 0) closeSync(descriptor);
758
+ if (created) try {
759
+ unlinkSync(file);
760
+ } catch {
761
+ throw new CliError("config", void 0, void 0, "The new token file could not be secured or removed.");
762
+ }
763
+ throw new CliError("config", void 0, void 0, "The token file could not be created exclusively.");
764
+ }
765
+ try {
766
+ const result = await requestTokenEnrollment({
767
+ name,
768
+ folder,
769
+ host: hostname(),
770
+ hash: createHash("sha256").update(secret).digest("hex")
771
+ }, config);
772
+ io.stdout(`${json || !io.isTTY ? JSON.stringify(result) : formatHuman(result)}\n`);
773
+ } catch (error) {
774
+ if (error instanceof CliError && error.code === "outcome_unknown") throw new CliError("outcome_unknown", void 0, error.requestId, "The request may have reached the daemon. Keep the new token file and check the approval request before retrying.");
775
+ try {
776
+ unlinkSync(file);
777
+ } catch {
778
+ throw new CliError("config", void 0, void 0, "Registration failed and the new token file could not be removed. Remove it before retrying.");
779
+ }
780
+ throw error;
781
+ }
782
+ });
783
+ return program;
784
+ }
785
+ async function runCli(argv, options = {}) {
786
+ const io = options.io ?? processIo;
787
+ try {
788
+ await createProgram(options).parseAsync(argv, { from: "user" });
789
+ return 0;
790
+ } catch (error) {
791
+ if (error instanceof CommanderError && error.code === "board.error") return error.exitCode;
792
+ if (error instanceof CommanderError && error.exitCode === 0) return 0;
793
+ const cliError = error instanceof CliError ? error : error instanceof CommanderError ? new CliError("usage", void 0, void 0, error.message) : new CliError("unexpected");
794
+ io.stderr(`${renderCliError(cliError, argv.includes("--json"))}\n`);
795
+ return errorExitCode(cliError);
796
+ }
797
+ }
798
+ //#endregion
799
+ export { createProgram, runCli };