@pilotspace/add 1.6.0 → 1.7.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.
package/bin/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  /**
5
5
  * @pilotspace/add installer.
6
6
  *
7
- * npx @pilotspace/add init [targetDir] [--force] [--stage <stage>] [--name <name>]
7
+ * npx @pilotspace/add init [targetDir] [--force] [--stage <stage>] [--name <name>] [--yes|--non-interactive]
8
8
  *
9
9
  * Installs the ADD skill + tooling + book into a target project:
10
10
  * <target>/.claude/skills/add/ (the skill Claude loads)
@@ -15,12 +15,18 @@
15
15
  * to a CLI user. A pre-run plain init would grandfather-lock the gate before `/add` runs
16
16
  * AND consume the brownfield signal in the terminal, where the AI never sees it.
17
17
  *
18
- * Zero npm dependencies, no Python needed at install time. Designed for failure:
19
- * verifies sources exist before copying, never clobbers an existing skill.
18
+ * One lazy, optional dependency (@clack/prompts) powers the interactive flow on a real
19
+ * terminal; it is dynamic-import()ed ONLY on that path, so a non-interactive / CI run
20
+ * (and the `--yes` / `--non-interactive` path) never loads it and degrades to plain text
21
+ * if it is missing. No Python needed at install time. Designed for failure: verifies
22
+ * sources exist before copying, never clobbers an existing skill, never throws on a
23
+ * non-TTY or a failed clack import.
20
24
  */
21
25
 
22
26
  const fs = require("fs");
23
27
  const path = require("path");
28
+ const os = require("os");
29
+ const crypto = require("crypto");
24
30
 
25
31
  const PKG_ROOT = path.resolve(__dirname, "..");
26
32
 
@@ -32,11 +38,26 @@ function parseArgs(argv) {
32
38
  // stage/name stay null unless EXPLICITLY passed — the engine's own `init`
33
39
  // defaults the stage and infers the name from the folder, so the manual-init
34
40
  // hint only echoes flags the user actually chose (shortest true command).
35
- const args = { _: [], force: false, check: false, stage: null, name: null };
41
+ const args = { _: [], force: false, check: false, noSkill: false, stage: null, name: null,
42
+ yes: false, nonInteractive: false, global: false, globalData: false };
36
43
  for (let i = 0; i < argv.length; i++) {
37
44
  const a = argv[i];
38
45
  if (a === "--force") args.force = true;
39
46
  else if (a === "--check") args.check = true;
47
+ // --global: ALSO install the managed layer to a shared home + register this project
48
+ // (the per-project self-contained drop still runs). update --global refreshes all.
49
+ else if (a === "--global") args.global = true;
50
+ // --global-data: (implies --global) ALSO persist this project's user-data under
51
+ // <home>/data/<key> keyed by path (opt-in, one-way snapshot).
52
+ else if (a === "--global-data") args.globalData = true;
53
+ // --yes / --non-interactive: skip all prompts, take defaults — the explicit
54
+ // non-interactive selector the interactive() gate honors (CI/pipes do this too).
55
+ else if (a === "--yes" || a === "-y") args.yes = true;
56
+ else if (a === "--non-interactive") args.nonInteractive = true;
57
+ // --no-skill: drop the engine + book ONLY, not the skill. The Claude Code plugin
58
+ // already provides the `add` skill, so a plugin bootstrap uses this to materialize
59
+ // .add/tooling/ + .add/docs/ into the project without a duplicate .claude/skills/add.
60
+ else if (a === "--no-skill") args.noSkill = true;
40
61
  else if (a === "--stage" || a === "--name") {
41
62
  const v = argv[++i];
42
63
  // fail loudly on a trailing/abutting flag — never silently drop a value
@@ -50,64 +71,385 @@ function parseArgs(argv) {
50
71
  return args;
51
72
  }
52
73
 
53
- function copyDir(src, dest, { skipIfExists, cleanReplace } = {}) {
54
- if (!fs.existsSync(src)) fail("missing packaged source: " + src);
55
- if (skipIfExists && fs.existsSync(dest)) {
56
- warn(dest + " exists leaving it untouched");
57
- return;
74
+ // --- agent detection: which coding agent is invoking the installer -----------
75
+ // ORDERED registry; detectAgent walks it top->bottom, first match wins, `generic` is
76
+ // the fallback. Mirror of _installer.py:AGENT_PROFILES. The per-agent env SIGNAL is
77
+ // best-effort (a mis-detect degrades to generic + is overridable in the clack confirm)
78
+ // — refine via a SPEC delta, never a hard fail.
79
+ const GENERIC_NEXT =
80
+ "open your AI Agent CLI (like Claude Code, Codex, etc.), then run `/add`, and " +
81
+ "say what you want to build — the agent sets up the foundation, sizes it into a " +
82
+ "milestone, and drives the build with you; you sign off once, at the lock-down.";
83
+
84
+ const AGENT_PROFILES = [
85
+ { id: "claude", label: "Claude Code / Claude app", integration_file: "CLAUDE.md",
86
+ env: ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"], envPrefix: null,
87
+ next_step: "Open Claude Code and run `/add` — the skill drives intake -> milestone -> build." },
88
+ { id: "codex", label: "Codex", integration_file: "AGENTS.md",
89
+ env: ["CODEX_HOME"], envPrefix: "CODEX_",
90
+ next_step: "Open Codex — it reads AGENTS.md; run `/add` or say what you want to build." },
91
+ { id: "opencode", label: "OpenCode", integration_file: "AGENTS.md",
92
+ env: ["OPENCODE"], envPrefix: "OPENCODE",
93
+ next_step: "Open OpenCode — it reads AGENTS.md; say what you want to build." },
94
+ { id: "generic", label: "your AI agent", integration_file: "AGENTS.md",
95
+ env: [], envPrefix: null, next_step: GENERIC_NEXT },
96
+ ];
97
+
98
+ // The SAME markers add.py:sync-guidelines uses, so init's sync-guidelines REPLACES this
99
+ // drop-time pointer in place (one block, never a duplicate).
100
+ const GUIDE_BEGIN = "<!-- ADD:BEGIN — managed by `add.py sync-guidelines`; do not edit inside -->";
101
+ const GUIDE_END = "<!-- ADD:END -->";
102
+
103
+ function profileMatches(profile, env) {
104
+ for (const key of profile.env) { if (env[key]) return true; }
105
+ if (profile.envPrefix) {
106
+ for (const k of Object.keys(env)) {
107
+ if (env[k] && k.startsWith(profile.envPrefix)) return true;
108
+ }
58
109
  }
59
- // Clean replace: drop a stale dest before copying so a `--force` re-install can
60
- // never leave orphaned files from a previous version behind. fs.cpSync merges
61
- // (it never removes), so without this `--force` is a merge, not a replace. Mirrors
62
- // _installer.py's `shutil.rmtree(skill_dest)` so npm and pip behave identically.
63
- if (cleanReplace && fs.existsSync(dest)) {
64
- fs.rmSync(dest, { recursive: true, force: true });
110
+ return false;
111
+ }
112
+
113
+ // Pure, total, deterministic: same env -> same profile; never throws. Generic is last.
114
+ function detectAgent(env) {
115
+ const generic = AGENT_PROFILES[AGENT_PROFILES.length - 1];
116
+ for (const profile of AGENT_PROFILES.slice(0, -1)) {
117
+ if (profileMatches(profile, env)) return profile;
65
118
  }
66
- fs.mkdirSync(path.dirname(dest), { recursive: true });
67
- fs.cpSync(src, dest, { recursive: true });
119
+ return generic;
68
120
  }
69
121
 
70
- function cmdInit(args) {
71
- const target = path.resolve(args._[0] || ".");
72
- if (!fs.existsSync(target)) fail("target directory does not exist: " + target);
73
- log("Installing ADD into " + target);
122
+ // A PATH lookup (no spawn): is an executable named `cmd` on PATH? Fail-soft -> null.
123
+ // Injectable into the enriched detector so the dev machine's installed agents never pollute tests.
124
+ function whichSync(cmd) {
125
+ try {
126
+ const dirs = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
127
+ const exts = process.platform === "win32"
128
+ ? (process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";")
129
+ : [""];
130
+ for (const dir of dirs) {
131
+ for (const ext of exts) {
132
+ const hit = path.join(dir, cmd + ext);
133
+ if (fs.existsSync(hit)) return hit;
134
+ }
135
+ }
136
+ } catch (_e) { /* fail-soft */ }
137
+ return null;
138
+ }
74
139
 
75
- // 1. skill -> .claude/skills/add
76
- copyDir(
77
- path.join(PKG_ROOT, "skill", "add"),
78
- path.join(target, ".claude", "skills", "add"),
79
- { skipIfExists: !args.force, cleanReplace: args.force }
140
+ // ADDITIVE enrichment for the INTERACTIVE default — never replaces detectAgent (which stays
141
+ // env-only; test_agent_detect pins it, the non-interactive write uses it). Precedence:
142
+ // env signal (authoritative) > a CLAUDE.md in the target (repo signal; AGENTS.md is ambiguous,
143
+ // so it does NOT pick) > an installed agent CLI (machine signal; PATH lookup only) > generic.
144
+ // Pure + fail-soft: a throwing probe reads as absent. `which` is injectable for hermetic tests.
145
+ function detectAgentEnriched(env, target, which) {
146
+ which = which || whichSync;
147
+ const base = detectAgent(env);
148
+ if (base.id !== "generic") return base; // env signal wins
149
+ const byId = {};
150
+ for (const p of AGENT_PROFILES) byId[p.id] = p;
151
+ try {
152
+ if (target && fs.existsSync(path.join(target, "CLAUDE.md"))) return byId.claude; // repo signal
153
+ } catch (_e) { /* fall through */ }
154
+ for (const id of ["claude", "codex", "opencode"]) {
155
+ try { if (which(id)) return byId[id]; } catch (_e) { /* probe absent */ } // machine signal
156
+ }
157
+ return base; // generic
158
+ }
159
+
160
+ // Fail-soft pre-flight summary for the INTERACTIVE path (the caller gates):
161
+ // "Pre-flight: git <✓|–> · python3 <✓|–> · agent: <label>". Each probe is a PATH lookup;
162
+ // a failure reads as absent. Never throws.
163
+ function readinessLine(env, target, which) {
164
+ env = env || process.env;
165
+ which = which || whichSync;
166
+ const caps = terminalCaps(env, process.stdout);
167
+ const tick = caps.unicode ? "✓" : "+";
168
+ const cross = caps.unicode ? "–" : "-";
169
+ const sep = caps.unicode ? " · " : " | ";
170
+ const have = (cmd) => { try { return !!which(cmd); } catch (_e) { return false; } };
171
+ let label;
172
+ try { label = detectAgentEnriched(env, target, which).label; }
173
+ catch (_e) { label = "your AI agent"; }
174
+ const mark = (ok) => (ok ? tick : cross);
175
+ return "Pre-flight: git " + mark(have("git")) + sep +
176
+ "python3 " + mark(have("python3")) + sep + "agent: " + label;
177
+ }
178
+
179
+ function agentPointerBlock(profile) {
180
+ return (
181
+ GUIDE_BEGIN + "\n" +
182
+ "## ADD — how to work in this repo\n" +
183
+ "\n" +
184
+ "This project uses **ADD (AI-Driven Development)**. The engine + book are installed.\n" +
185
+ "To begin: run `python3 .add/tooling/add.py status` (the resume point), read\n" +
186
+ "`.add/PROJECT.md`, then `python3 .add/tooling/add.py guide` for the current phase.\n" +
187
+ "\n" +
188
+ profile.next_step + "\n" +
189
+ "\n" +
190
+ "This pointer is replaced by the full guideline block when `add.py sync-guidelines`\n" +
191
+ "runs (at `/add`->init). Edit outside the markers, not inside.\n" +
192
+ GUIDE_END
80
193
  );
81
- log(" ✓ skill -> .claude/skills/add/");
82
-
83
- // 2. tooling -> .add/tooling (exclude tests from the installed copy)
84
- const toolingDest = path.join(target, ".add", "tooling");
85
- copyDir(path.join(PKG_ROOT, "tooling"), toolingDest, { skipIfExists: false });
86
- // installed copy is runtime-only: drop ALL test files and any compiled cache
87
- // (glob test_*.py not just test_add.py — so no test leaks into installs)
88
- fs.rmSync(path.join(toolingDest, "__pycache__"), { recursive: true, force: true });
89
- for (const entry of fs.readdirSync(toolingDest)) {
90
- if (/^test_.*\.py$/.test(entry)) {
91
- fs.rmSync(path.join(toolingDest, entry), { force: true });
194
+ }
195
+
196
+ // Inject the ADD pointer into <target>/<integration_file>, mirroring add.py:_inject_block.
197
+ // created|updated|unchanged|skipped. Only the marked region is (re)written; content outside
198
+ // the markers is preserved; a real change backs up <file>.bak first. Fail-soft (warn+skip).
199
+ function writeAgentPointer(target, profile) {
200
+ const dest = path.join(target, profile.integration_file);
201
+ const block = agentPointerBlock(profile);
202
+ try {
203
+ if (fs.existsSync(dest)) {
204
+ const current = fs.readFileSync(dest, "utf8");
205
+ const begin = current.indexOf(GUIDE_BEGIN);
206
+ let next;
207
+ if (begin !== -1) {
208
+ const endIdx = current.indexOf(GUIDE_END, begin);
209
+ if (endIdx !== -1) {
210
+ next = current.slice(0, begin) + block + current.slice(endIdx + GUIDE_END.length);
211
+ } else { // begin with no end: corrupt — append fresh
212
+ next = current.replace(/\n+$/, "") + "\n\n" + block + "\n";
213
+ }
214
+ } else { // no block yet — append, keep user content
215
+ next = current.replace(/\n+$/, "") + "\n\n" + block + "\n";
216
+ }
217
+ if (next === current) return "unchanged";
218
+ fs.writeFileSync(dest + ".bak", current); // rollback path before mutate
219
+ fs.writeFileSync(dest, next);
220
+ return "updated";
92
221
  }
222
+ fs.writeFileSync(dest, block + "\n");
223
+ return "created";
224
+ } catch (e) {
225
+ warn("could not write " + profile.integration_file + " — " +
226
+ (e && e.message ? e.message : e) + "; skipped");
227
+ return "skipped";
228
+ }
229
+ }
230
+
231
+ // --- interactive layer (clack on a real TTY; plain text everywhere else) -----
232
+ // Designed-for-failure: any doubt (non-TTY, CI, --yes, a failed import, an
233
+ // un-promptable stream) degrades to the EXACT plain-text path below. The clack
234
+ // import is dynamic + lazy (clack 1.x is ESM-only) so a non-interactive / CI run
235
+ // never loads it. A test seam (ADD_INSTALLER_FORCE_INTERACTIVE) reaches the branch
236
+ // without a PTY: "1" forces interactive, "fail" forces it but throws on import.
237
+
238
+ function interactive(args) {
239
+ if (args.yes || args.nonInteractive) return false; // explicit opt-out wins
240
+ const seam = process.env.ADD_INSTALLER_FORCE_INTERACTIVE;
241
+ if (seam === "1" || seam === "fail") return true; // documented test seam
242
+ return Boolean(process.stdout.isTTY && process.stdin.isTTY) && !process.env.CI;
243
+ }
244
+
245
+ async function loadClack() {
246
+ // honors the "fail" seam so the clack_unavailable fallback is testable without
247
+ // uninstalling the dependency.
248
+ if (process.env.ADD_INSTALLER_FORCE_INTERACTIVE === "fail") {
249
+ throw new Error("forced clack import failure (test seam)");
250
+ }
251
+ return import("@clack/prompts");
252
+ }
253
+
254
+ // --- brand + feature showcase (interactive path only; fail-soft) -------------
255
+ // Wordmark + value line + the 7-step Specify->Observe loop, rendered BEFORE the first
256
+ // prompt on the interactive path only — so the non-interactive byte stream is unchanged.
257
+ // The 7 labels are the real ADD phases (grounded in the method, never invented). Fail-soft:
258
+ // any draw error is swallowed so a banner can never abort the install. No color is emitted
259
+ // (default accent: none); the glyphs / tagline / accent are a SWAPPABLE content slot.
260
+ const BRAND_LOOP = ["Specify", "Scenarios", "Contract", "Tests", "Build", "Verify", "Observe"];
261
+
262
+ function terminalCaps(env, stream) {
263
+ const width = Number(env.COLUMNS) || (stream && stream.columns) || 80;
264
+ const enc = env.LC_ALL || env.LC_CTYPE || env.LANG || "";
265
+ const unicode = /utf-?8/i.test(enc) && !env.ADD_INSTALLER_ASCII;
266
+ return { width: width, unicode: unicode };
267
+ }
268
+
269
+ function brandLines(caps) {
270
+ const head = (caps.unicode && caps.width >= 40)
271
+ ? [
272
+ " █████╗ ██████╗ ██████╗",
273
+ "██╔══██╗██╔══██╗██╔══██╗",
274
+ "███████║██║ ██║██║ ██║",
275
+ "██╔══██║██║ ██║██║ ██║",
276
+ "██║ ██║██████╔╝██████╔╝",
277
+ "╚═╝ ╚═╝╚═════╝ ╚═════╝ ",
278
+ ]
279
+ : ["ADD"]; // plain-ASCII wordmark fallback
280
+ const arrow = caps.unicode ? " → " : " -> ";
281
+ const dash = caps.unicode ? " — " : " - ";
282
+ return head.concat([
283
+ "AI-Driven Development",
284
+ "",
285
+ "Spec-and-tests-first development" + dash + "any agent, through the CLI, no lost context.",
286
+ "The loop ADD drives with you:",
287
+ " " + BRAND_LOOP.join(arrow),
288
+ "",
289
+ ]);
290
+ }
291
+
292
+ function renderBrand(env, stream) {
293
+ try {
294
+ env = env || process.env;
295
+ stream = stream || process.stdout;
296
+ stream.write(brandLines(terminalCaps(env, stream)).join("\n") + "\n");
297
+ } catch (_e) { /* fail-soft: a banner must never abort the install */ }
298
+ }
299
+
300
+ // The two install-scope choices — global-first (recommended) vs self-contained. PURE +
301
+ // exported (the pip _scope_options twin) so the recommended pick + its why are hermetically
302
+ // testable; the interactive scope SELECT renders these.
303
+ function scopeOptions() {
304
+ return [
305
+ { value: "global", label: "Global home + this project",
306
+ hint: "a shared ~/.add + ~/.claude/skills/add reused by every project (this project still gets its own copy)",
307
+ recommended: true },
308
+ { value: "project", label: "This project only",
309
+ hint: "self-contained + git-tracked: nothing is written outside this folder" },
310
+ ];
311
+ }
312
+
313
+ // Returns { cancelled, target, profile, global }. A cancel happens BEFORE any file is written, so a
314
+ // cancelled run leaves the target untouched. Without a real TTY to read (the forced
315
+ // test seam), we cannot prompt — abort safely rather than hang. `askScope` is false when an
316
+ // explicit --global already chose the scope (honored, not re-asked).
317
+ async function runClackPreamble(clack, target, detected, askScope) {
318
+ renderBrand(process.env, process.stdout); // brand + showcase BEFORE the first prompt
319
+ try { log(readinessLine(process.env, target)); } // pre-flight: git · python3 · agent (fail-soft)
320
+ catch (_e) { /* the pre-flight line is informational — never block the install */ }
321
+ clack.intro("ADD — AI-Driven Development");
322
+ if (!process.stdin.isTTY) return { cancelled: true, target: target };
323
+ const chosen = await clack.text({
324
+ message: "Install ADD into which directory?",
325
+ initialValue: target, defaultValue: target,
326
+ });
327
+ if (clack.isCancel(chosen)) return { cancelled: true, target: target };
328
+ const ok = await clack.confirm({ message: "Write the ADD skill + tooling + book here?" });
329
+ if (clack.isCancel(ok) || !ok) return { cancelled: true, target: target };
330
+ // global-first SCOPE step (after the target confirm, before agent-detect) — recommended
331
+ // global home, explicit pick; skipped when --global already chose. global stays ADDITIVE.
332
+ let scopeGlobal = false;
333
+ if (askScope) {
334
+ const opts = scopeOptions();
335
+ const scope = await clack.select({
336
+ message: "Install scope?",
337
+ options: opts.map((o) => ({ value: o.value, label: o.label, hint: o.hint })),
338
+ initialValue: opts.find((o) => o.recommended).value,
339
+ });
340
+ if (clack.isCancel(scope)) return { cancelled: true, target: target };
341
+ scopeGlobal = scope === "global";
93
342
  }
94
- log(" ✓ tooling -> .add/tooling/add.py (+ templates)");
343
+ // agent-detect STEP (seeded delta: a STEP in THIS flow, via the clack ui layer) — the
344
+ // user confirms or overrides the detected agent before any file is written.
345
+ const picked = await clack.select({
346
+ message: "Set up for which agent? (detected: " + detected.label + ")",
347
+ options: AGENT_PROFILES.map((p) => ({ value: p.id, label: p.label })),
348
+ initialValue: detected.id,
349
+ });
350
+ if (clack.isCancel(picked)) return { cancelled: true, target: target };
351
+ const profile = AGENT_PROFILES.find((p) => p.id === picked) || detected;
352
+ // LAST optional step — a one-line build intent for `/add` to read. Fully optional: a clack
353
+ // cancel or an empty answer SKIPS (intent ""); the install has already been confirmed, so this
354
+ // never aborts. A NOTE only — it never triggers init.
355
+ let intent = "";
356
+ const typed = await clack.text({
357
+ message: "What do you want to build first? (optional — Enter to skip)",
358
+ placeholder: "", defaultValue: "",
359
+ });
360
+ if (!clack.isCancel(typed) && typed) intent = String(typed).trim();
361
+ return { cancelled: false, target: String(chosen || target), profile: profile, global: scopeGlobal, intent: intent };
362
+ }
363
+
364
+ // Persist `intent` as a NOTE at <target>/.add/.intent for `/add` to read — iff non-empty.
365
+ // DEFERRED-INIT: inert text only; never runs add.py/init, never touches state.json. Fail-soft
366
+ // (a write error is swallowed — the note is best-effort, never a reason to fail the install).
367
+ // Returns whether the note was written. Twin of _installer.py:_write_intent_note.
368
+ function writeIntentNote(target, intent) {
369
+ const text = (intent || "").trim();
370
+ if (!text) return false;
371
+ try {
372
+ const addDir = path.join(target, ".add");
373
+ fs.mkdirSync(addDir, { recursive: true }); // .add/ exists post-drop; recursive mkdir is a no-op then
374
+ fs.writeFileSync(path.join(addDir, ".intent"), text + "\n");
375
+ return true;
376
+ } catch (_e) { return false; }
377
+ }
378
+
379
+ // The drop — now a RECONCILE: restore missing managed trees + refresh present ones
380
+ // (sweep orphans) + report per-tree status. Byte-compatible handoff with the prior
381
+ // installer. The interactive path resolves a target then calls straight into this.
382
+ function dropFiles(args, target, profile, intent) {
383
+ profile = profile || detectAgent(process.env);
384
+ log("Installing ADD into " + target);
385
+ reconcile(args, target);
95
386
 
96
- // 3. docs (the book / trust layer) -> .add/docs
97
- copyDir(path.join(PKG_ROOT, "docs"), path.join(target, ".add", "docs"),
98
- { skipIfExists: false });
99
- log(" ✓ trust docs -> .add/docs/ (the AIDD book)");
387
+ // Agent detection: write THE detected agent's integration file (a marker-delimited
388
+ // pointer init's sync-guidelines later supersedes) + tailor the closing next-step.
389
+ // Best-effort + fail-soft — never aborts the successful drop above.
390
+ writeAgentPointer(target, profile);
391
+
392
+ // Optional build-intent NOTE for `/add` to read — "" (skip / non-interactive) -> no-op.
393
+ writeIntentNote(target, intent);
100
394
 
101
395
  // NO step 4: the installer DROPS FILES ONLY. Initialisation is deferred to the AI
102
396
  // (via `/add`) or a CLI user — a pre-run plain `add.py init` would grandfather-lock
103
397
  // the v12 lock-down gate before `/add` runs (see file header). So no Python is run here.
104
- log("\nDone. The `add` skill + tooling are installed (no project state yet that's intentional).");
105
- log("Next: open your AI Agent CLI (like Claude Code, Codex, etc.), then run `/add`, and say what you want to build the agent");
106
- log(" sets up the foundation, sizes it into a milestone, and drives the build with you;");
107
- log(" you sign off once, at the lock-down.");
398
+ log("\nDone. " + (args.noSkill ? "The engine + book are" : "The `add` skill + tooling are") +
399
+ " installed (no project state yetthat's intentional).");
400
+ if (profile.id === "generic") {
401
+ // the generic onramp line — kept literal so the conversational-only handoff is stable
402
+ log("Next: open your AI Agent CLI (like Claude Code, Codex, etc.), then run `/add`, and say what you want to build — the agent");
403
+ log(" sets up the foundation, sizes it into a milestone, and drives the build with you;");
404
+ log(" you sign off once, at the lock-down.");
405
+ } else {
406
+ log("Detected " + profile.label + ".");
407
+ log("Next: " + profile.next_step);
408
+ }
108
409
  log("");
109
410
  }
110
411
 
412
+ async function cmdInit(args) {
413
+ const target = path.resolve(args._[0] || ".");
414
+ if (!fs.existsSync(target)) fail("target directory does not exist: " + target);
415
+
416
+ let chosenTarget = target;
417
+ let profile = detectAgent(process.env); // default: non-interactive / fallback
418
+ let intent = ""; // build-intent NOTE — stays "" on the non-interactive path
419
+ if (interactive(args)) {
420
+ let clack = null;
421
+ try { clack = await loadClack(); }
422
+ catch (_e) { warn("clack unavailable — falling back to plain-text install"); }
423
+ if (clack) {
424
+ // enriched seed (env > CLAUDE.md > installed CLI) for the agent-select default; the user
425
+ // still confirms/overrides before any write. The non-interactive write below stays env-only.
426
+ const detected = detectAgentEnriched(process.env, target);
427
+ // an explicit --global/--global-data already chose the scope — don't re-ask it.
428
+ const askScope = !(args.global || args.globalData);
429
+ const outcome = await runClackPreamble(clack, target, detected, askScope);
430
+ if (outcome.cancelled) {
431
+ // the exit code IS the contract; a closed-pipe stdout (EPIPE) must not
432
+ // mask the cancel — guard the courtesy message, never let it throw.
433
+ try { clack.cancel("Installation cancelled — nothing was written."); }
434
+ catch (_e) { /* stdout unavailable (e.g. closed pipe) — exit code carries it */ }
435
+ process.exit(130); // user_cancelled: nothing written
436
+ }
437
+ chosenTarget = path.resolve(outcome.target);
438
+ if (!fs.existsSync(chosenTarget)) fail("target directory does not exist: " + chosenTarget);
439
+ if (outcome.profile) profile = outcome.profile; // honor the user's override
440
+ if (outcome.global) args.global = true; // honor the interactive scope pick (additive)
441
+ intent = outcome.intent || ""; // optional build-intent NOTE (written after the drop)
442
+ }
443
+ }
444
+ if (args.globalData) args.global = true; // --global-data implies --global (need a home)
445
+ // OPT-IN global home, BEFORE the per-project drop (fail-closed if the home is unwritable
446
+ // or its registry is corrupt — the package + the self-contained default stay usable).
447
+ if (args.global) installGlobal(args, chosenTarget);
448
+ dropFiles(args, chosenTarget, profile, intent);
449
+ // OPT-IN data persist, AFTER the drop (one-way snapshot of existing user-data).
450
+ if (args.globalData) installGlobalData(chosenTarget);
451
+ }
452
+
111
453
  // --- update: re-materialize the managed layer without a re-install -----------
112
454
  // The managed trees (ship-controlled). `update` clean-replaces each, so a file removed
113
455
  // upstream leaves no orphan — and never touches .add/state.json, PROJECT.md, milestones,
@@ -130,11 +472,11 @@ function readStamp(addDir) {
130
472
  try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch (_e) { return null; }
131
473
  }
132
474
 
133
- function writeStamp(addDir, version) {
475
+ function writeStamp(addDir, version, channel) {
134
476
  fs.mkdirSync(addDir, { recursive: true });
135
477
  fs.writeFileSync(
136
478
  path.join(addDir, STAMP_FILE),
137
- JSON.stringify({ version: version, channel: "npm", installed_at: new Date().toISOString() }, null, 2) + "\n"
479
+ JSON.stringify({ version: version, channel: channel || "npm", installed_at: new Date().toISOString() }, null, 2) + "\n"
138
480
  );
139
481
  }
140
482
 
@@ -151,7 +493,217 @@ function cleanReplaceTree(src, dest, stripTests) {
151
493
  }
152
494
  }
153
495
 
496
+ const TREE_LABEL = { "skill/add": "skill", "tooling": "tooling", "docs": "docs" };
497
+
498
+ // Per managed tree: "missing" (dest absent OR empty) or "present".
499
+ function managedStatus(target) {
500
+ const status = {};
501
+ for (const [sub, destParts] of MANAGED) {
502
+ const dest = path.join(target, ...destParts);
503
+ const present = fs.existsSync(dest) && fs.readdirSync(dest).length > 0;
504
+ status[sub] = present ? "present" : "missing";
505
+ }
506
+ return status;
507
+ }
508
+
509
+ // reconcile: restore-missing + refresh-present (sweep orphans) across the managed trees,
510
+ // reporting per-tree status. Honors --no-skill (the plugin provides the skill). Touches
511
+ // ONLY managed trees — never user data. Prechecks ALL sources first (design-for-failure:
512
+ // a corrupt package leaves the target untouched).
513
+ function reconcile(args, target, srcRoot) {
514
+ srcRoot = srcRoot || PKG_ROOT; // default: the package; the global home feeds propagation
515
+ const trees = MANAGED.filter(([sub]) => !(sub === "skill/add" && args.noSkill));
516
+ for (const [sub] of trees) {
517
+ if (!fs.existsSync(path.join(srcRoot, sub))) {
518
+ fail("missing packaged source: " + path.join(srcRoot, sub));
519
+ }
520
+ }
521
+ const status = managedStatus(target);
522
+ for (const [sub, destParts, stripTests] of trees) {
523
+ cleanReplaceTree(path.join(srcRoot, sub), path.join(target, ...destParts), stripTests);
524
+ const dest = destParts.join("/");
525
+ if (status[sub] === "missing") {
526
+ log(" ✓ restored " + TREE_LABEL[sub].padEnd(8) + "-> " + dest + " (was missing)");
527
+ } else {
528
+ log(" ✓ refreshed " + TREE_LABEL[sub].padEnd(8) + "-> " + dest);
529
+ }
530
+ }
531
+ return status;
532
+ }
533
+
534
+ // --- global home: an OPT-IN shared install (engine+book+skill) updated for all projects ----
535
+ // Resolution is PURE + total (never throws); the home MIRRORS the bundled managed layer so
536
+ // `update --global` propagation reuses reconcile() unchanged. Mirror of _installer.py.
537
+ function resolveGlobalHome(env) {
538
+ // ADD_HOME (set, non-empty) -> else XDG_DATA_HOME/add -> else <HOME>/.add. Reads HOME from
539
+ // the env mapping (never $HOME directly) so tests can inject a hermetic home.
540
+ env = env || process.env;
541
+ if (env.ADD_HOME) return path.resolve(env.ADD_HOME);
542
+ if (env.XDG_DATA_HOME) return path.join(path.resolve(env.XDG_DATA_HOME), "add");
543
+ return path.join(env.HOME || os.homedir(), ".add");
544
+ }
545
+
546
+ function claudeSkillsDir(env) {
547
+ env = env || process.env;
548
+ return path.join(env.HOME || os.homedir(), ".claude", "skills", "add");
549
+ }
550
+
551
+ function registryPath(home) { return path.join(home, "registry.json"); }
552
+
553
+ // [] when ABSENT; THROWS on present-but-corrupt so the caller fails LOUD (never a silent
554
+ // empty-list no-op that quietly skips every registered project).
555
+ function readRegistry(home) {
556
+ const p = registryPath(home);
557
+ if (!fs.existsSync(p)) return [];
558
+ let data;
559
+ try { data = JSON.parse(fs.readFileSync(p, "utf8")); }
560
+ catch (_e) { throw new Error("registry_corrupt"); }
561
+ if (!Array.isArray(data)) throw new Error("registry_corrupt");
562
+ return data;
563
+ }
564
+
565
+ // ATOMIC (temp + rename), de-duplicated preserving first-seen order.
566
+ function writeRegistry(home, paths) {
567
+ fs.mkdirSync(home, { recursive: true });
568
+ const seen = [];
569
+ for (const p of paths) { if (!seen.includes(p)) seen.push(p); }
570
+ const target = registryPath(home);
571
+ const tmp = target + ".tmp";
572
+ fs.writeFileSync(tmp, JSON.stringify(seen, null, 2) + "\n");
573
+ fs.renameSync(tmp, target); // atomic on the same filesystem (POSIX + Windows)
574
+ }
575
+
576
+ // The home mirrors the bundled layout (skill/add + tooling + docs at the SAME relative paths
577
+ // the package ships) so reconcile(args, project, home) reuses MANAGED unchanged.
578
+ const GLOBAL_TREES = [
579
+ ["skill/add", ["skill", "add"], false],
580
+ ["tooling", ["tooling"], true],
581
+ ["docs", ["docs"], false],
582
+ ];
583
+
584
+ // Clean-replace the bundled managed layer INTO <home> (canonical mirror), then DEPLOY the
585
+ // skill to ~/.claude/skills/add. Throws if a dir can't be written (caller -> home_unwritable).
586
+ // Prechecks ALL sources first (design-for-failure: a corrupt package leaves the home as-is).
587
+ function reconcileGlobal(home, claudeDir, noSkill) {
588
+ for (const [sub] of GLOBAL_TREES) {
589
+ if (!fs.existsSync(path.join(PKG_ROOT, sub))) {
590
+ fail("missing packaged source: " + path.join(PKG_ROOT, sub));
591
+ }
592
+ }
593
+ for (const [sub, destParts, stripTests] of GLOBAL_TREES) {
594
+ cleanReplaceTree(path.join(PKG_ROOT, sub), path.join(home, ...destParts), stripTests);
595
+ }
596
+ if (!noSkill) cleanReplaceTree(path.join(home, "skill", "add"), claudeDir, false);
597
+ }
598
+
599
+ // --- global DATA: an OPT-IN per-project user-data snapshot under <home>/data/<key> ----------
600
+ // Strictly additive; copies ONLY user-data (managed trees + transient excluded), clean-replaced,
601
+ // one-way (project->home). Mirror of _installer.py (identical key + include/exclude rule).
602
+ const DATA_EXCLUDE = ["tooling", "docs", ".update-cache", STAMP_FILE]; // managed trees + meta
603
+
604
+ // data_key twin: <sanitized-basename>-<sha1(abspath_utf8)[:12]>. Pure · total · separator-free.
605
+ function dataKey(projectAbspath) {
606
+ const p = String(projectAbspath);
607
+ const digest = crypto.createHash("sha1").update(p, "utf8").digest("hex").slice(0, 12);
608
+ const base = (path.basename(p) || "root").replace(/[^A-Za-z0-9._-]/g, "_");
609
+ return base + "-" + digest;
610
+ }
611
+
612
+ // A top-level .add/ entry is user-data unless it is a managed tree or a transient artifact.
613
+ function isUserData(name) {
614
+ if (DATA_EXCLUDE.includes(name)) return false;
615
+ if (name.startsWith("scope-snapshot")) return false;
616
+ if (name.includes("pre-archive-bak")) return false;
617
+ if (name.endsWith(".bak.json")) return false;
618
+ return true;
619
+ }
620
+
621
+ // Clean-replace a project's USER-DATA into <home>/data/<key>. true=persisted, false=skipped
622
+ // (no .add or no user-data — an honest skip). Throws if the data dir can't be written.
623
+ function persistData(home, projectAbspath) {
624
+ const addDir = path.join(projectAbspath, ".add");
625
+ if (!fs.existsSync(addDir)) return false;
626
+ const entries = fs.readdirSync(addDir).filter(isUserData);
627
+ if (entries.length === 0) return false;
628
+ const dest = path.join(home, "data", dataKey(projectAbspath));
629
+ if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true });
630
+ fs.mkdirSync(dest, { recursive: true });
631
+ for (const e of entries) {
632
+ fs.cpSync(path.join(addDir, e), path.join(dest, e), { recursive: true });
633
+ }
634
+ return true;
635
+ }
636
+
637
+ // init --global-data: persist this project's user-data after the per-project drop. Resolves the
638
+ // SAME realpath the registry uses (so the key matches). Skip+notice when empty; fail on unwritable.
639
+ function installGlobalData(chosenTarget) {
640
+ const home = resolveGlobalHome(process.env);
641
+ let resolved = chosenTarget;
642
+ try { resolved = fs.realpathSync(chosenTarget); } catch (_e) { /* fall back to the abspath */ }
643
+ let persisted;
644
+ try { persisted = persistData(home, resolved); }
645
+ catch (e) {
646
+ fail("cannot write global data " + path.join(home, "data", dataKey(resolved)) +
647
+ " — " + (e && e.message ? e.message : e));
648
+ }
649
+ if (persisted) log(" ✓ persisted data -> " + path.join(home, "data", dataKey(resolved)));
650
+ else log(" (no project data to persist yet — run /add to create one, then re-run --global-data)");
651
+ }
652
+
653
+ // init --global: install the managed layer ONCE to the shared home + register this project,
654
+ // fail-closed BEFORE the per-project drop. Returns the resolved target for the normal drop.
655
+ function installGlobal(args, chosenTarget) {
656
+ const home = resolveGlobalHome(process.env);
657
+ const claudeDir = claudeSkillsDir(process.env);
658
+ try { reconcileGlobal(home, claudeDir, args.noSkill); } // home_unwritable
659
+ catch (e) { fail("cannot write global home " + home + " — " + (e && e.message ? e.message : e)); }
660
+ writeStamp(home, pkgVersion(), "global");
661
+ let reg;
662
+ try { reg = readRegistry(home); } // registry_corrupt
663
+ catch (_e) { fail("global registry " + registryPath(home) + " is corrupt — fix or delete it; not registering"); }
664
+ let resolved = chosenTarget;
665
+ try { resolved = fs.realpathSync(chosenTarget); } catch (_e) { /* fall back to the abspath */ }
666
+ reg.push(resolved);
667
+ try { writeRegistry(home, reg); } // atomic + dedup
668
+ catch (e) { fail("cannot write global registry " + registryPath(home) + " — " + (e && e.message ? e.message : e)); }
669
+ log(" ✓ global home ready at " + home);
670
+ log(" ✓ registered " + resolved + " (registry: " + readRegistry(home).length + ")");
671
+ }
672
+
673
+ // update --global: refresh the home mirror + skill, then propagate to every registered+existing
674
+ // project via reconcile(.., home); prune vanished projects (warn) + rewrite the registry atomically.
675
+ function cmdUpdateGlobal(args) {
676
+ const home = resolveGlobalHome(process.env);
677
+ const claudeDir = claudeSkillsDir(process.env);
678
+ if (!fs.existsSync(path.join(home, STAMP_FILE))) {
679
+ fail("no global ADD install at " + home + " (.add-version not found) — run `init --global` first");
680
+ }
681
+ // Read the registry BEFORE refreshing the home — a corrupt registry fails closed with ZERO
682
+ // writes (never a silent empty-list no-op), leaving the file for the user to fix or delete.
683
+ let reg;
684
+ try { reg = readRegistry(home); }
685
+ catch (_e) { fail("global registry " + registryPath(home) + " is corrupt — fix or delete it; not propagating"); }
686
+ try { reconcileGlobal(home, claudeDir, args.noSkill); }
687
+ catch (e) { fail("cannot write global home " + home + " — " + (e && e.message ? e.message : e)); }
688
+ const version = pkgVersion();
689
+ writeStamp(home, version, "global");
690
+ const kept = [];
691
+ let pruned = 0;
692
+ for (const p of reg) {
693
+ if (!fs.existsSync(p)) { log(" ⚠ registered project " + p + " not found — pruning"); pruned++; continue; }
694
+ reconcile(args, p, home); // standard MANAGED map, sourced from the home mirror
695
+ // re-persist an opted-in project (one that already has a snapshot); a vanished
696
+ // project's snapshot is KEPT above (the backup outlives the dir).
697
+ if (fs.existsSync(path.join(home, "data", dataKey(p)))) persistData(home, p);
698
+ kept.push(p);
699
+ }
700
+ writeRegistry(home, kept);
701
+ log("ADD " + version + " · global home + " + kept.length + " project(s) reconciled" +
702
+ (pruned ? " (" + pruned + " pruned)" : "") + ".");
703
+ }
704
+
154
705
  function cmdUpdate(args) {
706
+ if (args.global) return cmdUpdateGlobal(args);
155
707
  const target = path.resolve(args._[0] || ".");
156
708
  const addDir = path.join(target, ".add");
157
709
  if (!fs.existsSync(path.join(addDir, "tooling")) && !fs.existsSync(path.join(addDir, "state.json"))) {
@@ -167,7 +719,11 @@ function cmdUpdate(args) {
167
719
  else log("ADD update available: project on " + cur + ", package is " + version + ". Run `update`.");
168
720
  return;
169
721
  }
170
- if (cur === version && !args.force) {
722
+ // same-version no-op ONLY when nothing is missing — a missing managed tree HEALS
723
+ // even at the current version (heal-reconcile).
724
+ const status = managedStatus(target);
725
+ const missing = MANAGED.some(([sub]) => status[sub] === "missing");
726
+ if (cur === version && !args.force && !missing) {
171
727
  log("ADD already at " + version + " — nothing to update (use --force to re-materialize).");
172
728
  return;
173
729
  }
@@ -176,34 +732,51 @@ function cmdUpdate(args) {
176
732
  if (fs.existsSync(stateFile)) {
177
733
  fs.copyFileSync(stateFile, path.join(addDir, "pre-update-state.bak.json"));
178
734
  }
179
- for (const [sub, destParts, stripTests] of MANAGED) {
180
- cleanReplaceTree(path.join(PKG_ROOT, sub), path.join(target, ...destParts), stripTests);
181
- }
735
+ reconcile(args, target);
182
736
  writeStamp(addDir, version);
183
737
  log("ADD updated " + (cur || "(unstamped)") + " -> " + version +
184
- " · skill · tooling · docs refreshed · your project state untouched.");
738
+ " · managed layer reconciled · your project state untouched.");
185
739
  }
186
740
 
187
- function main() {
741
+ async function main() {
188
742
  const argv = process.argv.slice(2);
189
743
  const cmd = argv[0] && !argv[0].startsWith("--") ? argv.shift() : "init";
190
744
  const args = parseArgs(argv);
191
745
  switch (cmd) {
192
746
  case "init":
193
- cmdInit(args);
747
+ await cmdInit(args);
194
748
  break;
195
749
  case "update":
196
750
  cmdUpdate(args);
197
751
  break;
198
752
  case "help":
199
753
  case "--help":
200
- log("usage: npx @pilotspace/add <init|update> [targetDir] [--force] [--check]");
754
+ log("usage: npx @pilotspace/add <init|update> [targetDir] [--force] [--check] [--no-skill] [--global] [--yes|--non-interactive]");
201
755
  log(" init install the ADD skill + tooling + book into a project");
756
+ log(" (--no-skill drops the engine + book only — used by the Claude Code plugin)");
757
+ log(" (--global ALSO installs to a shared home [ADD_HOME|XDG_DATA_HOME/add|~/.add] + registers the project)");
758
+ log(" (--global-data implies --global + persists this project's user-data under <home>/data/<key>)");
759
+ log(" (interactive in a real terminal; --yes / --non-interactive force the plain path)");
202
760
  log(" update re-materialize skill/tooling/docs to this package version (preserves your state)");
761
+ log(" (--global refreshes the shared home + propagates to every registered project)");
203
762
  break;
204
763
  default:
205
764
  fail("unknown command '" + cmd + "'. Try: npx @pilotspace/add init");
206
765
  }
207
766
  }
208
767
 
209
- main();
768
+ // Run ONLY when invoked directly (the bin / npx entry). When `require()`d — the test harness
769
+ // imports the pure detectors — main() must NOT fire (it would parse argv + install). This guard
770
+ // changes no runtime behavior on the real CLI path; the non-interactive output stays byte-identical.
771
+ if (require.main === module) {
772
+ main().catch((e) => fail(e && e.message ? e.message : String(e)));
773
+ }
774
+
775
+ module.exports = {
776
+ detectAgent: detectAgent,
777
+ detectAgentEnriched: detectAgentEnriched,
778
+ readinessLine: readinessLine,
779
+ whichSync: whichSync,
780
+ scopeOptions: scopeOptions,
781
+ writeIntentNote: writeIntentNote,
782
+ };