@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/CHANGELOG.md +82 -0
- package/README.md +34 -1
- package/bin/cli.js +630 -57
- package/docs/02-the-flow.md +1 -1
- package/docs/04-step-2-scenarios.md +6 -0
- package/docs/08-step-6-verify.md +4 -0
- package/docs/09-the-loop.md +2 -0
- package/docs/appendix-c-glossary.md +8 -0
- package/package.json +4 -1
- package/skill/add/SKILL.md +30 -2
- package/skill/add/fold.md +53 -35
- package/skill/add/graduate.md +2 -1
- package/skill/add/intake.md +6 -0
- package/skill/add/loop.md +18 -4
- package/skill/add/phases/0-setup.md +1 -1
- package/skill/add/phases/2-scenarios.md +2 -0
- package/skill/add/phases/3-contract.md +1 -1
- package/skill/add/phases/6-verify.md +10 -1
- package/skill/add/release.md +2 -1
- package/skill/add/report-template.md +83 -19
- package/skill/add/scope.md +41 -0
- package/tooling/add.py +536 -91
- package/tooling/templates/GLOSSARY.md.tmpl +2 -0
- package/tooling/templates/MILESTONE.md.tmpl +23 -0
- package/tooling/templates/TASK.md.tmpl +12 -1
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
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
fs.cpSync(src, dest, { recursive: true });
|
|
119
|
+
return generic;
|
|
68
120
|
}
|
|
69
121
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
fs.
|
|
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
|
-
|
|
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
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
398
|
+
log("\nDone. " + (args.noSkill ? "The engine + book are" : "The `add` skill + tooling are") +
|
|
399
|
+
" installed (no project state yet — that'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
|
-
|
|
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
|
-
|
|
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
|
-
" ·
|
|
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
|
-
|
|
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
|
+
};
|