faberun 0.3.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.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. package/src/web/server.mjs +552 -0
@@ -0,0 +1,294 @@
1
+ /**
2
+ * `faberun setup [--yes] [--harnesses a,b] [--worker <id>] [--judge <id>]
3
+ * [--json]`: onboard a fresh machine.
4
+ *
5
+ * It checks the two host prerequisites, discovers the catalogue runtimes
6
+ * through the same `discoverRuntimes` the engine uses, asks which harnesses to
7
+ * enable and which runtime is the default worker and judge, and writes the user
8
+ * config at `$FABERUN_HOME/config.json`. The judge must resolve to a vendor
9
+ * other than the worker's; the prompt refuses a same-vendor answer once and the
10
+ * command fails on the second.
11
+ *
12
+ * Discovery and the question function are injected so tests never touch a real
13
+ * binary or a terminal. `--json` never asks: it reports the same facts as one
14
+ * object and takes the defaults or the flags.
15
+ */
16
+ import { createInterface } from "node:readline/promises";
17
+ import {
18
+ DISCOVERY_RUNTIME_DEFINITIONS,
19
+ availableCandidates,
20
+ cheapest,
21
+ discoverRuntimes,
22
+ strongest,
23
+ } from "../engine/runtime-discovery.mjs";
24
+ import { boundedGitSync } from "../repo/worktree.mjs";
25
+ import { colorLevel, renderBanner, statusToken } from "./brand.mjs";
26
+ import { packageVersion } from "../host/package.mjs";
27
+ import { configPath, faberunHome } from "../host/home.mjs";
28
+ import { writeUserConfig } from "../host/config.mjs";
29
+
30
+ /** @typedef {import("../engine/runtime-discovery.mjs").RuntimeAvailability} RuntimeAvailability */
31
+ /** @typedef {import("../host/config.mjs").UserConfig} UserConfig */
32
+ /** @typedef {(text: string) => void} Writer */
33
+ /** @typedef {(question: string) => Promise<string>} Asker */
34
+ /** @typedef {{id: string, harness: string, model: string, available: boolean, status: string, missing: string[]}} RuntimeView */
35
+ /** @typedef {{ask: Asker, close: () => void}} AskerHandle */
36
+ /**
37
+ * @typedef {object} SetupOptions
38
+ * @property {boolean} [yes]
39
+ * @property {string} [harnesses]
40
+ * @property {string} [worker]
41
+ * @property {string} [judge]
42
+ * @property {boolean} [json]
43
+ * @property {NodeJS.ProcessEnv} [env]
44
+ * @property {() => Promise<Record<string, RuntimeAvailability>>} [discover]
45
+ * @property {Asker} [ask]
46
+ * @property {Writer} [stdout]
47
+ * @property {Writer} [stderr]
48
+ * @property {boolean} [isTTY]
49
+ */
50
+
51
+ /** Node major version the tool requires; `package.json#engines` says the same. */
52
+ const MIN_NODE_MAJOR = 22;
53
+
54
+ /** The harnesses a fresh machine can install, named in the failure hint. */
55
+ const INSTALL_HARNESSES = ["claude", "codex", "agy", "dsh", "zcode"];
56
+
57
+ /**
58
+ * @param {SetupOptions} [options]
59
+ * @returns {Promise<number>} the process exit code
60
+ */
61
+ export async function setupCommand(options = {}) {
62
+ const env = options.env ?? process.env;
63
+ const stdout = options.stdout ?? ((text) => process.stdout.write(text));
64
+ const stderr = options.stderr ?? ((text) => process.stderr.write(text));
65
+ const json = options.json === true;
66
+ const yes = options.yes === true;
67
+ const isTTY = options.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
68
+ const level = colorLevel(env, isTTY);
69
+ const discover = options.discover ?? (() => discoverRuntimes(DISCOVERY_RUNTIME_DEFINITIONS));
70
+
71
+ const availability = await discover();
72
+ const runtimes = runtimeViews(availability, env);
73
+ const availableHarnesses = [...new Set(runtimes.filter((view) => view.available).map((view) => view.harness))];
74
+ const requirements = [nodeRequirement(), gitRequirement()];
75
+
76
+ if (isTTY && !json) {
77
+ stdout(renderBanner({
78
+ version: packageVersion(),
79
+ // DESIGN.md draws `node 26.8.1`; `process.version` is `v26.8.1`.
80
+ nodeVersion: process.version.replace(/^v/u, ""),
81
+ harnessCount: availableHarnesses.length,
82
+ level,
83
+ env,
84
+ }));
85
+ }
86
+
87
+ if (json) {
88
+ // `--json` never asks and never paints; the object below is the whole
89
+ // report, whether setup succeeds or stops at a check.
90
+ } else {
91
+ for (const requirement of requirements) {
92
+ stdout(`${statusToken(requirement.ok ? "ok" : "fail", level)} ${requirement.name} · ${requirement.detail}\n`);
93
+ }
94
+ }
95
+
96
+ if (requirements.some((requirement) => !requirement.ok)) {
97
+ if (json) stdout(`${JSON.stringify({ requirements, runtimes, config: null }, null, 2)}\n`);
98
+ return 1;
99
+ }
100
+
101
+ if (!json) {
102
+ for (const view of runtimes) {
103
+ const missing = view.missing.length ? ` · set ${view.missing.join(", ")}` : "";
104
+ stdout(`${statusToken(runtimeToken(view), level)} ${view.id} · ${view.status} · ${view.harness} · ${view.model}${missing}\n`);
105
+ }
106
+ }
107
+
108
+ if (availableHarnesses.length === 0) {
109
+ if (json) {
110
+ stdout(`${JSON.stringify({ requirements, runtimes, config: null }, null, 2)}\n`);
111
+ } else {
112
+ stdout(`${statusToken("fail", level)} harnesses · none available · install one of: ${INSTALL_HARNESSES.join(", ")}\n`);
113
+ }
114
+ return 1;
115
+ }
116
+
117
+ const candidates = availableCandidates(DISCOVERY_RUNTIME_DEFINITIONS, availability);
118
+ const defaultWorker = cheapest(candidates)?.id ?? "";
119
+ const interactive = isTTY && !json && !yes
120
+ && options.harnesses === undefined && options.worker === undefined && options.judge === undefined;
121
+
122
+ /** @type {string[]} */
123
+ let selectedHarnesses;
124
+ let selectedWorker;
125
+ let selectedJudge;
126
+ const asker = makeAsker(options.ask);
127
+ try {
128
+ if (interactive) {
129
+ const harnessAnswer = (await asker.ask(`Enable which harnesses? [${availableHarnesses.join(", ")}] `)).trim();
130
+ selectedHarnesses = splitHarnesses(harnessAnswer).length ? splitHarnesses(harnessAnswer) : availableHarnesses;
131
+
132
+ const workerAnswer = (await asker.ask(`Default worker runtime? [${defaultWorker}] `)).trim();
133
+ selectedWorker = workerAnswer || defaultWorker;
134
+
135
+ let judgeAnswer = (await asker.ask(`Default judge runtime? [${defaultJudge(selectedWorker, candidates)}] `)).trim();
136
+ let judge = judgeAnswer || defaultJudge(selectedWorker, candidates);
137
+ if (!crossVendor(judge, selectedWorker)) {
138
+ stderr("the judge must come from a different vendor than the worker\n");
139
+ judgeAnswer = (await asker.ask(`Default judge runtime? [${defaultJudge(selectedWorker, candidates)}] `)).trim();
140
+ judge = judgeAnswer || defaultJudge(selectedWorker, candidates);
141
+ if (!crossVendor(judge, selectedWorker)) return 1;
142
+ }
143
+ selectedJudge = judge;
144
+ } else {
145
+ selectedHarnesses = splitHarnesses(options.harnesses ?? "").length ? splitHarnesses(options.harnesses ?? "") : availableHarnesses;
146
+ selectedWorker = options.worker ?? defaultWorker;
147
+ selectedJudge = options.judge ?? defaultJudge(selectedWorker, candidates);
148
+ if (!crossVendor(selectedJudge, selectedWorker)) {
149
+ if (json) stdout(`${JSON.stringify({ requirements, runtimes, config: null }, null, 2)}\n`);
150
+ else stdout(`${statusToken("fail", level)} judge · the judge must come from a different vendor than the worker\n`);
151
+ return 1;
152
+ }
153
+ }
154
+ } finally {
155
+ asker.close();
156
+ }
157
+
158
+ const config = {
159
+ schemaVersion: /** @type {1} */ (1),
160
+ harnesses: selectedHarnesses,
161
+ worker: selectedWorker,
162
+ judge: selectedJudge,
163
+ updatedAt: new Date().toISOString(),
164
+ };
165
+ writeUserConfig(env, config);
166
+
167
+ if (json) {
168
+ stdout(`${JSON.stringify({ requirements, runtimes, config }, null, 2)}\n`);
169
+ return 0;
170
+ }
171
+ stdout(`${statusToken("ok", level)} config · ${configPath(faberunHome(env))}\n`);
172
+ stdout("next · faberun init in a repository · faberun doctor\n");
173
+ return 0;
174
+ }
175
+
176
+ /**
177
+ * One discovery line's token: an available runtime is a pass, a binary that is
178
+ * absent is a failure, and every other reason (quota, balance, auth, provider
179
+ * error) is a warning.
180
+ *
181
+ * @param {RuntimeView} view
182
+ * @returns {"ok"|"warn"|"fail"}
183
+ */
184
+ function runtimeToken(view) {
185
+ if (view.available) return "ok";
186
+ return view.status === "not_found" ? "fail" : "warn";
187
+ }
188
+
189
+ /**
190
+ * @param {Record<string, RuntimeAvailability>} availability
191
+ * @param {NodeJS.ProcessEnv} env
192
+ * @returns {RuntimeView[]}
193
+ */
194
+ function runtimeViews(availability, env) {
195
+ return Object.entries(DISCOVERY_RUNTIME_DEFINITIONS).map(([id, definition]) => {
196
+ const state = availability[id];
197
+ const available = state?.available === true;
198
+ return {
199
+ id,
200
+ harness: definition.harness,
201
+ model: definition.model,
202
+ available,
203
+ status: available ? "available" : (state?.reason ?? "provider_unavailable"),
204
+ missing: missingEnvKeys(definition, env),
205
+ };
206
+ });
207
+ }
208
+
209
+ /**
210
+ * The env var names a runtime's own config declares through an `*.env_key` and
211
+ * the process environment does not set. Discovery already reports the
212
+ * authentication reason; naming the variable is what makes it fixable.
213
+ *
214
+ * @param {import("../engine/runtime-discovery.mjs").DiscoveryRuntime} runtime
215
+ * @param {NodeJS.ProcessEnv} env
216
+ * @returns {string[]}
217
+ */
218
+ function missingEnvKeys(runtime, env) {
219
+ /** @type {string[]} */
220
+ const names = [];
221
+ for (const [key, value] of Object.entries(runtime.config ?? {})) {
222
+ if (!key.endsWith(".env_key")) continue;
223
+ if (typeof value === "string" && value.length > 0 && !env[value]) names.push(value);
224
+ }
225
+ return [...new Set(names)];
226
+ }
227
+
228
+ /**
229
+ * The strongest available runtime whose vendor differs from the worker's. An
230
+ * empty string means no cross-vendor runtime is available, which the caller
231
+ * turns into a refusal.
232
+ *
233
+ * @param {string} workerId
234
+ * @param {import("../engine/runtime-discovery.mjs").RuntimeCandidate[]} candidates
235
+ * @returns {string}
236
+ */
237
+ function defaultJudge(workerId, candidates) {
238
+ const vendor = DISCOVERY_RUNTIME_DEFINITIONS[workerId]?.vendor;
239
+ if (vendor === undefined) return "";
240
+ return strongest(candidates, vendor)?.id ?? "";
241
+ }
242
+
243
+ /**
244
+ * Whether `judgeId` is a known runtime whose vendor differs from the known
245
+ * vendor of `workerId`. An unknown id on either side is not cross-vendor.
246
+ *
247
+ * @param {string} judgeId
248
+ * @param {string} workerId
249
+ * @returns {boolean}
250
+ */
251
+ function crossVendor(judgeId, workerId) {
252
+ const judgeVendor = DISCOVERY_RUNTIME_DEFINITIONS[judgeId]?.vendor;
253
+ const workerVendor = DISCOVERY_RUNTIME_DEFINITIONS[workerId]?.vendor;
254
+ return Boolean(judgeVendor && workerVendor && judgeVendor !== workerVendor);
255
+ }
256
+
257
+ /**
258
+ * @param {string} text
259
+ * @returns {string[]}
260
+ */
261
+ function splitHarnesses(text) {
262
+ return [...new Set(String(text).split(",").map((name) => name.trim()).filter(Boolean))];
263
+ }
264
+
265
+ /** @returns {{name: string, ok: boolean, detail: string}} */
266
+ function nodeRequirement() {
267
+ const major = Number(process.versions.node.split(".")[0]);
268
+ const ok = Number.isFinite(major) && major >= MIN_NODE_MAJOR;
269
+ return {
270
+ name: "node",
271
+ ok,
272
+ detail: ok ? `${process.version} (${MIN_NODE_MAJOR} or newer required)` : `${process.version} · ${MIN_NODE_MAJOR} or newer required`,
273
+ };
274
+ }
275
+
276
+ /** @returns {{name: string, ok: boolean, detail: string}} */
277
+ function gitRequirement() {
278
+ const probe = boundedGitSync(["--version"], { encoding: "utf8" });
279
+ if (probe.error || probe.status !== 0) return { name: "git", ok: false, detail: "not found on PATH" };
280
+ return { name: "git", ok: true, detail: String(probe.stdout ?? "").trim() };
281
+ }
282
+
283
+ /**
284
+ * A question function, real or injected. The real one reads one shared readline
285
+ * interface for the whole command and closes it when setup is done.
286
+ *
287
+ * @param {Asker|undefined} injected
288
+ * @returns {AskerHandle}
289
+ */
290
+ function makeAsker(injected) {
291
+ if (injected) return { ask: injected, close: () => {} };
292
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
293
+ return { ask: (question) => rl.question(question), close: () => rl.close() };
294
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * `skills` argv: list, install.
3
+ *
4
+ * Per-operation options only, so `--force` is rejected by `list`. The
5
+ * behavior lives in `installSkills`, exported because `faberun init` installs
6
+ * the same catalogue into a target repository; this file owns the wire, the
7
+ * same split `seat.mjs` uses.
8
+ */
9
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { parseArgs as parseFlags } from "node:util";
14
+
15
+ const SKILLS_DIR = fileURLToPath(new URL("../../skills", import.meta.url));
16
+
17
+ /** Flags are scoped to the operation that declares them; all others are rejected. */
18
+ /** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
19
+ const OPERATION_OPTIONS = {
20
+ list: {},
21
+ install: {
22
+ target: { type: "string" },
23
+ global: { type: "boolean" },
24
+ force: { type: "boolean" },
25
+ },
26
+ };
27
+
28
+ /**
29
+ * @param {string[]} args
30
+ * @returns {void}
31
+ */
32
+ export function skillsCli(args) {
33
+ const operation = args[0];
34
+ if (!operation || !Object.hasOwn(OPERATION_OPTIONS, operation)) return usage();
35
+ let parsed;
36
+ try {
37
+ parsed = parseFlags({ args: args.slice(1), options: OPERATION_OPTIONS[operation], allowPositionals: true, strict: true });
38
+ } catch (error) {
39
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
40
+ return usage();
41
+ }
42
+ const values = /** @type {{target?: string, global?: boolean, force?: boolean}} */ (parsed.values);
43
+ if (operation === "list") {
44
+ if (parsed.positionals.length) return usage();
45
+ for (const name of catalog()) process.stdout.write(`${name}\n`);
46
+ return;
47
+ }
48
+ const skillsDir = values.global
49
+ ? join(homedir(), ".claude", "skills")
50
+ : join(values.target ? resolve(values.target) : process.cwd(), ".claude", "skills");
51
+ try {
52
+ installSkills({ names: parsed.positionals, skillsDir, force: values.force === true, stdout: process.stdout });
53
+ } catch (error) {
54
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
55
+ return usage();
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Copy catalogue skills into `skillsDir`. A skill that is already installed is
61
+ * kept unless `force`; the caller owns the wire and the target directory.
62
+ *
63
+ * @param {{names: string[], skillsDir: string, force: boolean, stdout: {write(text: string): unknown}}} options
64
+ * @returns {{installed: number, skipped: number}}
65
+ */
66
+ export function installSkills({ names, skillsDir, force, stdout }) {
67
+ const available = catalog();
68
+ const selected = names.length ? names : available;
69
+ for (const name of selected) {
70
+ if (!available.includes(name)) throw new Error(`no skill named "${name}" (run \`faberun skills list\`)`);
71
+ }
72
+ mkdirSync(skillsDir, { recursive: true });
73
+ let installed = 0;
74
+ let skipped = 0;
75
+ for (const name of selected) {
76
+ const destination = join(skillsDir, name);
77
+ if (existsSync(destination) && !force) {
78
+ stdout.write(`skipped ${name} · exists, use --force\n`);
79
+ skipped++;
80
+ continue;
81
+ }
82
+ rmSync(destination, { recursive: true, force: true });
83
+ cpSync(join(SKILLS_DIR, name), destination, { recursive: true });
84
+ stdout.write(`installed ${name} · ${skillsDir}\n`);
85
+ installed++;
86
+ }
87
+ stdout.write(`${installed} installed · ${skipped} skipped\n`);
88
+ return { installed, skipped };
89
+ }
90
+
91
+ /**
92
+ * @returns {string[]} catalogue entries that carry a SKILL.md
93
+ */
94
+ function catalog() {
95
+ return readdirSync(SKILLS_DIR, { withFileTypes: true })
96
+ .filter((entry) => entry.isDirectory() && existsSync(join(SKILLS_DIR, entry.name, "SKILL.md")))
97
+ .map((entry) => entry.name)
98
+ .sort();
99
+ }
100
+
101
+ /** @returns {void} */
102
+ function usage() {
103
+ process.stderr.write("usage: faberun skills <list|install> [<name>...] [--target <dir>] [--global] [--force]\n");
104
+ process.exitCode = 2;
105
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * `faberun update [--check] [--json]`: fetch the latest GitHub release and
3
+ * install it into the versioned home layout.
4
+ *
5
+ * The only command that reaches the network. The release is the channel (tags
6
+ * `vX.Y.Z`), and the tarball is the release's `tarball_url`. A new version
7
+ * proves it runs by printing its own `--version` *before* `current` moves, so a
8
+ * broken release can never take the working install down with it: any failure
9
+ * removes the partial directory and leaves `current` untouched.
10
+ *
11
+ * Version comparison is numeric on the three parts only. Prereleases are out of
12
+ * scope here: `v1.2.3-rc.1` does not parse, and an unparseable tag is an error
13
+ * rather than something to compare as if it were a release.
14
+ */
15
+ import { spawnSync } from "node:child_process";
16
+ import { mkdirSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { compareVersions, currentLink, faberunHome, installedVersionDir, tmpDir, versionsDir, writeUpdateCheck } from "../host/home.mjs";
19
+ import { packageVersion } from "../host/package.mjs";
20
+ import { errorMessage } from "../util.mjs";
21
+
22
+ /** @typedef {{tag: string, version: string, tarballUrl: string}} Release */
23
+ /** @typedef {(text: string) => void} Writer */
24
+ /**
25
+ * @typedef {object} UpdateOptions
26
+ * @property {boolean} [check]
27
+ * @property {boolean} [json]
28
+ * @property {NodeJS.ProcessEnv} [env]
29
+ * @property {typeof fetch} [fetchImpl]
30
+ * @property {string} [entryPath]
31
+ * @property {Writer} [stdout]
32
+ * @property {Writer} [stderr]
33
+ */
34
+
35
+ const RELEASES_URL = "https://api.github.com/repos/feliperun/faberun/releases/latest";
36
+ const REQUEST_TIMEOUT_MS = 10_000;
37
+
38
+ /**
39
+ * @param {UpdateOptions} [options]
40
+ * @returns {Promise<number>} the process exit code
41
+ */
42
+ export async function updateCommand(options = {}) {
43
+ const check = options.check === true;
44
+ const json = options.json === true;
45
+ const env = options.env ?? process.env;
46
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
47
+ const entryPath = options.entryPath ?? process.argv[1];
48
+ const stdout = options.stdout ?? ((text) => process.stdout.write(text));
49
+ const stderr = options.stderr ?? ((text) => process.stderr.write(text));
50
+ const home = faberunHome(env);
51
+ const current = packageVersion();
52
+ const installed = installedVersionDir(entryPath, home);
53
+
54
+ if (!check && !installed) {
55
+ stderr(`faberun is not installed under ${home}; update it the way it was installed (git pull, or re-run install.sh)\n`);
56
+ return 1;
57
+ }
58
+
59
+ /** @type {Release} */
60
+ let release;
61
+ try {
62
+ release = await fetchLatestRelease(fetchImpl, env);
63
+ } catch (error) {
64
+ stderr(`${errorMessage(error)}\n`);
65
+ return 1;
66
+ }
67
+
68
+ if (check) {
69
+ writeUpdateCheck(home, { checkedAt: new Date().toISOString(), current, latest: release.version });
70
+ if (json) {
71
+ stdout(`${JSON.stringify({ current, latest: release.version, updated: false, installedUnderHome: installed !== null, home })}\n`);
72
+ } else {
73
+ stdout(`faberun ${current} · latest ${release.version}\n`);
74
+ stdout(compareVersions(release.version, current) > 0 ? "update available · run faberun update\n" : "up to date\n");
75
+ }
76
+ return 0;
77
+ }
78
+
79
+ if (compareVersions(release.version, current) <= 0) {
80
+ if (json) stdout(`${JSON.stringify({ current, latest: release.version, updated: false, installedUnderHome: true, home })}\n`);
81
+ else stdout("up to date\n");
82
+ return 0;
83
+ }
84
+
85
+ try {
86
+ await installRelease(fetchImpl, home, release);
87
+ writeUpdateCheck(home, { checkedAt: new Date().toISOString(), current: release.version, latest: release.version });
88
+ } catch (error) {
89
+ stderr(`${errorMessage(error)}\n`);
90
+ return 1;
91
+ }
92
+
93
+ if (json) stdout(`${JSON.stringify({ current: release.version, latest: release.version, updated: true, installedUnderHome: true, home })}\n`);
94
+ else stdout(`updated · ${current} to ${release.version}\n`);
95
+ return 0;
96
+ }
97
+
98
+ /**
99
+ * The latest release's tag, version and tarball URL. A non-2xx response or a
100
+ * tag that is not `vX.Y.Z` is an error, reported as one line by the caller.
101
+ *
102
+ * @param {typeof fetch} fetchImpl
103
+ * @param {NodeJS.ProcessEnv} env
104
+ * @returns {Promise<Release>}
105
+ */
106
+ async function fetchLatestRelease(fetchImpl, env) {
107
+ const url = typeof env.FABERUN_RELEASES_URL === "string" && env.FABERUN_RELEASES_URL ? env.FABERUN_RELEASES_URL : RELEASES_URL;
108
+ const response = await fetchImpl(url, { headers: releaseHeaders(), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
109
+ if (!response.ok) throw new Error(`release lookup failed: HTTP ${response.status}`);
110
+ const body = /** @type {Record<string, unknown>} */ (await response.json());
111
+ const parsed = parseTag(body.tag_name);
112
+ if (typeof body.tarball_url !== "string" || !body.tarball_url) throw new Error(`release ${parsed.tag} has no tarball_url`);
113
+ return { tag: parsed.tag, version: parsed.version, tarballUrl: body.tarball_url };
114
+ }
115
+
116
+ /**
117
+ * Parse `vX.Y.Z`. Prerelease and build metadata are deliberately not accepted;
118
+ * the caller reports the tag as an error.
119
+ *
120
+ * @param {unknown} tagName
121
+ * @returns {{tag: string, version: string}}
122
+ */
123
+ function parseTag(tagName) {
124
+ if (typeof tagName !== "string") throw new Error("release has no tag_name");
125
+ if (!/^v\d+\.\d+\.\d+$/u.test(tagName)) throw new Error(`release tag is not vX.Y.Z: ${tagName}`);
126
+ return { tag: tagName, version: tagName.slice(1) };
127
+ }
128
+
129
+ /**
130
+ * @param {typeof fetch} fetchImpl
131
+ * @param {string} home
132
+ * @param {Release} release
133
+ * @returns {Promise<void>}
134
+ */
135
+ async function installRelease(fetchImpl, home, release) {
136
+ const partial = join(versionsDir(home), `${release.tag}.partial`);
137
+ const destination = join(versionsDir(home), release.version);
138
+ const tarball = join(tmpDir(home), `${release.tag}.tgz`);
139
+ try {
140
+ mkdirSync(tmpDir(home), { recursive: true });
141
+ mkdirSync(partial, { recursive: true });
142
+ await downloadTarball(fetchImpl, release.tarballUrl, tarball);
143
+ extractTarball(tarball, partial);
144
+ verifyVersion(partial, release.version);
145
+ rmSync(destination, { recursive: true, force: true });
146
+ renameSync(partial, destination);
147
+ repointCurrent(home, release.version);
148
+ } catch (error) {
149
+ rmSync(partial, { recursive: true, force: true });
150
+ rmSync(tarball, { force: true });
151
+ throw error;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * @param {typeof fetch} fetchImpl
157
+ * @param {string} url
158
+ * @param {string} destination
159
+ * @returns {Promise<void>}
160
+ */
161
+ async function downloadTarball(fetchImpl, url, destination) {
162
+ const response = await fetchImpl(url, { headers: releaseHeaders(), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
163
+ if (!response.ok) throw new Error(`tarball download failed: HTTP ${response.status}`);
164
+ writeFileSync(destination, Buffer.from(await response.arrayBuffer()));
165
+ }
166
+
167
+ /**
168
+ * @param {string} tarball
169
+ * @param {string} destination
170
+ * @returns {void}
171
+ */
172
+ function extractTarball(tarball, destination) {
173
+ const result = spawnSync("tar", ["-xzf", tarball, "--strip-components=1", "-C", destination], { encoding: "utf8" });
174
+ if (result.error) throw result.error;
175
+ if (result.status !== 0) throw new Error(`tar failed: ${String(result.stderr ?? "").trim() || `exit ${result.status}`}`);
176
+ }
177
+
178
+ /**
179
+ * Run the candidate before it can become `current`: it must exit 0 and print
180
+ * exactly `faberun <version>`.
181
+ *
182
+ * @param {string} partial
183
+ * @param {string} version
184
+ * @returns {void}
185
+ */
186
+ function verifyVersion(partial, version) {
187
+ const binary = join(partial, "bin", "faberun.mjs");
188
+ const result = spawnSync(process.execPath, [binary, "--version"], { encoding: "utf8" });
189
+ if (result.error) throw result.error;
190
+ const printed = String(result.stdout ?? "").trim();
191
+ if (result.status !== 0) throw new Error(`new version failed to run: ${printed || `exit ${result.status}`}`);
192
+ if (printed !== `faberun ${version}`) throw new Error(`new version printed ${printed || "nothing"}; expected faberun ${version}`);
193
+ }
194
+
195
+ /**
196
+ * Point `current` at the new version atomically: a `current.tmp` symlink then a
197
+ * rename over `current`, so a concurrent reader sees either the old target or
198
+ * the new one, never a missing link.
199
+ *
200
+ * @param {string} home
201
+ * @param {string} version
202
+ * @returns {void}
203
+ */
204
+ function repointCurrent(home, version) {
205
+ const link = currentLink(home);
206
+ const temporary = `${link}.tmp`;
207
+ rmSync(temporary, { force: true });
208
+ // SPEC.md's layout: `current -> versions/<v>`, relative to the home.
209
+ symlinkSync(join("versions", version), temporary);
210
+ renameSync(temporary, link);
211
+ }
212
+
213
+ /** @returns {Record<string, string>} */
214
+ function releaseHeaders() {
215
+ return { Accept: "application/vnd.github+json", "User-Agent": `faberun/${packageVersion()}` };
216
+ }