@dtlabs/ai-toolkit 0.10.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +108 -0
- package/bin/cli.js +1457 -143
- package/docs/installation.md +74 -8
- package/docs/reference.md +60 -12
- package/lib/asset-catalog.js +52 -0
- package/lib/execution-ledger.js +515 -0
- package/package.json +9 -7
- package/{.claude → src/claude}/agents/define-feature.md +36 -60
- package/{.claude → src/claude}/agents/install-toolkit.md +30 -103
- package/{.claude → src/claude}/scripts/wb-render.js +101 -2
- package/{.claude → src/claude}/scripts/wb-validate.js +32 -2
- package/{.claude → src/claude}/skills/implement-feature/SKILL.md +4 -2
- package/{.claude → src/claude}/workflows/am-phase1.js +9 -12
- package/{.claude → src/claude}/workflows/pm-phase1.js +25 -75
- package/{.claude → src/claude}/workflows/pm-phase2.js +57 -108
- package/{.claude → src/claude}/workflows/pm-phase3.js +162 -75
- package/.claude/scripts/.gitkeep +0 -0
- /package/{.claude → src/claude}/agents/concurrency-safety-assessment.md +0 -0
- /package/{.claude → src/claude}/agents/dependency-injection-refactoring.md +0 -0
- /package/{.claude → src/claude}/agents/dependency-supply-chain-security.md +0 -0
- /package/{.claude → src/claude}/agents/developer-backend.md +0 -0
- /package/{.claude → src/claude}/agents/developer-frontend.md +0 -0
- /package/{.claude → src/claude}/agents/developer-testing.md +0 -0
- /package/{.claude → src/claude}/agents/domain-model-refactoring.md +0 -0
- /package/{.claude → src/claude}/agents/generate-requirements.md +0 -0
- /package/{.claude → src/claude}/agents/generate-tech-spec.md +0 -0
- /package/{.claude → src/claude}/agents/generate-work-breakdown.md +0 -0
- /package/{.claude → src/claude}/agents/generic-software-assessment.md +0 -0
- /package/{.claude → src/claude}/agents/god-class-decomposition.md +0 -0
- /package/{.claude → src/claude}/agents/init-agents-md.md +0 -0
- /package/{.claude → src/claude}/agents/intervention-documentation-standard.md +0 -0
- /package/{.claude → src/claude}/agents/layered-architecture-assessment.md +0 -0
- /package/{.claude → src/claude}/agents/review-solution.md +0 -0
- /package/{.claude → src/claude}/agents/security-hardening.md +0 -0
- /package/{.claude → src/claude}/agents/validate-feature-docs.md +0 -0
- /package/{.claude → src/claude}/agents/validate-work-breakdown-semantic.md +0 -0
- /package/{.claude → src/claude}/commands/assessment-status.md +0 -0
- /package/{.claude → src/claude}/commands/check-docs.md +0 -0
- /package/{.claude → src/claude}/commands/check-interventions.md +0 -0
- /package/{.claude → src/claude}/commands/feature-status.md +0 -0
- /package/{.claude → src/claude}/commands/next-intervention.md +0 -0
- /package/{.claude → src/claude}/commands/next-task.md +0 -0
- /package/{.claude → src/claude}/commands/pr-description.md +0 -0
- /package/{.claude → src/claude}/skills/assess-codebase/SKILL.md +0 -0
- /package/{.claude → src/claude}/skills/define-feature/SKILL.md +0 -0
- /package/{.claude → src/claude}/skills/hi-gaia/SKILL.md +0 -0
- /package/{.claude → src/claude}/skills/init-agents/SKILL.md +0 -0
- /package/{.claude → src/claude}/skills/install-toolkit/SKILL.md +0 -0
- /package/{.claude → src/claude}/workflows/am-phase2.js +0 -0
package/bin/cli.js
CHANGED
|
@@ -3,6 +3,7 @@ const fs = require('fs');
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const crypto = require('crypto');
|
|
5
5
|
const readline = require('readline');
|
|
6
|
+
const executionLedger = require('../lib/execution-ledger');
|
|
6
7
|
const packageRoot = path.join(__dirname, '..');
|
|
7
8
|
|
|
8
9
|
// ── colors ────────────────────────────────────────────────────────────────────
|
|
@@ -40,23 +41,6 @@ function banner() {
|
|
|
40
41
|
|
|
41
42
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
42
43
|
|
|
43
|
-
// User-owned config that must NEVER be copied into a destination. We only verify
|
|
44
|
-
// and advise on these — copying would clobber the user's existing settings.
|
|
45
|
-
const NEVER_COPY = new Set(['settings.json', 'settings.local.json']);
|
|
46
|
-
|
|
47
|
-
// Path segments that identify non-distributable source files. Applied by
|
|
48
|
-
// isDistributable() inside expandMappings() — the single source of truth for
|
|
49
|
-
// whether a file enters the install plan, copy, manifest, and orphan pipeline.
|
|
50
|
-
// Tests, fixtures, and helpers live under .claude/scripts/tests/ and must never
|
|
51
|
-
// be shipped to destination projects (FTR-015 will move them to tests/**).
|
|
52
|
-
const NEVER_DIST_SEGMENTS = ['.claude/scripts/tests/'];
|
|
53
|
-
|
|
54
|
-
function isDistributable(srcPath) {
|
|
55
|
-
const n = srcPath.replace(/\\/g, '/');
|
|
56
|
-
if (NEVER_DIST_SEGMENTS.some(seg => n.includes(seg))) return false;
|
|
57
|
-
if (path.basename(srcPath).endsWith('.test.js') && n.includes('.claude/scripts/')) return false;
|
|
58
|
-
return true;
|
|
59
|
-
}
|
|
60
44
|
|
|
61
45
|
function fileHash(filePath) {
|
|
62
46
|
return crypto.createHash('md5').update(fs.readFileSync(filePath)).digest('hex');
|
|
@@ -175,14 +159,10 @@ function expandMappings(mappings) {
|
|
|
175
159
|
if (!fs.existsSync(src)) continue;
|
|
176
160
|
if (fs.statSync(src).isDirectory()) {
|
|
177
161
|
for (const entry of walkDir(src)) {
|
|
178
|
-
if (NEVER_COPY.has(path.basename(entry))) continue; // never clobber user config
|
|
179
|
-
if (!isDistributable(entry)) continue;
|
|
180
162
|
const rel = path.relative(src, entry);
|
|
181
163
|
files.push({ src: entry, dest: path.join(dest, rel) });
|
|
182
164
|
}
|
|
183
165
|
} else {
|
|
184
|
-
if (NEVER_COPY.has(path.basename(src))) continue;
|
|
185
|
-
if (!isDistributable(src)) continue;
|
|
186
166
|
files.push({ src, dest });
|
|
187
167
|
}
|
|
188
168
|
}
|
|
@@ -282,10 +262,20 @@ function computeOrphans(oldFiles, newFiles) {
|
|
|
282
262
|
.filter(f => !newSet.has(f));
|
|
283
263
|
}
|
|
284
264
|
|
|
265
|
+
// Timestamp used for the current install/upgrade session — set once so all orphans
|
|
266
|
+
// from the same run land in the same subdirectory, making the trash auditable.
|
|
267
|
+
let _trashTimestamp = null;
|
|
268
|
+
function trashTimestamp() {
|
|
269
|
+
if (!_trashTimestamp) _trashTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
270
|
+
return _trashTimestamp;
|
|
271
|
+
}
|
|
272
|
+
|
|
285
273
|
function moveToTrash(destRoot, relativePath) {
|
|
286
274
|
const source = path.join(destRoot, relativePath);
|
|
287
275
|
if (!fs.existsSync(source)) return;
|
|
288
|
-
|
|
276
|
+
// Each upgrade session gets its own timestamped subdirectory so repeated upgrades
|
|
277
|
+
// never overwrite previously trashed files. The timestamp is stable for the session.
|
|
278
|
+
const trashPath = path.join(destRoot, '.claude', '.ai-toolkit-trash', trashTimestamp(), relativePath);
|
|
289
279
|
ensureDir(trashPath);
|
|
290
280
|
try {
|
|
291
281
|
fs.renameSync(source, trashPath);
|
|
@@ -299,7 +289,7 @@ function moveToTrash(destRoot, relativePath) {
|
|
|
299
289
|
}
|
|
300
290
|
}
|
|
301
291
|
|
|
302
|
-
function writeManifest(destRoot, fileList) {
|
|
292
|
+
function writeManifest(destRoot, fileList, installationMode) {
|
|
303
293
|
const manifestPath = path.join(destRoot, '.claude', MANIFEST_FILE);
|
|
304
294
|
const trashDir = path.join(destRoot, '.claude', '.ai-toolkit-trash');
|
|
305
295
|
const filtered = fileList.filter(rel => {
|
|
@@ -309,6 +299,7 @@ function writeManifest(destRoot, fileList) {
|
|
|
309
299
|
const manifest = {
|
|
310
300
|
version: TOOLKIT_VERSION,
|
|
311
301
|
installedAt: new Date().toISOString(),
|
|
302
|
+
installationMode: installationMode,
|
|
312
303
|
files: filtered.map(f => f.replace(/\\/g, '/')),
|
|
313
304
|
};
|
|
314
305
|
try {
|
|
@@ -321,7 +312,12 @@ function writeManifest(destRoot, fileList) {
|
|
|
321
312
|
|
|
322
313
|
// ── install ───────────────────────────────────────────────────────────────────
|
|
323
314
|
|
|
324
|
-
async function runInstall(label, mappings, force, destRoot) {
|
|
315
|
+
async function runInstall(label, mappings, force, destRoot, dryRun = false, installationMode) {
|
|
316
|
+
const { getAssetCategories } = require('../lib/asset-catalog');
|
|
317
|
+
// Catalog runtime-dir prefixes (e.g. '.claude/agents/') — only these paths
|
|
318
|
+
// belong in the manifest. Documentation and CLAUDE.md are excluded.
|
|
319
|
+
const catalogPrefixes = getAssetCategories().map(c => c.runtimeDir.replace(/\\/g, '/') + '/');
|
|
320
|
+
|
|
325
321
|
const files = expandMappings(mappings);
|
|
326
322
|
const entries = categorize(files);
|
|
327
323
|
|
|
@@ -331,17 +327,26 @@ async function runInstall(label, mappings, force, destRoot) {
|
|
|
331
327
|
|
|
332
328
|
// ── prune phase (before file copy) ───────────────────────────────────────────
|
|
333
329
|
const oldManifest = readManifest(destRoot);
|
|
334
|
-
const
|
|
335
|
-
|
|
330
|
+
const allFileSet = files.map(f => path.relative(destRoot, f.dest).replace(/\\/g, '/'));
|
|
331
|
+
// Restrict manifest entries to catalog runtime assets only (no docs, no CLAUDE.md).
|
|
332
|
+
const newFileSet = allFileSet.filter(rel => catalogPrefixes.some(p => rel.startsWith(p)));
|
|
333
|
+
// Filter orphans to catalog prefixes only: entries from old manifests outside catalog dirs
|
|
334
|
+
// (e.g. docs/, CLAUDE.md written by pre-hotfix installers) must never be treated as orphans.
|
|
335
|
+
const orphans = computeOrphans(oldManifest.files, newFileSet)
|
|
336
|
+
.filter(o => catalogPrefixes.some(p => o.replace(/\\/g, '/').startsWith(p)));
|
|
336
337
|
|
|
337
338
|
const existingOrphans = orphans.filter(o => fs.existsSync(path.join(destRoot, o)));
|
|
338
339
|
|
|
339
340
|
if (existingOrphans.length > 0) {
|
|
340
341
|
console.log();
|
|
341
|
-
|
|
342
|
+
const orphanLabel = dryRun
|
|
343
|
+
? clr('red', `${existingOrphans.length} stale file(s) would be removed`)
|
|
344
|
+
: clr('red', `${existingOrphans.length} stale file(s) found`);
|
|
345
|
+
console.log(`${bold('📦 Orphan cleanup')} ${clr('gray', '→')} ${orphanLabel}`);
|
|
342
346
|
console.log(divider());
|
|
343
347
|
for (const orphan of existingOrphans) {
|
|
344
|
-
|
|
348
|
+
const marker = dryRun ? clr('red', 'WOULD REMOVE') : clr('red', 'REMOVED ');
|
|
349
|
+
console.log(` ${clr('red', '∅')} ${marker} ${orphan}`);
|
|
345
350
|
}
|
|
346
351
|
console.log(divider());
|
|
347
352
|
}
|
|
@@ -349,40 +354,43 @@ async function runInstall(label, mappings, force, destRoot) {
|
|
|
349
354
|
let removedCount = 0;
|
|
350
355
|
let keptCount = 0;
|
|
351
356
|
|
|
352
|
-
if (
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
removedCount++;
|
|
356
|
-
console.log(` ${clr('red', '∅')} ${dim(orphan)}`);
|
|
357
|
-
}
|
|
358
|
-
} else {
|
|
359
|
-
for (const orphan of existingOrphans) {
|
|
360
|
-
const fullPath = path.join(destRoot, orphan);
|
|
361
|
-
if (!fs.existsSync(fullPath)) continue;
|
|
362
|
-
const ok = await askConfirm(` Move to trash ${clr('red', orphan)}?`);
|
|
363
|
-
if (ok) {
|
|
357
|
+
if (!dryRun) {
|
|
358
|
+
if (force) {
|
|
359
|
+
for (const orphan of existingOrphans) {
|
|
364
360
|
moveToTrash(destRoot, orphan);
|
|
365
|
-
console.log(` ${clr('green', '✔')} Moved to trash\n`);
|
|
366
361
|
removedCount++;
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
362
|
+
console.log(` ${clr('red', '∅')} ${dim(orphan)}`);
|
|
363
|
+
}
|
|
364
|
+
} else {
|
|
365
|
+
for (const orphan of existingOrphans) {
|
|
366
|
+
const fullPath = path.join(destRoot, orphan);
|
|
367
|
+
if (!fs.existsSync(fullPath)) continue;
|
|
368
|
+
const ok = await askConfirm(` Move to trash ${clr('red', orphan)}?`);
|
|
369
|
+
if (ok) {
|
|
370
|
+
moveToTrash(destRoot, orphan);
|
|
371
|
+
console.log(` ${clr('green', '✔')} Moved to trash\n`);
|
|
372
|
+
removedCount++;
|
|
373
|
+
} else {
|
|
374
|
+
console.log(` ${clr('gray', '✖')} Kept as-is\n`);
|
|
375
|
+
keptCount++;
|
|
376
|
+
}
|
|
370
377
|
}
|
|
371
378
|
}
|
|
372
|
-
}
|
|
373
379
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
380
|
+
if (existingOrphans.length > 0) {
|
|
381
|
+
console.log(divider());
|
|
382
|
+
console.log(
|
|
383
|
+
` ${clr('red', `∅ Moved: ${removedCount}`)}` +
|
|
384
|
+
` ${clr('gray', `↪ .claude/.ai-toolkit-trash/`)}` +
|
|
385
|
+
` ${clr('gray', `✖ Kept: ${keptCount}`)}\n`
|
|
386
|
+
);
|
|
387
|
+
}
|
|
381
388
|
}
|
|
382
389
|
|
|
383
390
|
// ── install plan display ──────────────────────────────────────────────────────
|
|
384
391
|
console.log();
|
|
385
|
-
|
|
392
|
+
const planTitle = dryRun ? bold('📦 Dry run — install plan') : bold('📦 Install plan');
|
|
393
|
+
console.log(`${planTitle} ${clr('gray', '→')} ${clr('cyan', label)}`);
|
|
386
394
|
console.log(divider());
|
|
387
395
|
for (const e of newFiles) console.log(` ${clr('green', '✚')} ${clr('green', 'NEW ')} ${dim(path.relative(process.cwd(), e.dest))}`);
|
|
388
396
|
for (const e of modified) console.log(` ${clr('yellow', '~')} ${clr('yellow', 'MODIFIED')} ${path.relative(process.cwd(), e.dest)}`);
|
|
@@ -395,6 +403,11 @@ async function runInstall(label, mappings, force, destRoot) {
|
|
|
395
403
|
);
|
|
396
404
|
console.log();
|
|
397
405
|
|
|
406
|
+
if (dryRun) {
|
|
407
|
+
console.log(` ${clr('cyan', 'ℹ')} Dry run — no files were written.\n`);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
398
411
|
for (const e of newFiles) {
|
|
399
412
|
ensureDir(e.dest);
|
|
400
413
|
fs.copyFileSync(e.src, e.dest);
|
|
@@ -402,7 +415,7 @@ async function runInstall(label, mappings, force, destRoot) {
|
|
|
402
415
|
|
|
403
416
|
if (modified.length === 0) {
|
|
404
417
|
console.log(` ${clr('green', '✔')} All new files copied. No conflicts.\n`);
|
|
405
|
-
writeManifest(destRoot, newFileSet);
|
|
418
|
+
writeManifest(destRoot, newFileSet, installationMode);
|
|
406
419
|
return;
|
|
407
420
|
}
|
|
408
421
|
|
|
@@ -413,7 +426,7 @@ async function runInstall(label, mappings, force, destRoot) {
|
|
|
413
426
|
fs.copyFileSync(e.src, e.dest);
|
|
414
427
|
console.log(` ${clr('yellow', '↺')} ${dim(path.relative(process.cwd(), e.dest))}`);
|
|
415
428
|
}
|
|
416
|
-
writeManifest(destRoot, newFileSet);
|
|
429
|
+
writeManifest(destRoot, newFileSet, installationMode);
|
|
417
430
|
return;
|
|
418
431
|
}
|
|
419
432
|
|
|
@@ -442,7 +455,7 @@ async function runInstall(label, mappings, force, destRoot) {
|
|
|
442
455
|
` ${clr('gray', `✖ Kept as-is: ${skipped}`)}\n`
|
|
443
456
|
);
|
|
444
457
|
|
|
445
|
-
writeManifest(destRoot, newFileSet);
|
|
458
|
+
writeManifest(destRoot, newFileSet, installationMode);
|
|
446
459
|
}
|
|
447
460
|
|
|
448
461
|
// ── subagent spawn-depth check (verify & advise only — never write) ────────────
|
|
@@ -568,67 +581,6 @@ function mergeAllowlist(destDir) {
|
|
|
568
581
|
return { status: 'merged', preserved };
|
|
569
582
|
}
|
|
570
583
|
|
|
571
|
-
// INFRA-T01 (FTR-013):
|
|
572
|
-
// Append a new entry to {featureDir}/{prefix}-token-ledger.json atomically.
|
|
573
|
-
// If the file does not exist it is created. If JSON is malformed the file is
|
|
574
|
-
// overwritten with a single-element array containing the new entry.
|
|
575
|
-
//
|
|
576
|
-
// Algorithm:
|
|
577
|
-
// 1. Read and parse the existing ledger (or start with [])
|
|
578
|
-
// 2. Push the new entry
|
|
579
|
-
// 3. Write the full array back in one synchronous write (atomic)
|
|
580
|
-
function appendLedgerEntry(featureDir, prefix, entry) {
|
|
581
|
-
const filePath = path.join(featureDir, `${prefix}-token-ledger.json`);
|
|
582
|
-
let ledger = [];
|
|
583
|
-
if (fs.existsSync(filePath)) {
|
|
584
|
-
try {
|
|
585
|
-
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
586
|
-
ledger = Array.isArray(parsed) ? parsed : [];
|
|
587
|
-
} catch (_) {
|
|
588
|
-
console.log(`Warning: could not parse token ledger at ${filePath} — starting fresh`);
|
|
589
|
-
ledger = [];
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
ledger.push(entry);
|
|
593
|
-
const dir = path.dirname(filePath);
|
|
594
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
595
|
-
fs.writeFileSync(filePath, JSON.stringify(ledger, null, 2), 'utf8');
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
// INFRA-T02 (FTR-013):
|
|
599
|
-
// Find and update an existing entry in {featureDir}/{prefix}-token-ledger.json
|
|
600
|
-
// by agent key, atomically. Searches from the end of the array so that the most
|
|
601
|
-
// recent entry for a given key is updated (handles any accidental duplicates).
|
|
602
|
-
// If the file does not exist or the key is not found the call is a silent no-op.
|
|
603
|
-
//
|
|
604
|
-
// Algorithm:
|
|
605
|
-
// 1. Read and parse the existing ledger (silent return on missing/malformed)
|
|
606
|
-
// 2. Find the last entry where entry.agent === agentKey
|
|
607
|
-
// 3. Object.assign the updates onto that entry
|
|
608
|
-
// 4. Write the full array back in one synchronous write (atomic)
|
|
609
|
-
function updateLedgerEntry(featureDir, prefix, agentKey, updates) {
|
|
610
|
-
const filePath = path.join(featureDir, `${prefix}-token-ledger.json`);
|
|
611
|
-
if (!fs.existsSync(filePath)) return;
|
|
612
|
-
let ledger;
|
|
613
|
-
try {
|
|
614
|
-
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
615
|
-
ledger = Array.isArray(parsed) ? parsed : [];
|
|
616
|
-
} catch (_) {
|
|
617
|
-
console.log(`Warning: could not parse token ledger at ${filePath} — skipping update for "${agentKey}"`);
|
|
618
|
-
return;
|
|
619
|
-
}
|
|
620
|
-
let idx = -1;
|
|
621
|
-
for (let i = ledger.length - 1; i >= 0; i--) {
|
|
622
|
-
if (ledger[i] && ledger[i].agent === agentKey) { idx = i; break; }
|
|
623
|
-
}
|
|
624
|
-
if (idx === -1) {
|
|
625
|
-
console.log(`Warning: ledger entry not found for agent key "${agentKey}"`);
|
|
626
|
-
return;
|
|
627
|
-
}
|
|
628
|
-
Object.assign(ledger[idx], updates);
|
|
629
|
-
fs.writeFileSync(filePath, JSON.stringify(ledger, null, 2), 'utf8');
|
|
630
|
-
}
|
|
631
|
-
|
|
632
584
|
// US-05-T01:
|
|
633
585
|
// Idempotently append `.claude/settings.local.json` to {destDir}/.gitignore.
|
|
634
586
|
// Creates .gitignore if it does not exist (AC-06, AC-07).
|
|
@@ -660,19 +612,1019 @@ function updateGitignore(destDir) {
|
|
|
660
612
|
}
|
|
661
613
|
}
|
|
662
614
|
|
|
615
|
+
// ── resolveFeaturesRoot ───────────────────────────────────────────────────────
|
|
616
|
+
|
|
617
|
+
// US-05-TASK-BE-01 (FTR-016):
|
|
618
|
+
// Resolve the features root directory for a given working directory.
|
|
619
|
+
//
|
|
620
|
+
// Ordered precedence — ALL candidates are gathered before deciding so that
|
|
621
|
+
// ambiguity (multiply-declared AGENTS.md entries, multiple inconsistent
|
|
622
|
+
// defaults) can be detected rather than silently swallowed:
|
|
623
|
+
// 1. Explicit override via options.featuresRoot (highest precedence)
|
|
624
|
+
// 2. features_root: convention parsed from <cwd>/AGENTS.md
|
|
625
|
+
// 3. A single existing conventional default directory
|
|
626
|
+
//
|
|
627
|
+
// AGENTS.md grammar parser (deterministic):
|
|
628
|
+
// - Ignores HTML-comment lines of the form <!-- ... --> (same input line)
|
|
629
|
+
// - Strips inline # comments from the value and trims trailing whitespace
|
|
630
|
+
// - Counts only active (non-commented) declarations when detecting the
|
|
631
|
+
// multiply-declared error; throws when two or more are found
|
|
632
|
+
//
|
|
633
|
+
// Returns an absolute path.
|
|
634
|
+
// Throws a clear structured Error on ambiguous or multiply-declared roots.
|
|
635
|
+
//
|
|
636
|
+
// Parameters:
|
|
637
|
+
// cwd (string): project root directory
|
|
638
|
+
// options (object): { featuresRoot? } — explicit override, highest precedence
|
|
639
|
+
function resolveFeaturesRoot(cwd, options) {
|
|
640
|
+
const opts = options || {};
|
|
641
|
+
const resolvedCwd = path.resolve(cwd);
|
|
642
|
+
|
|
643
|
+
// Conventional default directories tried when no higher-precedence source is found.
|
|
644
|
+
const CONVENTIONAL_DEFAULTS = [
|
|
645
|
+
'internal_docs/features',
|
|
646
|
+
'docs/features',
|
|
647
|
+
];
|
|
648
|
+
|
|
649
|
+
// ── AGENTS.md grammar parser ──────────────────────────────────────────────
|
|
650
|
+
// Returns an array of trimmed value strings for each ACTIVE features_root:
|
|
651
|
+
// declaration found in <cwdDir>/AGENTS.md. Deterministic: same input →
|
|
652
|
+
// same result; does not rely on filesystem ordering.
|
|
653
|
+
function parseAgentsMdDeclarations(cwdDir) {
|
|
654
|
+
const agentsMdPath = path.join(cwdDir, 'AGENTS.md');
|
|
655
|
+
if (!fs.existsSync(agentsMdPath)) return [];
|
|
656
|
+
let content;
|
|
657
|
+
try { content = fs.readFileSync(agentsMdPath, 'utf8'); } catch (_) { return []; }
|
|
658
|
+
|
|
659
|
+
const declarations = [];
|
|
660
|
+
for (const line of content.split('\n')) {
|
|
661
|
+
const trimmed = line.trim();
|
|
662
|
+
|
|
663
|
+
// Ignore HTML comment lines: <!-- anything -->
|
|
664
|
+
if (/^<!--.*-->$/.test(trimmed)) continue;
|
|
665
|
+
|
|
666
|
+
// Match active features_root: declarations
|
|
667
|
+
const match = trimmed.match(/^features_root:\s*(.*)$/);
|
|
668
|
+
if (!match) continue;
|
|
669
|
+
|
|
670
|
+
// Strip inline # comment, then trim
|
|
671
|
+
let value = match[1];
|
|
672
|
+
const hashIdx = value.indexOf('#');
|
|
673
|
+
if (hashIdx !== -1) value = value.substring(0, hashIdx);
|
|
674
|
+
value = value.trim();
|
|
675
|
+
|
|
676
|
+
if (value) declarations.push(value);
|
|
677
|
+
}
|
|
678
|
+
return declarations;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ── Source 1: Explicit override ───────────────────────────────────────────
|
|
682
|
+
if (opts.featuresRoot !== undefined && opts.featuresRoot !== null && opts.featuresRoot !== '') {
|
|
683
|
+
return path.resolve(resolvedCwd, opts.featuresRoot);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ── Source 2: AGENTS.md features_root: convention ────────────────────────
|
|
687
|
+
const agentsDeclarations = parseAgentsMdDeclarations(resolvedCwd);
|
|
688
|
+
if (agentsDeclarations.length > 1) {
|
|
689
|
+
throw new Error(
|
|
690
|
+
`resolveFeaturesRoot: AGENTS.md declares features_root: ${agentsDeclarations.length} times ` +
|
|
691
|
+
`(values: ${agentsDeclarations.map(v => JSON.stringify(v)).join(', ')}); ` +
|
|
692
|
+
'only one active declaration is allowed'
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
if (agentsDeclarations.length === 1) {
|
|
696
|
+
return path.resolve(resolvedCwd, agentsDeclarations[0]);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// ── Source 3: Conventional defaults — use only when exactly one exists ────
|
|
700
|
+
const existingDefaults = CONVENTIONAL_DEFAULTS
|
|
701
|
+
.map(d => path.resolve(resolvedCwd, d))
|
|
702
|
+
.filter(d => { try { return fs.statSync(d).isDirectory(); } catch (_) { return false; } });
|
|
703
|
+
|
|
704
|
+
if (existingDefaults.length === 1) {
|
|
705
|
+
return existingDefaults[0];
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (existingDefaults.length > 1) {
|
|
709
|
+
throw new Error(
|
|
710
|
+
`resolveFeaturesRoot: multiple conventional default directories exist ` +
|
|
711
|
+
`(${existingDefaults.join(', ')}); declare features_root: in AGENTS.md to disambiguate`
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
throw new Error(
|
|
716
|
+
`resolveFeaturesRoot: no features root found under ${resolvedCwd}. ` +
|
|
717
|
+
'Declare features_root: in AGENTS.md or create one of the conventional default directories ' +
|
|
718
|
+
`(${CONVENTIONAL_DEFAULTS.join(', ')})`
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// ── payload & detection ───────────────────────────────────────────────────────
|
|
723
|
+
|
|
724
|
+
// Returns file-level {src, dest} mappings (absolute paths) for ALL distributable
|
|
725
|
+
// catalog assets, applying TOOLKIT_INTERNAL_ASSETS exclusions.
|
|
726
|
+
// This is the single authoritative source used by the installer (copy plan),
|
|
727
|
+
// resolver, doctor, and list-assets so that ALL four agree on exactly what a valid
|
|
728
|
+
// installation contains. effectiveRoot must be the .claude/ runtime directory.
|
|
729
|
+
function buildPayloadFileMappings(effectiveRoot) {
|
|
730
|
+
const { getAssetCategories } = require('../lib/asset-catalog');
|
|
731
|
+
const mappings = [];
|
|
732
|
+
for (const cat of getAssetCategories()) {
|
|
733
|
+
const srcDir = path.join(packageRoot, cat.sourceDir);
|
|
734
|
+
if (!fs.existsSync(srcDir)) continue;
|
|
735
|
+
const internalItems = TOOLKIT_INTERNAL_ASSETS[cat.name];
|
|
736
|
+
const catSubDir = cat.runtimeDir.replace(/^\.claude\//, '');
|
|
737
|
+
const destCatDir = path.join(effectiveRoot, catSubDir);
|
|
738
|
+
let entries;
|
|
739
|
+
try { entries = fs.readdirSync(srcDir); } catch (_) { continue; }
|
|
740
|
+
for (const item of entries) {
|
|
741
|
+
if (internalItems && internalItems.has(item)) continue;
|
|
742
|
+
const itemSrc = path.join(srcDir, item);
|
|
743
|
+
let stat;
|
|
744
|
+
try { stat = fs.statSync(itemSrc); } catch (_) { continue; }
|
|
745
|
+
if (stat.isDirectory()) {
|
|
746
|
+
try {
|
|
747
|
+
for (const f of walkDir(itemSrc)) {
|
|
748
|
+
const rel = path.relative(itemSrc, f);
|
|
749
|
+
mappings.push({ src: f, dest: path.join(destCatDir, item, rel) });
|
|
750
|
+
}
|
|
751
|
+
} catch (_) { /* ignore */ }
|
|
752
|
+
} else {
|
|
753
|
+
mappings.push({ src: itemSrc, dest: path.join(destCatDir, item) });
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return mappings;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Returns the Set of absolute runtime dest-paths derived from buildPayloadFileMappings.
|
|
761
|
+
// Used by resolver, doctor, and list-assets for completeness checks.
|
|
762
|
+
function buildExpectedPayload(effectiveRoot) {
|
|
763
|
+
return new Set(buildPayloadFileMappings(effectiveRoot).map(m => path.resolve(m.dest)));
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// ── validateRuntimeRelativePath ───────────────────────────────────────────────
|
|
767
|
+
// Single source of truth for runtime-path safety and canonicalization. Used by
|
|
768
|
+
// BOTH resolveClaudeRuntimeAsset() (lenient: normalizes and accepts) and
|
|
769
|
+
// validateManifestFilesField() (strict: rejects any non-canonical form). The
|
|
770
|
+
// two callers format their own diagnostics, but traversal, absolute/drive paths,
|
|
771
|
+
// null bytes, confinement, and the canonical computation live here only.
|
|
772
|
+
//
|
|
773
|
+
// A path is safe when it is a non-empty string with no surrounding whitespace,
|
|
774
|
+
// no null byte, is neither a Unix/Windows absolute path nor drive-relative
|
|
775
|
+
// (e.g. 'C:foo'), and contains no '..' traversal segment.
|
|
776
|
+
// Its canonical form uses '/' as the sole separator with no '.'/empty segments,
|
|
777
|
+
// no double separators, and no trailing slash.
|
|
778
|
+
//
|
|
779
|
+
// options:
|
|
780
|
+
// root — absolute installation root; when given, the returned
|
|
781
|
+
// `resolved` is confined within it. NOTE: after the
|
|
782
|
+
// absolute/drive/traversal rejections above, a surviving
|
|
783
|
+
// value can never escape root, so the 'escapes-root' branch
|
|
784
|
+
// is unreachable defence in depth (retained against future
|
|
785
|
+
// OS/symlink normalization surprises).
|
|
786
|
+
// requireCanonical — when true, a value that is not already canonical is an
|
|
787
|
+
// error (code 'non-canonical'); the manifest validator uses
|
|
788
|
+
// this so doctor flags incoherent manifests instead of
|
|
789
|
+
// silently normalizing them.
|
|
790
|
+
//
|
|
791
|
+
// Returns on success: { ok: true, canonical, resolved, canonicalDiffers }
|
|
792
|
+
// Returns on failure: { ok: false, code, reason, canonical? }
|
|
793
|
+
function validateRuntimeRelativePath(value, options) {
|
|
794
|
+
const opts = options || {};
|
|
795
|
+
const fail = (code, reason, extra) => Object.assign({ ok: false, code, reason }, extra);
|
|
796
|
+
|
|
797
|
+
if (typeof value !== 'string') {
|
|
798
|
+
const found = value === null ? 'null' : value === undefined ? 'undefined' : typeof value;
|
|
799
|
+
return fail('not-string', `must be a non-empty string (found ${found})`);
|
|
800
|
+
}
|
|
801
|
+
if (value.length === 0) {
|
|
802
|
+
return fail('empty', 'must be a non-empty string (found empty string)');
|
|
803
|
+
}
|
|
804
|
+
if (value.includes('\0')) {
|
|
805
|
+
return fail('null-byte', 'must not contain null bytes');
|
|
806
|
+
}
|
|
807
|
+
if (value.trim().length === 0) {
|
|
808
|
+
return fail('whitespace-only', 'must not be a whitespace-only string');
|
|
809
|
+
}
|
|
810
|
+
if (value !== value.trim()) {
|
|
811
|
+
return fail('surrounding-whitespace', 'must not have leading or trailing whitespace');
|
|
812
|
+
}
|
|
813
|
+
// Normalize separators BEFORE the absolute-path checks. Otherwise a rooted
|
|
814
|
+
// Windows path ('\Windows\x.md') or a UNC path ('\\server\share\x.md') would
|
|
815
|
+
// slip past a raw startsWith('/') test and be reinterpreted as relative once
|
|
816
|
+
// the backslashes were converted. Per the Tech Spec: normalize '\' → '/' first,
|
|
817
|
+
// then reject anything that begins with '/'.
|
|
818
|
+
const unified = value.replace(/\\/g, '/');
|
|
819
|
+
if (unified.startsWith('/')) {
|
|
820
|
+
// Covers Unix absolute ('/etc/passwd'), rooted Windows ('\Windows\x.md' → '/…'),
|
|
821
|
+
// and UNC ('\\server\share' → '//server/share', or already-'/'-form '//server').
|
|
822
|
+
return fail('absolute-unix', 'must be relative (got Unix absolute path)');
|
|
823
|
+
}
|
|
824
|
+
if (/^[A-Za-z]:\//.test(unified)) {
|
|
825
|
+
return fail('absolute-windows', 'must be relative (got Windows absolute path)');
|
|
826
|
+
}
|
|
827
|
+
if (/^[A-Za-z]:/.test(unified)) {
|
|
828
|
+
return fail('drive-relative', 'must be relative (got Windows drive-relative path)');
|
|
829
|
+
}
|
|
830
|
+
if (unified.split('/').includes('..')) {
|
|
831
|
+
return fail('traversal', 'must not contain path traversal (..)');
|
|
832
|
+
}
|
|
833
|
+
// Canonical form: forward slashes, no '.'/empty segments, no double separators,
|
|
834
|
+
// no trailing slash. '..' is already rejected above, so it cannot survive here.
|
|
835
|
+
const canonical = unified.split('/').filter(s => s !== '' && s !== '.').join('/');
|
|
836
|
+
if (canonical.length === 0) {
|
|
837
|
+
return fail('empty-after-canonical', 'must resolve to a non-empty canonical path');
|
|
838
|
+
}
|
|
839
|
+
let resolved = null;
|
|
840
|
+
if (opts.root !== undefined && opts.root !== null) {
|
|
841
|
+
const rootResolved = path.resolve(opts.root);
|
|
842
|
+
resolved = path.resolve(rootResolved, canonical);
|
|
843
|
+
if (resolved !== rootResolved && !resolved.startsWith(rootResolved + path.sep)) {
|
|
844
|
+
return fail('escapes-root', 'escapes the installation root', { canonical });
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
const canonicalDiffers = value !== canonical;
|
|
848
|
+
if (opts.requireCanonical && canonicalDiffers) {
|
|
849
|
+
return fail('non-canonical', `is not canonical; expected '${canonical}'`, { canonical });
|
|
850
|
+
}
|
|
851
|
+
return { ok: true, canonical, resolved, canonicalDiffers };
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Validate the shape of a manifest 'files' field for the doctor's schema check.
|
|
855
|
+
// Returns an array of human-readable error strings; an empty array means valid.
|
|
856
|
+
// A valid 'files' field is an array whose every element is a canonical,
|
|
857
|
+
// destination-relative path (forward slashes only, no '.'/'..'/empty segments,
|
|
858
|
+
// no trailing slash) confined to destRoot and free of null bytes. Non-canonical
|
|
859
|
+
// entries are reported — never silently normalized — so doctor requires the
|
|
860
|
+
// installer to regenerate an incoherent manifest.
|
|
861
|
+
// destRoot is the installation destination (parent of .claude) paths resolve against.
|
|
862
|
+
function validateManifestFilesField(files, destRoot) {
|
|
863
|
+
const errors = [];
|
|
864
|
+
if (!Array.isArray(files)) {
|
|
865
|
+
errors.push(`'files' must be an array (found ${files === null ? 'null' : typeof files})`);
|
|
866
|
+
return errors;
|
|
867
|
+
}
|
|
868
|
+
const rootResolved = path.resolve(destRoot);
|
|
869
|
+
files.forEach((entry, i) => {
|
|
870
|
+
const check = validateRuntimeRelativePath(entry, { root: rootResolved, requireCanonical: true });
|
|
871
|
+
if (check.ok) return;
|
|
872
|
+
// Format the diagnostic inline — this is the only caller, so no shared helper.
|
|
873
|
+
// The entry is echoed (when a string) so operators can spot the culprit.
|
|
874
|
+
const shown = typeof entry === 'string' ? ` ('${entry}')` : '';
|
|
875
|
+
let msg;
|
|
876
|
+
switch (check.code) {
|
|
877
|
+
case 'traversal':
|
|
878
|
+
msg = `'files[${i}]' must not contain '..' segments (path traversal)${shown}`;
|
|
879
|
+
break;
|
|
880
|
+
case 'non-canonical':
|
|
881
|
+
msg = `'files[${i}]' is not canonical; expected '${check.canonical}'${shown}`;
|
|
882
|
+
break;
|
|
883
|
+
case 'escapes-root':
|
|
884
|
+
msg = `'files[${i}]' escapes the installation root${shown}`;
|
|
885
|
+
break;
|
|
886
|
+
default:
|
|
887
|
+
msg = `'files[${i}]' ${check.reason}${shown}`;
|
|
888
|
+
}
|
|
889
|
+
errors.push(msg);
|
|
890
|
+
});
|
|
891
|
+
return errors;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Shared presence-detection helpers — used by resolver, doctor, and list-assets.
|
|
895
|
+
// condC: at least one catalog category directory inside claudeDir has files.
|
|
896
|
+
function hasToolkitPayloadFiles(claudeDir) {
|
|
897
|
+
const { getAssetCategories } = require('../lib/asset-catalog');
|
|
898
|
+
for (const cat of getAssetCategories()) {
|
|
899
|
+
const catDir = path.join(claudeDir, cat.runtimeDir.replace(/^\.claude\//, ''));
|
|
900
|
+
if (!fs.existsSync(catDir)) continue;
|
|
901
|
+
try { if (fs.statSync(catDir).isDirectory() && walkDir(catDir).length > 0) return true; }
|
|
902
|
+
catch (_) { /* ignore unreadable dirs */ }
|
|
903
|
+
}
|
|
904
|
+
return false;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// A toolkit installation is PRESENT at claudeDir when any of:
|
|
908
|
+
// condA: .ai-toolkit-manifest.json exists (content may be corrupt)
|
|
909
|
+
// condB: .ai-toolkit-version exists
|
|
910
|
+
// condC: at least one catalog category dir contains one or more files
|
|
911
|
+
// settings.json / settings.local.json alone do NOT satisfy any condition.
|
|
912
|
+
function isToolkitInstalled(claudeDir) {
|
|
913
|
+
if (fs.existsSync(path.join(claudeDir, '.ai-toolkit-manifest.json'))) return true;
|
|
914
|
+
if (fs.existsSync(path.join(claudeDir, '.ai-toolkit-version'))) return true;
|
|
915
|
+
return hasToolkitPayloadFiles(claudeDir);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// ── resolveClaudeRuntimeAsset ─────────────────────────────────────────────────
|
|
919
|
+
|
|
920
|
+
// INFRA-TASK-BE-02 (FTR-015):
|
|
921
|
+
// Resolve a runtime asset path using a six-phase algorithm:
|
|
922
|
+
// Phase 0: Input validation (no filesystem access)
|
|
923
|
+
// Phase A: Presence detection — local vs global installation
|
|
924
|
+
// Phase B: Mode decision — local-only | global-only | both (error) | none (error)
|
|
925
|
+
// Phase C: Metadata warnings — manifest, version stamp; emit to stderr; never abort
|
|
926
|
+
// Phase D: Catalog membership + completeness check
|
|
927
|
+
// Phase E: Confinement check + return absolute path
|
|
928
|
+
//
|
|
929
|
+
// Parameters:
|
|
930
|
+
// relativePath (string): path relative to .claude/, e.g. 'agents/agent-name.md'
|
|
931
|
+
// options (object): { projectDir, home }
|
|
932
|
+
// projectDir — target project directory (defaults to process.cwd())
|
|
933
|
+
// home — override for os.homedir() to enable test isolation
|
|
934
|
+
//
|
|
935
|
+
// Returns: absolute path string
|
|
936
|
+
// Throws: Error with diagnostics on validation failure, ambiguity, or incompleteness
|
|
937
|
+
function resolveClaudeRuntimeAsset(relativePath, options) {
|
|
938
|
+
const os = require('os');
|
|
939
|
+
const { getAssetCategories } = require('../lib/asset-catalog');
|
|
940
|
+
|
|
941
|
+
const opts = options || {};
|
|
942
|
+
const effectiveHome = opts.home !== undefined ? opts.home : os.homedir();
|
|
943
|
+
const effectiveProjectDir = opts.projectDir !== undefined ? opts.projectDir : process.cwd();
|
|
944
|
+
|
|
945
|
+
// ── Phase 0: Input validation (no filesystem access) ──────────────────────
|
|
946
|
+
// Delegate all path-safety + canonicalization to the shared primitive. The
|
|
947
|
+
// resolver is lenient (requireCanonical omitted): it normalizes backslashes,
|
|
948
|
+
// '.'/empty segments, and trailing slashes into the canonical form and
|
|
949
|
+
// proceeds, but still rejects null bytes, absolute/drive paths, and traversal.
|
|
950
|
+
const inputCheck = validateRuntimeRelativePath(relativePath);
|
|
951
|
+
if (!inputCheck.ok) {
|
|
952
|
+
throw new Error(`relativePath ${inputCheck.reason}`);
|
|
953
|
+
}
|
|
954
|
+
relativePath = inputCheck.canonical;
|
|
955
|
+
|
|
956
|
+
// ── Runtime root definitions ───────────────────────────────────────────────
|
|
957
|
+
const localRuntimeRoot = path.join(effectiveProjectDir, '.claude');
|
|
958
|
+
const globalRuntimeRoot = path.join(effectiveHome, '.claude');
|
|
959
|
+
|
|
960
|
+
// ── Phase A: Presence detection ────────────────────────────────────────────
|
|
961
|
+
const localPresent = isToolkitInstalled(localRuntimeRoot);
|
|
962
|
+
const globalPresent = isToolkitInstalled(globalRuntimeRoot);
|
|
963
|
+
|
|
964
|
+
// ── Phase B: Mode decision ─────────────────────────────────────────────────
|
|
965
|
+
if (!localPresent && !globalPresent) {
|
|
966
|
+
throw new Error(
|
|
967
|
+
"No toolkit installation found. Run 'npm run toolkit:dev-install-global' to install globally, or the installer in your project."
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
if (localPresent && globalPresent) {
|
|
971
|
+
throw new Error(
|
|
972
|
+
`Ambiguous: toolkit installations detected at both ${localRuntimeRoot} and ` +
|
|
973
|
+
`${globalRuntimeRoot}. The error is raised even if one installation has corrupt ` +
|
|
974
|
+
'metadata — presence is established regardless of metadata validity.'
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const effectiveRoot = localPresent ? localRuntimeRoot : globalRuntimeRoot;
|
|
979
|
+
const effectiveMode = localPresent ? 'local' : 'global';
|
|
980
|
+
|
|
981
|
+
// Confinement lives in the shared primitive: re-validate the (already canonical)
|
|
982
|
+
// relativePath against the now-known installation root and reuse its resolved
|
|
983
|
+
// absolute path everywhere below, instead of re-implementing path.resolve +
|
|
984
|
+
// startsWith here.
|
|
985
|
+
const rootedCheck = validateRuntimeRelativePath(relativePath, { root: effectiveRoot });
|
|
986
|
+
if (!rootedCheck.ok) {
|
|
987
|
+
throw new Error('Resolved path escapes installation root (confinement violation)');
|
|
988
|
+
}
|
|
989
|
+
const absoluteRequested = rootedCheck.resolved;
|
|
990
|
+
|
|
991
|
+
// ── Phase C: Metadata warnings (emit to stderr; never abort) ──────────────
|
|
992
|
+
const manifestPath = path.join(effectiveRoot, '.ai-toolkit-manifest.json');
|
|
993
|
+
let manifest = null;
|
|
994
|
+
let manifestSchemaValid = false;
|
|
995
|
+
|
|
996
|
+
if (!fs.existsSync(manifestPath)) {
|
|
997
|
+
process.stderr.write('Warning: No manifest found; installation may be manual or manifest was lost\n');
|
|
998
|
+
} else {
|
|
999
|
+
let parsed = null;
|
|
1000
|
+
try {
|
|
1001
|
+
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
1002
|
+
} catch (_) {
|
|
1003
|
+
process.stderr.write(`Warning: Manifest is corrupt (invalid JSON) at ${manifestPath}\n`);
|
|
1004
|
+
}
|
|
1005
|
+
if (parsed !== null) {
|
|
1006
|
+
const required = ['version', 'installedAt', 'installationMode', 'files'];
|
|
1007
|
+
const missingFields = required.filter(f => parsed[f] === undefined);
|
|
1008
|
+
if (missingFields.length > 0) {
|
|
1009
|
+
process.stderr.write(`Warning: Manifest schema invalid: missing fields ${missingFields.join(', ')}\n`);
|
|
1010
|
+
} else {
|
|
1011
|
+
manifestSchemaValid = true;
|
|
1012
|
+
manifest = parsed;
|
|
1013
|
+
if (parsed.installationMode !== effectiveMode) {
|
|
1014
|
+
process.stderr.write(
|
|
1015
|
+
`Warning: Manifest installationMode='${parsed.installationMode}' mismatches effective mode '${effectiveMode}'\n`
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const versionStampPath = path.join(effectiveRoot, '.ai-toolkit-version');
|
|
1023
|
+
if (!fs.existsSync(versionStampPath)) {
|
|
1024
|
+
process.stderr.write(`Warning: No version stamp at ${versionStampPath}\n`);
|
|
1025
|
+
} else {
|
|
1026
|
+
const stampVersion = fs.readFileSync(versionStampPath, 'utf8').trim();
|
|
1027
|
+
const resolverVersion = require('../package.json').version;
|
|
1028
|
+
if (stampVersion !== resolverVersion) {
|
|
1029
|
+
process.stderr.write(
|
|
1030
|
+
`Warning: Version mismatch: installed=${stampVersion}, resolver=${resolverVersion}; ` +
|
|
1031
|
+
'run toolkit:dev-install-global to update\n'
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// ── Phase D: Catalog membership + completeness check ──────────────────────
|
|
1037
|
+
// Use the shared distributable payload function so the resolver checks exactly
|
|
1038
|
+
// the same files that the installer writes (TOOLKIT_INTERNAL_ASSETS excluded).
|
|
1039
|
+
const expectedPayload = buildExpectedPayload(effectiveRoot);
|
|
1040
|
+
|
|
1041
|
+
// Step 6e: warn about stale manifest entries (in manifest but no longer in catalog)
|
|
1042
|
+
if (manifestSchemaValid && manifest && Array.isArray(manifest.files)) {
|
|
1043
|
+
const parentDir = path.join(effectiveRoot, '..');
|
|
1044
|
+
const stale = manifest.files.filter(f => !expectedPayload.has(path.resolve(path.join(parentDir, f))));
|
|
1045
|
+
if (stale.length > 0) {
|
|
1046
|
+
process.stderr.write(
|
|
1047
|
+
`Warning: Manifest has ${stale.length} stale entries not in current catalog: ${stale.join(', ')}\n`
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Step 8a: catalog membership — requested asset must be in expectedPayload
|
|
1053
|
+
if (!expectedPayload.has(absoluteRequested)) {
|
|
1054
|
+
throw new Error(
|
|
1055
|
+
`Requested asset '${relativePath}' is not a registered catalog asset (mode: ${effectiveMode}). ` +
|
|
1056
|
+
'Only toolkit-installed catalog assets may be resolved; arbitrary files under .claude/ are ' +
|
|
1057
|
+
'not resolvable via this function.'
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Step 9: warn about expected assets absent from manifest (metadata-staleness; disk is authoritative)
|
|
1062
|
+
if (manifestSchemaValid && manifest && Array.isArray(manifest.files)) {
|
|
1063
|
+
const parentDir = path.join(effectiveRoot, '..');
|
|
1064
|
+
const manifestAbsSet = new Set(
|
|
1065
|
+
manifest.files.map(f => path.resolve(path.join(parentDir, f)))
|
|
1066
|
+
);
|
|
1067
|
+
for (const f of expectedPayload) {
|
|
1068
|
+
if (!manifestAbsSet.has(f)) {
|
|
1069
|
+
process.stderr.write(`Warning: Expected asset missing from manifest: ${f}\n`);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Step 10: disk completeness — every expected catalog asset must exist on disk;
|
|
1075
|
+
// a stale manifest cannot suppress this error — disk truth is the only authority.
|
|
1076
|
+
const missingFiles = [...expectedPayload].filter(f => !fs.existsSync(f));
|
|
1077
|
+
if (missingFiles.length > 0) {
|
|
1078
|
+
throw new Error(
|
|
1079
|
+
`Installation incomplete (${effectiveMode}). Missing files:\n ${missingFiles.join('\n ')}\n` +
|
|
1080
|
+
'Run the installer.'
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// ── Phase E: Return path ───────────────────────────────────────────────────
|
|
1085
|
+
// Confinement was already enforced by the shared primitive above; absoluteRequested
|
|
1086
|
+
// is its confined, resolved absolute path (effectiveRoot already ends at .claude/).
|
|
1087
|
+
const absolutePath = absoluteRequested;
|
|
1088
|
+
|
|
1089
|
+
// Step 12: final existence check
|
|
1090
|
+
if (!fs.existsSync(absolutePath)) {
|
|
1091
|
+
throw new Error(`Asset not found at ${absolutePath} (mode: ${effectiveMode})`);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Step 13: return
|
|
1095
|
+
return absolutePath;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// ── runDoctorResolution ───────────────────────────────────────────────────────
|
|
1099
|
+
|
|
1100
|
+
// US-07-TASK-BE-01 (FTR-015):
|
|
1101
|
+
// Human-readable diagnostics of runtime installation mode, asset resolution,
|
|
1102
|
+
// and potential conflicts. Read-only — never modifies or deletes any file.
|
|
1103
|
+
//
|
|
1104
|
+
// Parameters (via options object):
|
|
1105
|
+
// projectDir (string): target project directory (defaults to process.cwd())
|
|
1106
|
+
// home (string): override for os.homedir() to enable test isolation
|
|
1107
|
+
//
|
|
1108
|
+
// Output: writes to stdout; always exits 0 (reports, does not fail).
|
|
1109
|
+
function runDoctorResolution(options) {
|
|
1110
|
+
const os = require('os');
|
|
1111
|
+
const { getAssetCategories } = require('../lib/asset-catalog');
|
|
1112
|
+
|
|
1113
|
+
const opts = options || {};
|
|
1114
|
+
const effectiveHome = opts.home !== undefined ? path.resolve(opts.home) : os.homedir();
|
|
1115
|
+
const effectiveProject = opts.projectDir !== undefined ? path.resolve(opts.projectDir) : process.cwd();
|
|
1116
|
+
|
|
1117
|
+
const categories = getAssetCategories();
|
|
1118
|
+
const localRuntimeRoot = path.join(effectiveProject, '.claude');
|
|
1119
|
+
const globalRuntimeRoot = path.join(effectiveHome, '.claude');
|
|
1120
|
+
|
|
1121
|
+
// ── Internal helpers ────────────────────────────────────────────────────────
|
|
1122
|
+
|
|
1123
|
+
function catRelDir(cat) {
|
|
1124
|
+
return cat.runtimeDir.replace(/^\.claude\//, '');
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function countCategoryFiles(runtimeRoot, cat) {
|
|
1128
|
+
const catDir = path.join(runtimeRoot, catRelDir(cat));
|
|
1129
|
+
if (!fs.existsSync(catDir)) return 0;
|
|
1130
|
+
try { return walkDir(catDir).length; } catch (_) { return 0; }
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function readVersionStamp(runtimeRoot) {
|
|
1134
|
+
const vFile = path.join(runtimeRoot, '.ai-toolkit-version');
|
|
1135
|
+
if (!fs.existsSync(vFile)) return null;
|
|
1136
|
+
try { return fs.readFileSync(vFile, 'utf8').trim(); } catch (_) { return null; }
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Returns { status: 'present'|'missing'|'corrupt', data: parsed|null, path: string }
|
|
1140
|
+
function readManifestInfo(runtimeRoot) {
|
|
1141
|
+
const manifestPath = path.join(runtimeRoot, '.ai-toolkit-manifest.json');
|
|
1142
|
+
if (!fs.existsSync(manifestPath)) return { status: 'missing', data: null, path: manifestPath };
|
|
1143
|
+
let raw;
|
|
1144
|
+
try { raw = fs.readFileSync(manifestPath, 'utf8'); } catch (_) {
|
|
1145
|
+
return { status: 'missing', data: null, path: manifestPath };
|
|
1146
|
+
}
|
|
1147
|
+
try {
|
|
1148
|
+
const parsed = JSON.parse(raw);
|
|
1149
|
+
return { status: 'present', data: parsed, path: manifestPath };
|
|
1150
|
+
} catch (_) {
|
|
1151
|
+
return { status: 'corrupt', data: null, path: manifestPath };
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// Return category files present under runtimeRoot that are NOT in manifest.files.
|
|
1156
|
+
// Only meaningful when manifestData exists with a files array; returns [] otherwise.
|
|
1157
|
+
function findResiduals(runtimeRoot, manifestData) {
|
|
1158
|
+
if (!manifestData || !Array.isArray(manifestData.files)) return [];
|
|
1159
|
+
// Defensive: a malformed manifest may contain non-string entries; ignore them
|
|
1160
|
+
// here so doctor still produces a read-only report (the schema check flags them).
|
|
1161
|
+
const tracked = new Set(
|
|
1162
|
+
manifestData.files.filter(f => typeof f === 'string').map(f => f.replace(/\\/g, '/'))
|
|
1163
|
+
);
|
|
1164
|
+
const residuals = [];
|
|
1165
|
+
const parentDir = path.dirname(runtimeRoot); // e.g. /proj (parent of .claude)
|
|
1166
|
+
for (const cat of categories) {
|
|
1167
|
+
const catDir = path.join(runtimeRoot, catRelDir(cat));
|
|
1168
|
+
if (!fs.existsSync(catDir)) continue;
|
|
1169
|
+
try {
|
|
1170
|
+
for (const f of walkDir(catDir)) {
|
|
1171
|
+
const rel = path.relative(parentDir, f).replace(/\\/g, '/');
|
|
1172
|
+
if (!tracked.has(rel)) residuals.push(rel);
|
|
1173
|
+
}
|
|
1174
|
+
} catch (_) { /* ignore */ }
|
|
1175
|
+
}
|
|
1176
|
+
return residuals;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// ── Detect installations ────────────────────────────────────────────────────
|
|
1180
|
+
const localPresent = isToolkitInstalled(localRuntimeRoot);
|
|
1181
|
+
const globalPresent = isToolkitInstalled(globalRuntimeRoot);
|
|
1182
|
+
|
|
1183
|
+
let mode, modeStatus;
|
|
1184
|
+
if (localPresent && globalPresent) { mode = 'both'; modeStatus = 'AMBIGUOUS'; }
|
|
1185
|
+
else if (localPresent) { mode = 'local-only'; modeStatus = 'VALID'; }
|
|
1186
|
+
else if (globalPresent) { mode = 'global-only'; modeStatus = 'VALID'; }
|
|
1187
|
+
else { mode = 'none'; modeStatus = 'NOT INSTALLED'; }
|
|
1188
|
+
|
|
1189
|
+
const presentRoots = [];
|
|
1190
|
+
if (localPresent) presentRoots.push({ label: 'Local', root: localRuntimeRoot });
|
|
1191
|
+
if (globalPresent) presentRoots.push({ label: 'Global', root: globalRuntimeRoot });
|
|
1192
|
+
|
|
1193
|
+
// ── Output helpers ──────────────────────────────────────────────────────────
|
|
1194
|
+
const L = (line = '') => console.log(line);
|
|
1195
|
+
const H = (title) => { L(bold(title)); L(divider('─', 68)); };
|
|
1196
|
+
const tick = clr('green', '✔');
|
|
1197
|
+
const cross = clr('red', '✖');
|
|
1198
|
+
const warnS = clr('yellow', '⚠');
|
|
1199
|
+
|
|
1200
|
+
// ── Header ──────────────────────────────────────────────────────────────────
|
|
1201
|
+
L('');
|
|
1202
|
+
L(clr('cyan', '╔══════════════════════════════════════════════════════════════════════╗'));
|
|
1203
|
+
L(clr('cyan', '║') + bold(clr('white', ' Claude Runtime Resolution Diagnostics ')) + clr('cyan', '║'));
|
|
1204
|
+
L(clr('cyan', '╚══════════════════════════════════════════════════════════════════════╝'));
|
|
1205
|
+
L('');
|
|
1206
|
+
|
|
1207
|
+
// ── Toolkit Source ──────────────────────────────────────────────────────────
|
|
1208
|
+
H('Toolkit Source');
|
|
1209
|
+
L(` Repository root: ${dim(packageRoot)}`);
|
|
1210
|
+
L(` Source directory: ${dim(path.join(packageRoot, 'src', 'claude'))}`);
|
|
1211
|
+
L('');
|
|
1212
|
+
|
|
1213
|
+
// ── Installation Detection ──────────────────────────────────────────────────
|
|
1214
|
+
H('Installation Detection');
|
|
1215
|
+
L(` Local runtime present: ${localPresent ? `${tick} ${dim(localRuntimeRoot)}` : `${cross} ${dim('not found')}`}`);
|
|
1216
|
+
L(` Global runtime present: ${globalPresent ? `${tick} ${dim(globalRuntimeRoot)}` : `${cross} ${dim('not found')}`}`);
|
|
1217
|
+
L('');
|
|
1218
|
+
|
|
1219
|
+
// ── Effective Runtime Mode ──────────────────────────────────────────────────
|
|
1220
|
+
H('Effective Runtime Mode');
|
|
1221
|
+
const modeColors = { 'local-only': 'green', 'global-only': 'green', 'both': 'yellow', 'none': 'red' };
|
|
1222
|
+
const statusColors = { 'VALID': 'green', 'AMBIGUOUS': 'yellow', 'NOT INSTALLED': 'red' };
|
|
1223
|
+
L(` Mode: ${clr(modeColors[mode], mode)}`);
|
|
1224
|
+
L(` Status: ${clr(statusColors[modeStatus] || 'gray', modeStatus)}`);
|
|
1225
|
+
L('');
|
|
1226
|
+
|
|
1227
|
+
// ── Runtime Inventory ───────────────────────────────────────────────────────
|
|
1228
|
+
for (const { label, root } of presentRoots) {
|
|
1229
|
+
H(`Runtime Inventory (${label})`);
|
|
1230
|
+
for (const cat of categories) {
|
|
1231
|
+
const count = countCategoryFiles(root, cat);
|
|
1232
|
+
const name = (cat.name.charAt(0).toUpperCase() + cat.name.slice(1)).padEnd(12);
|
|
1233
|
+
L(` ${name} ${clr(count > 0 ? 'cyan' : 'gray', String(count).padStart(3))} file${count !== 1 ? 's' : ' '}`);
|
|
1234
|
+
}
|
|
1235
|
+
L('');
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// ── Version Stamps ──────────────────────────────────────────────────────────
|
|
1239
|
+
H('Version Stamps');
|
|
1240
|
+
L(` Toolkit source version: ${clr('cyan', TOOLKIT_VERSION)} ${dim('(from package.json)')}`);
|
|
1241
|
+
if (localPresent) {
|
|
1242
|
+
const lv = readVersionStamp(localRuntimeRoot);
|
|
1243
|
+
const lvDisplay = lv || dim('not found');
|
|
1244
|
+
const lvColor = lv === TOOLKIT_VERSION ? 'green' : lv ? 'yellow' : 'gray';
|
|
1245
|
+
L(` Local installed version: ${lv ? clr(lvColor, lv) : lvDisplay} ${dim('(from .claude/.ai-toolkit-version)')}`);
|
|
1246
|
+
}
|
|
1247
|
+
if (globalPresent) {
|
|
1248
|
+
const gv = readVersionStamp(globalRuntimeRoot);
|
|
1249
|
+
const gvDisplay = gv || dim('not found');
|
|
1250
|
+
const gvColor = gv === TOOLKIT_VERSION ? 'green' : gv ? 'yellow' : 'gray';
|
|
1251
|
+
L(` Global installed version: ${gv ? clr(gvColor, gv) : gvDisplay} ${dim('(from ~/.claude/.ai-toolkit-version)')}`);
|
|
1252
|
+
}
|
|
1253
|
+
if (localPresent && globalPresent) {
|
|
1254
|
+
const lv = readVersionStamp(localRuntimeRoot);
|
|
1255
|
+
const gv = readVersionStamp(globalRuntimeRoot);
|
|
1256
|
+
const match = lv !== null && gv !== null && lv === gv;
|
|
1257
|
+
L(` Match: ${match ? tick : `${cross} ${clr('yellow', `local=${lv || '?'}, global=${gv || '?'}`)}`}`);
|
|
1258
|
+
}
|
|
1259
|
+
L('');
|
|
1260
|
+
|
|
1261
|
+
// ── Duplicate Files (only when both installations present) ──────────────────
|
|
1262
|
+
if (mode === 'both') {
|
|
1263
|
+
H('Duplicate Files (local vs global)');
|
|
1264
|
+
const dupes = [];
|
|
1265
|
+
for (const cat of categories) {
|
|
1266
|
+
const relDir = catRelDir(cat);
|
|
1267
|
+
const localCatDir = path.join(localRuntimeRoot, relDir);
|
|
1268
|
+
const globalCatDir = path.join(globalRuntimeRoot, relDir);
|
|
1269
|
+
if (!fs.existsSync(localCatDir) || !fs.existsSync(globalCatDir)) continue;
|
|
1270
|
+
try {
|
|
1271
|
+
const localFiles = walkDir(localCatDir).map(f => path.relative(localCatDir, f).replace(/\\/g, '/'));
|
|
1272
|
+
const globalSet = new Set(walkDir(globalCatDir).map(f => path.relative(globalCatDir, f).replace(/\\/g, '/')));
|
|
1273
|
+
for (const f of localFiles) {
|
|
1274
|
+
if (!globalSet.has(f)) continue;
|
|
1275
|
+
const lp = path.join(localCatDir, f);
|
|
1276
|
+
const gp = path.join(globalCatDir, f);
|
|
1277
|
+
const same = fileHash(lp) === fileHash(gp);
|
|
1278
|
+
dupes.push({ relPath: `${relDir}/${f}`, same });
|
|
1279
|
+
}
|
|
1280
|
+
} catch (_) { /* ignore */ }
|
|
1281
|
+
}
|
|
1282
|
+
if (dupes.length === 0) {
|
|
1283
|
+
L(` ${dim('(no files appear in both installations)')}`);
|
|
1284
|
+
} else {
|
|
1285
|
+
for (const d of dupes) {
|
|
1286
|
+
L(` ${dim(d.relPath)}: ${d.same ? `${tick} content matches` : `${cross} content differs`}`);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
L('');
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// ── Manifest Consistency ────────────────────────────────────────────────────
|
|
1293
|
+
H('Manifest Consistency');
|
|
1294
|
+
if (presentRoots.length === 0) {
|
|
1295
|
+
L(` ${dim('(no installation to check)')}`);
|
|
1296
|
+
} else {
|
|
1297
|
+
for (const { label, root } of presentRoots) {
|
|
1298
|
+
const mInfo = readManifestInfo(root);
|
|
1299
|
+
const mSymbol = { present: tick, missing: warnS, corrupt: cross }[mInfo.status];
|
|
1300
|
+
const mStatusColor = { present: 'green', missing: 'yellow', corrupt: 'red' }[mInfo.status];
|
|
1301
|
+
L(` ${bold(label)} manifest:`);
|
|
1302
|
+
L(` File: ${dim(mInfo.path)}`);
|
|
1303
|
+
L(` Status: ${mSymbol} ${clr(mStatusColor, mInfo.status)}`);
|
|
1304
|
+
if (mInfo.status === 'present' && mInfo.data) {
|
|
1305
|
+
const fCount = Array.isArray(mInfo.data.files) ? mInfo.data.files.length : '?';
|
|
1306
|
+
L(` Files: ${fCount}`);
|
|
1307
|
+
if (mInfo.data.version) L(` Version: ${mInfo.data.version}`);
|
|
1308
|
+
if (mInfo.data.installationMode) L(` Installation mode: ${mInfo.data.installationMode}`);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
L('');
|
|
1313
|
+
|
|
1314
|
+
// ── Residual .claude/ Assets ────────────────────────────────────────────────
|
|
1315
|
+
H('Residual .claude/ Assets');
|
|
1316
|
+
if (presentRoots.length === 0) {
|
|
1317
|
+
L(` ${dim('(no installation to check)')}`);
|
|
1318
|
+
} else {
|
|
1319
|
+
for (const { label, root } of presentRoots) {
|
|
1320
|
+
const mInfo = readManifestInfo(root);
|
|
1321
|
+
if (mInfo.status !== 'present') {
|
|
1322
|
+
L(` ${label}: ${warnS} ${dim('cannot check residuals — manifest missing or corrupt')}`);
|
|
1323
|
+
} else {
|
|
1324
|
+
const residuals = findResiduals(root, mInfo.data);
|
|
1325
|
+
if (residuals.length === 0) {
|
|
1326
|
+
L(` ${label}: ${tick} ${clr('green', 'CLEAN')} ${dim('(all category files tracked in manifest)')}`);
|
|
1327
|
+
} else {
|
|
1328
|
+
L(` ${label}: ${warnS} ${clr('yellow', 'WARNING')} — ${residuals.length} untracked file(s):`);
|
|
1329
|
+
for (const r of residuals.slice(0, 10)) L(` ${dim(r)}`);
|
|
1330
|
+
if (residuals.length > 10) L(` ${dim(`... and ${residuals.length - 10} more`)}`);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
L('');
|
|
1336
|
+
|
|
1337
|
+
// ── Disk Completeness ─────────────────────────────────────────────────────────
|
|
1338
|
+
H('Disk Completeness');
|
|
1339
|
+
let diskIncomplete = false;
|
|
1340
|
+
let missingRuntimeFiles = [];
|
|
1341
|
+
|
|
1342
|
+
if (mode === 'local-only' || mode === 'global-only') {
|
|
1343
|
+
const singleRoot = mode === 'local-only' ? localRuntimeRoot : globalRuntimeRoot;
|
|
1344
|
+
const expected = buildExpectedPayload(singleRoot);
|
|
1345
|
+
missingRuntimeFiles = [...expected].filter(f => !fs.existsSync(f));
|
|
1346
|
+
diskIncomplete = missingRuntimeFiles.length > 0;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
if (mode !== 'local-only' && mode !== 'global-only') {
|
|
1350
|
+
L(` ${dim('(check runs only for single-installation mode)')}`);
|
|
1351
|
+
} else if (!diskIncomplete) {
|
|
1352
|
+
L(` ${tick} ${clr('green', 'COMPLETE')} ${dim('all catalog files present on disk')}`);
|
|
1353
|
+
} else {
|
|
1354
|
+
L(` ${cross} ${clr('red', 'INCOMPLETE')} — ${missingRuntimeFiles.length} required file(s) missing:`);
|
|
1355
|
+
for (const f of missingRuntimeFiles.slice(0, 10)) {
|
|
1356
|
+
L(` ${clr('red', '✖')} ${dim(f)}`);
|
|
1357
|
+
}
|
|
1358
|
+
if (missingRuntimeFiles.length > 10) L(` ${dim(`... and ${missingRuntimeFiles.length - 10} more`)}`);
|
|
1359
|
+
}
|
|
1360
|
+
L('');
|
|
1361
|
+
|
|
1362
|
+
// ── Action Items ────────────────────────────────────────────────────────────
|
|
1363
|
+
H('Action Items');
|
|
1364
|
+
const actions = [];
|
|
1365
|
+
let manifestSchemaProblematic = false;
|
|
1366
|
+
let manifestModeInconsistent = false;
|
|
1367
|
+
let manifestFilesIncomplete = false;
|
|
1368
|
+
|
|
1369
|
+
if (mode === 'both') {
|
|
1370
|
+
actions.push(`${warnS} Choose one: delete local or global installation to resolve ambiguity`);
|
|
1371
|
+
}
|
|
1372
|
+
if (mode === 'none') {
|
|
1373
|
+
actions.push(`${cross} No installation found. Run 'npm run toolkit:dev-install-global' or the local installer`);
|
|
1374
|
+
}
|
|
1375
|
+
if (mode === 'local-only' || mode === 'global-only') {
|
|
1376
|
+
const effectiveRoot = mode === 'local-only' ? localRuntimeRoot : globalRuntimeRoot;
|
|
1377
|
+
const detectedMode = mode === 'local-only' ? 'local' : 'global';
|
|
1378
|
+
const iv = readVersionStamp(effectiveRoot);
|
|
1379
|
+
if (iv && iv !== TOOLKIT_VERSION) {
|
|
1380
|
+
actions.push(`${warnS} Version mismatch: run 'npm run toolkit:dev-install-global' to update`);
|
|
1381
|
+
}
|
|
1382
|
+
const mInfo = readManifestInfo(effectiveRoot);
|
|
1383
|
+
if (mInfo.status === 'corrupt') {
|
|
1384
|
+
manifestSchemaProblematic = true;
|
|
1385
|
+
actions.push(`${cross} Manifest schema invalid: file is corrupt (invalid JSON)`);
|
|
1386
|
+
} else if (mInfo.status === 'missing') {
|
|
1387
|
+
manifestSchemaProblematic = true;
|
|
1388
|
+
actions.push(`${warnS} Manifest is ${mInfo.status}. Run the installer to regenerate.`);
|
|
1389
|
+
} else if (mInfo.status === 'present' && mInfo.data) {
|
|
1390
|
+
// Check required manifest fields (including installationMode).
|
|
1391
|
+
const required = ['version', 'installedAt', 'installationMode', 'files'];
|
|
1392
|
+
const missingFields = required.filter(f => mInfo.data[f] === undefined);
|
|
1393
|
+
if (missingFields.length > 0) {
|
|
1394
|
+
manifestSchemaProblematic = true;
|
|
1395
|
+
for (const f of missingFields) {
|
|
1396
|
+
actions.push(`${cross} Manifest schema invalid: missing required field '${f}'`);
|
|
1397
|
+
}
|
|
1398
|
+
actions.push(`${warnS} Run the installer to regenerate the manifest with all required fields.`);
|
|
1399
|
+
} else {
|
|
1400
|
+
const parentDir = path.join(effectiveRoot, '..');
|
|
1401
|
+
// installationMode must match the detected installation mode.
|
|
1402
|
+
if (mInfo.data.installationMode !== detectedMode) {
|
|
1403
|
+
manifestModeInconsistent = true;
|
|
1404
|
+
actions.push(
|
|
1405
|
+
`${cross} Manifest installationMode='${mInfo.data.installationMode}' does not match ` +
|
|
1406
|
+
`detected mode '${detectedMode}' — run the installer to repair`
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1409
|
+
// manifest.files must be a well-formed array of relative, in-bounds paths.
|
|
1410
|
+
const filesErrors = validateManifestFilesField(mInfo.data.files, parentDir);
|
|
1411
|
+
if (filesErrors.length > 0) {
|
|
1412
|
+
manifestSchemaProblematic = true;
|
|
1413
|
+
for (const e of filesErrors.slice(0, 5)) {
|
|
1414
|
+
actions.push(`${cross} Manifest schema invalid: ${e}`);
|
|
1415
|
+
}
|
|
1416
|
+
if (filesErrors.length > 5) {
|
|
1417
|
+
actions.push(`${cross} ... and ${filesErrors.length - 5} more manifest 'files' error(s)`);
|
|
1418
|
+
}
|
|
1419
|
+
actions.push(`${warnS} Run the installer to regenerate the manifest with a valid 'files' list.`);
|
|
1420
|
+
} else {
|
|
1421
|
+
// files is a valid array — it must cover all expected catalog assets.
|
|
1422
|
+
const expected = buildExpectedPayload(effectiveRoot);
|
|
1423
|
+
const manifestAbsSet = new Set(
|
|
1424
|
+
mInfo.data.files.map(f => path.resolve(path.join(parentDir, f)))
|
|
1425
|
+
);
|
|
1426
|
+
const missingFromManifest = [...expected].filter(f => !manifestAbsSet.has(f));
|
|
1427
|
+
if (missingFromManifest.length > 0) {
|
|
1428
|
+
manifestFilesIncomplete = true;
|
|
1429
|
+
actions.push(
|
|
1430
|
+
`${cross} manifest.files is missing ${missingFromManifest.length} expected catalog asset(s) — run the installer`
|
|
1431
|
+
);
|
|
1432
|
+
for (const f of missingFromManifest.slice(0, 3)) {
|
|
1433
|
+
actions.push(` ${dim(path.basename(f))}`);
|
|
1434
|
+
}
|
|
1435
|
+
if (missingFromManifest.length > 3) {
|
|
1436
|
+
actions.push(` ${dim(`... and ${missingFromManifest.length - 3} more`)}`);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
if (diskIncomplete) {
|
|
1444
|
+
const display = missingRuntimeFiles.slice(0, 5);
|
|
1445
|
+
for (const f of display) {
|
|
1446
|
+
actions.push(`${cross} Missing required file: ${dim(path.basename(f))} — run the installer`);
|
|
1447
|
+
}
|
|
1448
|
+
if (missingRuntimeFiles.length > 5) {
|
|
1449
|
+
actions.push(`${cross} ... and ${missingRuntimeFiles.length - 5} more missing file(s) — run the installer`);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
if (actions.length === 0) {
|
|
1454
|
+
L(` ${tick} ${clr('green', 'runtime ready for pipelines')}`);
|
|
1455
|
+
} else {
|
|
1456
|
+
for (const a of actions) L(` • ${a}`);
|
|
1457
|
+
}
|
|
1458
|
+
L('');
|
|
1459
|
+
|
|
1460
|
+
// ── Summary ─────────────────────────────────────────────────────────────────
|
|
1461
|
+
H('Summary');
|
|
1462
|
+
// READY requires: single installation, valid manifest schema, consistent installationMode,
|
|
1463
|
+
// manifest.files covering all expected assets, and complete disk payload.
|
|
1464
|
+
const overallOk = (mode === 'local-only' || mode === 'global-only')
|
|
1465
|
+
&& !manifestSchemaProblematic
|
|
1466
|
+
&& !manifestModeInconsistent
|
|
1467
|
+
&& !manifestFilesIncomplete
|
|
1468
|
+
&& !diskIncomplete;
|
|
1469
|
+
const overallStatus = overallOk ? 'READY' : 'PROBLEMATIC';
|
|
1470
|
+
L(` Status: ${clr(overallOk ? 'green' : 'red', overallStatus)}`);
|
|
1471
|
+
let recommendation;
|
|
1472
|
+
if (mode === 'none') {
|
|
1473
|
+
recommendation = "Run 'npm run toolkit:dev-install-global' to install globally, or use the local installer.";
|
|
1474
|
+
} else if (mode === 'both') {
|
|
1475
|
+
recommendation = 'Remove one installation to resolve ambiguity before running pipelines.';
|
|
1476
|
+
} else if (manifestSchemaProblematic || manifestModeInconsistent || manifestFilesIncomplete) {
|
|
1477
|
+
recommendation = 'Run the installer to repair the manifest before running pipelines.';
|
|
1478
|
+
} else if (diskIncomplete) {
|
|
1479
|
+
recommendation = 'Run the installer to complete the installation before running pipelines.';
|
|
1480
|
+
} else {
|
|
1481
|
+
recommendation = `Installation is operational in ${mode} mode.`;
|
|
1482
|
+
}
|
|
1483
|
+
L(` Recommendation: ${dim(recommendation)}`);
|
|
1484
|
+
L('');
|
|
1485
|
+
L(clr('gray', '═'.repeat(72)));
|
|
1486
|
+
L('');
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// ── validatePurityGuard ───────────────────────────────────────────────────────
|
|
1490
|
+
|
|
1491
|
+
// US-05-TASK-BE-03 (FTR-015):
|
|
1492
|
+
// Rejects any *.test.js files or blocked directory segments under sourceDir.
|
|
1493
|
+
// Called by the installer before copying and by the prepack lifecycle hook.
|
|
1494
|
+
//
|
|
1495
|
+
// Blocked file patterns: *.test.js
|
|
1496
|
+
// Blocked directory names: tests, fixtures, mocks, helpers
|
|
1497
|
+
//
|
|
1498
|
+
// Returns an array of violation strings (empty = clean).
|
|
1499
|
+
// Never modifies the filesystem.
|
|
1500
|
+
const PURITY_BLOCKED_DIRS = new Set(['tests', 'fixtures', 'mocks', 'helpers']);
|
|
1501
|
+
const PURITY_BLOCKED_EXTS = ['.test.js'];
|
|
1502
|
+
|
|
1503
|
+
function validatePurityGuard(sourceDir) {
|
|
1504
|
+
const violations = [];
|
|
1505
|
+
|
|
1506
|
+
function walk(dir) {
|
|
1507
|
+
if (!fs.existsSync(dir)) return;
|
|
1508
|
+
let entries;
|
|
1509
|
+
try { entries = fs.readdirSync(dir); } catch (_) { return; }
|
|
1510
|
+
for (const entry of entries) {
|
|
1511
|
+
const full = path.join(dir, entry);
|
|
1512
|
+
let stat;
|
|
1513
|
+
try { stat = fs.statSync(full); } catch (_) { continue; }
|
|
1514
|
+
if (stat.isDirectory()) {
|
|
1515
|
+
if (PURITY_BLOCKED_DIRS.has(entry)) {
|
|
1516
|
+
violations.push(`Blocked directory: ${full}`);
|
|
1517
|
+
} else {
|
|
1518
|
+
walk(full);
|
|
1519
|
+
}
|
|
1520
|
+
} else {
|
|
1521
|
+
if (PURITY_BLOCKED_EXTS.some(ext => entry.endsWith(ext))) {
|
|
1522
|
+
violations.push(`Blocked file: ${full}`);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
walk(sourceDir);
|
|
1529
|
+
return violations;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// validate-purity CLI command entry point.
|
|
1533
|
+
// Scans sourceDir (defaults to src/claude/) for test or fixture contamination.
|
|
1534
|
+
// Exit 0 = clean. Exit 1 = violations found. Violations written to stderr.
|
|
1535
|
+
function runValidatePurity(sourceDir) {
|
|
1536
|
+
const effectiveDir = sourceDir
|
|
1537
|
+
? path.resolve(sourceDir)
|
|
1538
|
+
: path.join(packageRoot, 'src', 'claude');
|
|
1539
|
+
|
|
1540
|
+
const violations = validatePurityGuard(effectiveDir);
|
|
1541
|
+
if (violations.length === 0) {
|
|
1542
|
+
process.stdout.write(`Purity guard: PASS — no test files or blocked dirs under ${effectiveDir}\n`);
|
|
1543
|
+
process.exit(0);
|
|
1544
|
+
} else {
|
|
1545
|
+
process.stderr.write(`Purity guard: FAIL — ${violations.length} violation(s) under ${effectiveDir}:\n`);
|
|
1546
|
+
for (const v of violations) process.stderr.write(` ${v}\n`);
|
|
1547
|
+
process.exit(1);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// ── distributable asset mapping ───────────────────────────────────────────────
|
|
1552
|
+
|
|
1553
|
+
// Assets that are toolkit-internal and must NEVER be distributed to consumer
|
|
1554
|
+
// projects. Keyed by category name; value is a Set of item names to skip.
|
|
1555
|
+
// See AGENTS.md hard constraints.
|
|
1556
|
+
const TOOLKIT_INTERNAL_ASSETS = {
|
|
1557
|
+
agents: new Set(['install-toolkit.md']),
|
|
1558
|
+
skills: new Set(['install-toolkit']),
|
|
1559
|
+
};
|
|
1560
|
+
|
|
1561
|
+
// ── verifyInstall ─────────────────────────────────────────────────────────────
|
|
1562
|
+
|
|
1563
|
+
// Checks that the version stamp in <home>/.claude/.ai-toolkit-version matches
|
|
1564
|
+
// package.json. Called as the final step of toolkit:dev-install-global.
|
|
1565
|
+
// Exit 0 = versions match. Exit 1 = stamp missing, unreadable, or wrong version.
|
|
1566
|
+
function runVerifyInstall(options) {
|
|
1567
|
+
const os = require('os');
|
|
1568
|
+
const opts = options || {};
|
|
1569
|
+
const effectiveHome = opts.home !== undefined ? path.resolve(opts.home) : os.homedir();
|
|
1570
|
+
const versionFile = path.join(effectiveHome, '.claude', VERSION_FILE);
|
|
1571
|
+
|
|
1572
|
+
if (!fs.existsSync(versionFile)) {
|
|
1573
|
+
process.stderr.write(`Error: verify-install FAILED — no version stamp at ${versionFile}\n`);
|
|
1574
|
+
process.exit(1);
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
let stamp;
|
|
1578
|
+
try {
|
|
1579
|
+
stamp = fs.readFileSync(versionFile, 'utf8').trim();
|
|
1580
|
+
} catch (err) {
|
|
1581
|
+
process.stderr.write(`Error: verify-install FAILED — cannot read ${versionFile}: ${err.message}\n`);
|
|
1582
|
+
process.exit(1);
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
if (!stamp) {
|
|
1586
|
+
process.stderr.write(`Error: verify-install FAILED — version stamp is empty at ${versionFile}\n`);
|
|
1587
|
+
process.exit(1);
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
if (stamp !== TOOLKIT_VERSION) {
|
|
1591
|
+
process.stderr.write(
|
|
1592
|
+
`Error: verify-install FAILED — version mismatch\n` +
|
|
1593
|
+
` installed: ${stamp}\n` +
|
|
1594
|
+
` expected: ${TOOLKIT_VERSION}\n` +
|
|
1595
|
+
`Run 'npm run toolkit:dev-install-global' to update.\n`
|
|
1596
|
+
);
|
|
1597
|
+
process.exit(1);
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
process.stdout.write(`verify-install: PASS — version ${stamp} matches package.json\n`);
|
|
1601
|
+
process.exit(0);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
663
1604
|
// ── entry points ──────────────────────────────────────────────────────────────
|
|
664
1605
|
|
|
665
|
-
async function installLocal(targetDir, force) {
|
|
1606
|
+
async function installLocal(targetDir, force, dryRun = false) {
|
|
666
1607
|
banner();
|
|
667
1608
|
targetDir = path.resolve(process.cwd(), targetDir || '.');
|
|
668
1609
|
console.log(` ${clr('cyan', '▸')} Target: ${bold(targetDir)}\n`);
|
|
669
|
-
await checkVersion(targetDir, force);
|
|
1610
|
+
if (!dryRun) await checkVersion(targetDir, force);
|
|
1611
|
+
// Purity guard (UC-05 / BR-16 / AC-29): abort if source contains test files or blocked dirs.
|
|
1612
|
+
const srcClaudeDir = path.join(packageRoot, 'src', 'claude');
|
|
1613
|
+
const purityViolations = validatePurityGuard(srcClaudeDir);
|
|
1614
|
+
if (purityViolations.length > 0) {
|
|
1615
|
+
process.stderr.write(`Purity guard: FAIL — ${purityViolations.length} violation(s):\n`);
|
|
1616
|
+
for (const v of purityViolations) process.stderr.write(` ${v}\n`);
|
|
1617
|
+
process.exit(1);
|
|
1618
|
+
}
|
|
1619
|
+
// Build copy plan from the single canonical source (buildPayloadFileMappings) so
|
|
1620
|
+
// installer, resolver, doctor, and list-assets all use the same distributable set.
|
|
670
1621
|
const mappings = [
|
|
671
|
-
|
|
1622
|
+
...buildPayloadFileMappings(path.join(targetDir, '.claude')),
|
|
672
1623
|
{ src: path.join(packageRoot, 'docs'), dest: path.join(targetDir, 'docs') },
|
|
673
1624
|
{ src: path.join(packageRoot, 'CLAUDE.md'), dest: path.join(targetDir, 'CLAUDE.md') },
|
|
674
1625
|
];
|
|
675
|
-
await runInstall(`local project`, mappings, force, targetDir);
|
|
1626
|
+
await runInstall(`local project`, mappings, force, targetDir, dryRun, 'local');
|
|
1627
|
+
if (dryRun) return;
|
|
676
1628
|
writeInstalledVersion(targetDir);
|
|
677
1629
|
console.log(` ${clr('green', '✔')} ${bold('Install complete.')}\n`);
|
|
678
1630
|
checkSpawnDepth(targetDir);
|
|
@@ -683,24 +1635,32 @@ async function installLocal(targetDir, force) {
|
|
|
683
1635
|
console.log();
|
|
684
1636
|
}
|
|
685
1637
|
|
|
686
|
-
async function installGlobal(force) {
|
|
1638
|
+
async function installGlobal(force, dryRun = false, homeOverride = undefined) {
|
|
687
1639
|
banner();
|
|
688
1640
|
try {
|
|
689
|
-
const homedir = require('os').homedir();
|
|
1641
|
+
const homedir = homeOverride !== undefined ? path.resolve(homeOverride) : require('os').homedir();
|
|
690
1642
|
const target = path.join(homedir, '.claude');
|
|
691
1643
|
console.log(` ${clr('cyan', '▸')} Target: ${bold(target)} ${clr('gray', '(global Claude folder)')}\n`);
|
|
692
|
-
|
|
1644
|
+
// destRoot is homedir so helpers (manifest, version stamp, trash) resolve to
|
|
1645
|
+
// ~/.claude/... rather than ~/.claude/.claude/... (FIX: global install root path)
|
|
1646
|
+
if (!dryRun) await checkVersion(homedir, force);
|
|
1647
|
+
// Purity guard (UC-05 / BR-16 / AC-29): abort if source contains test files or blocked dirs.
|
|
1648
|
+
const srcClaudeDir = path.join(packageRoot, 'src', 'claude');
|
|
1649
|
+
const purityViolations = validatePurityGuard(srcClaudeDir);
|
|
1650
|
+
if (purityViolations.length > 0) {
|
|
1651
|
+
process.stderr.write(`Purity guard: FAIL — ${purityViolations.length} violation(s):\n`);
|
|
1652
|
+
for (const v of purityViolations) process.stderr.write(` ${v}\n`);
|
|
1653
|
+
process.exit(1);
|
|
1654
|
+
}
|
|
1655
|
+
// Build copy plan from the single canonical source (buildPayloadFileMappings).
|
|
693
1656
|
const mappings = [
|
|
694
|
-
|
|
695
|
-
{ src: path.join(packageRoot, '
|
|
696
|
-
{ src: path.join(packageRoot, '.
|
|
697
|
-
{ src: path.join(packageRoot, '.claude', 'workflows'), dest: path.join(target, 'workflows') },
|
|
698
|
-
{ src: path.join(packageRoot, '.claude', 'scripts'), dest: path.join(target, 'scripts') },
|
|
699
|
-
{ src: path.join(packageRoot, 'docs'), dest: path.join(target, 'docs') },
|
|
700
|
-
{ src: path.join(packageRoot, 'CLAUDE.global.md'), dest: path.join(target, 'CLAUDE.md') },
|
|
1657
|
+
...buildPayloadFileMappings(target),
|
|
1658
|
+
{ src: path.join(packageRoot, 'docs'), dest: path.join(target, 'docs') },
|
|
1659
|
+
{ src: path.join(packageRoot, 'CLAUDE.global.md'), dest: path.join(target, 'CLAUDE.md') },
|
|
701
1660
|
];
|
|
702
|
-
await runInstall('global Claude folder', mappings, force,
|
|
703
|
-
|
|
1661
|
+
await runInstall('global Claude folder', mappings, force, homedir, dryRun, 'global');
|
|
1662
|
+
if (dryRun) return;
|
|
1663
|
+
writeInstalledVersion(homedir);
|
|
704
1664
|
console.log(` ${clr('green', '✔')} ${bold('Global install complete.')}\n`);
|
|
705
1665
|
checkSpawnDepth(homedir);
|
|
706
1666
|
console.log(divider());
|
|
@@ -721,22 +1681,38 @@ function help() {
|
|
|
721
1681
|
console.log(` ${clr('cyan', 'ai-toolkit')} ${clr('yellow', '--local <dir>')} Install into target directory`);
|
|
722
1682
|
console.log(` ${clr('cyan', 'ai-toolkit')} ${clr('yellow', '--global')} Install into ~/.claude (global)`);
|
|
723
1683
|
console.log(` ${clr('gray', ' --force')} Overwrite all files without prompting`);
|
|
1684
|
+
console.log(` ${clr('gray', ' --dry-run')} Preview what would change — no files written`);
|
|
724
1685
|
console.log();
|
|
725
1686
|
}
|
|
726
1687
|
|
|
727
1688
|
async function main() {
|
|
728
|
-
const argv
|
|
729
|
-
const force
|
|
1689
|
+
const argv = process.argv.slice(2);
|
|
1690
|
+
const force = argv.includes('--force');
|
|
1691
|
+
const dryRun = argv.includes('--dry-run');
|
|
730
1692
|
|
|
731
|
-
if (argv.length === 0 ||
|
|
732
|
-
await installLocal('.', force);
|
|
1693
|
+
if (argv.length === 0 || argv.every(a => a === '--force' || a === '--dry-run')) {
|
|
1694
|
+
await installLocal('.', force, dryRun);
|
|
733
1695
|
return;
|
|
734
1696
|
}
|
|
735
1697
|
|
|
736
1698
|
if (argv[0] === '--local') {
|
|
737
|
-
await installLocal(argv[1] || '.', force);
|
|
1699
|
+
await installLocal(argv[1] || '.', force, dryRun);
|
|
738
1700
|
} else if (argv[0] === '--global') {
|
|
739
|
-
|
|
1701
|
+
const homeIdx = argv.indexOf('--home');
|
|
1702
|
+
const homeOverride = homeIdx !== -1 ? argv[homeIdx + 1] : undefined;
|
|
1703
|
+
await installGlobal(force, dryRun, homeOverride);
|
|
1704
|
+
} else if (argv[0] === 'install') {
|
|
1705
|
+
// Subcommand aliases: `install --project <dir>` ≡ `--local <dir>`
|
|
1706
|
+
// `install --global` ≡ `--global`
|
|
1707
|
+
const sub = argv[1];
|
|
1708
|
+
if (sub === '--project') {
|
|
1709
|
+
await installLocal(argv[2] || '.', force, dryRun);
|
|
1710
|
+
} else if (sub === '--global') {
|
|
1711
|
+
await installGlobal(force, dryRun);
|
|
1712
|
+
} else {
|
|
1713
|
+
// No sub-flag: install into current directory (same as bare invocation)
|
|
1714
|
+
await installLocal(sub && !sub.startsWith('-') ? sub : '.', force, dryRun);
|
|
1715
|
+
}
|
|
740
1716
|
} else if (argv[0] === 'help' || argv[0] === '--help') {
|
|
741
1717
|
help();
|
|
742
1718
|
} else if (argv[0] === 'merge-allowlist') {
|
|
@@ -775,14 +1751,338 @@ async function main() {
|
|
|
775
1751
|
console.error(`Error: ${err.message}`);
|
|
776
1752
|
process.exit(1);
|
|
777
1753
|
}
|
|
1754
|
+
} else if (argv[0] === 'verify-install') {
|
|
1755
|
+
const remaining = argv.slice(1);
|
|
1756
|
+
const homeIdx = remaining.indexOf('--home');
|
|
1757
|
+
const home = homeIdx !== -1 ? remaining[homeIdx + 1] : undefined;
|
|
1758
|
+
runVerifyInstall({ home });
|
|
1759
|
+
} else if (argv[0] === 'doctor' && argv[1] === 'resolution') {
|
|
1760
|
+
const remaining = argv.slice(2);
|
|
1761
|
+
const projectIdx = remaining.indexOf('--project');
|
|
1762
|
+
const homeIdx = remaining.indexOf('--home');
|
|
1763
|
+
const projectDir = projectIdx !== -1 ? remaining[projectIdx + 1] : process.cwd();
|
|
1764
|
+
const home = homeIdx !== -1 ? remaining[homeIdx + 1] : undefined;
|
|
1765
|
+
runDoctorResolution({ projectDir, home });
|
|
1766
|
+
process.exit(0);
|
|
1767
|
+
} else if (argv[0] === 'validate-purity') {
|
|
1768
|
+
runValidatePurity(argv[1]);
|
|
1769
|
+
} else if (argv[0] === 'resolve-asset') {
|
|
1770
|
+
// resolve-asset <relativePath> [--project <dir>] [--home <dir>]
|
|
1771
|
+
// Three-tier model: Tier 1 = exit 0 + stdout=path + stderr="";
|
|
1772
|
+
// Tier 2 = exit 0 + stdout=path + stderr=warnings (emitted by resolver);
|
|
1773
|
+
// Tier 3 = exit 1 + stdout="" + stderr=error.
|
|
1774
|
+
const remaining = argv.slice(1);
|
|
1775
|
+
let relativePath = null;
|
|
1776
|
+
let projectDir = process.cwd();
|
|
1777
|
+
let home;
|
|
1778
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
1779
|
+
if (remaining[i] === '--project' && remaining[i + 1]) { projectDir = remaining[++i]; }
|
|
1780
|
+
else if (remaining[i] === '--home' && remaining[i + 1]) { home = remaining[++i]; }
|
|
1781
|
+
else if (!remaining[i].startsWith('-')) { relativePath = remaining[i]; }
|
|
1782
|
+
}
|
|
1783
|
+
if (!relativePath) {
|
|
1784
|
+
process.stderr.write('Error: resolve-asset requires a relative path argument\n');
|
|
1785
|
+
process.exit(1);
|
|
1786
|
+
}
|
|
1787
|
+
try {
|
|
1788
|
+
const resolved = resolveClaudeRuntimeAsset(relativePath, { projectDir, home });
|
|
1789
|
+
process.stdout.write(resolved + '\n');
|
|
1790
|
+
process.exit(0);
|
|
1791
|
+
} catch (err) {
|
|
1792
|
+
process.stderr.write(err.message + '\n');
|
|
1793
|
+
process.exit(1);
|
|
1794
|
+
}
|
|
1795
|
+
} else if (argv[0] === 'run-asset') {
|
|
1796
|
+
// run-asset <relativePath> [--project <dir>] [--home <dir>] [-- ...scriptArgs]
|
|
1797
|
+
// Security constraints: scripts category only; .js extension only; spawnSync shell:false.
|
|
1798
|
+
const remaining = argv.slice(1);
|
|
1799
|
+
const dashDashIdx = remaining.indexOf('--');
|
|
1800
|
+
const scriptArgs = dashDashIdx !== -1 ? remaining.slice(dashDashIdx + 1) : [];
|
|
1801
|
+
const beforeDash = dashDashIdx !== -1 ? remaining.slice(0, dashDashIdx) : remaining;
|
|
1802
|
+
let relativePath = null;
|
|
1803
|
+
let projectDir = process.cwd();
|
|
1804
|
+
let home;
|
|
1805
|
+
for (let i = 0; i < beforeDash.length; i++) {
|
|
1806
|
+
if (beforeDash[i] === '--project' && beforeDash[i + 1]) { projectDir = beforeDash[++i]; }
|
|
1807
|
+
else if (beforeDash[i] === '--home' && beforeDash[i + 1]) { home = beforeDash[++i]; }
|
|
1808
|
+
else if (!beforeDash[i].startsWith('-')) { relativePath = beforeDash[i]; }
|
|
1809
|
+
}
|
|
1810
|
+
if (!relativePath) {
|
|
1811
|
+
process.stderr.write('Error: run-asset requires a relative path argument\n');
|
|
1812
|
+
process.exit(1);
|
|
1813
|
+
}
|
|
1814
|
+
// Security: only the scripts category is executable
|
|
1815
|
+
if (!relativePath.startsWith('scripts/')) {
|
|
1816
|
+
process.stderr.write(`Error: run-asset is restricted to the scripts category (got: ${relativePath})\n`);
|
|
1817
|
+
process.exit(1);
|
|
1818
|
+
}
|
|
1819
|
+
// Security: .js extension only
|
|
1820
|
+
if (!relativePath.endsWith('.js')) {
|
|
1821
|
+
process.stderr.write(`Error: run-asset only executes .js files (got: ${relativePath})\n`);
|
|
1822
|
+
process.exit(1);
|
|
1823
|
+
}
|
|
1824
|
+
try {
|
|
1825
|
+
const scriptPath = resolveClaudeRuntimeAsset(relativePath, { projectDir, home });
|
|
1826
|
+
const { spawnSync } = require('child_process');
|
|
1827
|
+
const result = spawnSync(process.execPath, [scriptPath, ...scriptArgs], {
|
|
1828
|
+
stdio: 'inherit',
|
|
1829
|
+
shell: false,
|
|
1830
|
+
});
|
|
1831
|
+
if (result.error) {
|
|
1832
|
+
process.stderr.write(`Error spawning script: ${result.error.message}\n`);
|
|
1833
|
+
process.exit(1);
|
|
1834
|
+
}
|
|
1835
|
+
process.exit(result.status || 0);
|
|
1836
|
+
} catch (err) {
|
|
1837
|
+
process.stderr.write(err.message + '\n');
|
|
1838
|
+
process.exit(1);
|
|
1839
|
+
}
|
|
1840
|
+
} else if (argv[0] === 'list-assets') {
|
|
1841
|
+
// list-assets --project <dir> --category <name> [--format json|plain] [--home <dir>]
|
|
1842
|
+
// Both --project and --category are MANDATORY (Tech Spec contract).
|
|
1843
|
+
// Returns exclusively assets from the distributable payload (buildExpectedPayload).
|
|
1844
|
+
// Foreign/user-created files under .claude/ are never returned.
|
|
1845
|
+
// Exit 0 + result for installed single-mode; exit 1 for no-install or mixed install.
|
|
1846
|
+
const { getAssetCategories, getCategoryByName } = require('../lib/asset-catalog');
|
|
1847
|
+
const os = require('os');
|
|
1848
|
+
const remaining = argv.slice(1);
|
|
1849
|
+
let category = null;
|
|
1850
|
+
let format = 'json';
|
|
1851
|
+
let projectDir = null;
|
|
1852
|
+
let home;
|
|
1853
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
1854
|
+
if (remaining[i] === '--category' && remaining[i + 1]) { category = remaining[++i]; }
|
|
1855
|
+
else if (remaining[i] === '--format' && remaining[i + 1]) { format = remaining[++i]; }
|
|
1856
|
+
else if (remaining[i] === '--project' && remaining[i + 1]) { projectDir = remaining[++i]; }
|
|
1857
|
+
else if (remaining[i] === '--home' && remaining[i + 1]) { home = remaining[++i]; }
|
|
1858
|
+
}
|
|
1859
|
+
// Mandatory parameter contract (Tier 3: exit 1, stdout empty, diagnostic on stderr).
|
|
1860
|
+
if (!projectDir) {
|
|
1861
|
+
process.stderr.write('Error: list-assets requires --project <dir>\n');
|
|
1862
|
+
process.exit(1);
|
|
1863
|
+
}
|
|
1864
|
+
if (!category) {
|
|
1865
|
+
process.stderr.write('Error: list-assets requires --category <name>\n');
|
|
1866
|
+
process.exit(1);
|
|
1867
|
+
}
|
|
1868
|
+
const cat = getCategoryByName(category);
|
|
1869
|
+
if (!cat) {
|
|
1870
|
+
const valid = getAssetCategories().map(c => c.name).join(', ');
|
|
1871
|
+
process.stderr.write(`Error: unknown category '${category}'. Valid: ${valid}\n`);
|
|
1872
|
+
process.exit(1);
|
|
1873
|
+
}
|
|
1874
|
+
// Shared 3-condition presence detection (same as resolver and doctor).
|
|
1875
|
+
const effectiveHome = home ? path.resolve(home) : os.homedir();
|
|
1876
|
+
const effectiveProject = path.resolve(projectDir);
|
|
1877
|
+
const localClaude = path.join(effectiveProject, '.claude');
|
|
1878
|
+
const globalClaude = path.join(effectiveHome, '.claude');
|
|
1879
|
+
|
|
1880
|
+
const localPresent = isToolkitInstalled(localClaude);
|
|
1881
|
+
const globalPresent = isToolkitInstalled(globalClaude);
|
|
1882
|
+
|
|
1883
|
+
if (localPresent && globalPresent) {
|
|
1884
|
+
process.stderr.write(
|
|
1885
|
+
'Error: mixed installation — toolkit is present in both local (.claude/) and global ' +
|
|
1886
|
+
`(${globalClaude}) locations. Remove one before listing assets.\n`
|
|
1887
|
+
);
|
|
1888
|
+
process.exit(1);
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// No installation → Tier 3 error (exit 1, stdout empty, diagnostic on stderr).
|
|
1892
|
+
if (!localPresent && !globalPresent) {
|
|
1893
|
+
process.stderr.write('Error: no toolkit installation found. Install with --local or --global.\n');
|
|
1894
|
+
process.exit(1);
|
|
1895
|
+
}
|
|
1896
|
+
const runtimeRoot = localPresent ? localClaude : globalClaude;
|
|
1897
|
+
|
|
1898
|
+
// Return only catalog-defined assets (no foreign files) for the requested
|
|
1899
|
+
// category, and only files that actually exist on disk.
|
|
1900
|
+
const allExpected = [...buildExpectedPayload(runtimeRoot)].sort();
|
|
1901
|
+
const catDir = path.resolve(path.join(runtimeRoot, cat.name));
|
|
1902
|
+
const results = allExpected
|
|
1903
|
+
.filter(f => f.startsWith(catDir + path.sep) || f.startsWith(catDir + '/'))
|
|
1904
|
+
.filter(f => fs.existsSync(f));
|
|
1905
|
+
|
|
1906
|
+
if (format === 'json') {
|
|
1907
|
+
process.stdout.write(JSON.stringify(results, null, 2) + '\n');
|
|
1908
|
+
} else {
|
|
1909
|
+
for (const r of results) process.stdout.write(r + '\n');
|
|
1910
|
+
}
|
|
1911
|
+
process.exit(0);
|
|
1912
|
+
} else if (argv[0] === 'resolve-features-root') {
|
|
1913
|
+
try {
|
|
1914
|
+
const resolved = resolveFeaturesRoot(process.cwd());
|
|
1915
|
+
process.stdout.write(resolved + '\n');
|
|
1916
|
+
process.exit(0);
|
|
1917
|
+
} catch (err) {
|
|
1918
|
+
process.stderr.write(err.message + '\n');
|
|
1919
|
+
process.exit(1);
|
|
1920
|
+
}
|
|
1921
|
+
} else if (argv[0] === 'ledger') {
|
|
1922
|
+
handleLedgerCommand(argv.slice(1));
|
|
778
1923
|
} else if (fs.existsSync(argv[0]) && fs.statSync(argv[0]).isDirectory()) {
|
|
779
|
-
await installLocal(argv[0], force);
|
|
1924
|
+
await installLocal(argv[0], force, dryRun);
|
|
780
1925
|
} else {
|
|
781
1926
|
help();
|
|
782
1927
|
process.exit(1);
|
|
783
1928
|
}
|
|
784
1929
|
}
|
|
785
1930
|
|
|
1931
|
+
// ── shell quoting helper ──────────────────────────────────────────────────────
|
|
1932
|
+
function shellQuotePosix(arg) {
|
|
1933
|
+
const s = String(arg);
|
|
1934
|
+
if (s.indexOf('\0') !== -1) throw new Error('shellQuotePosix: argument contains a NUL byte');
|
|
1935
|
+
if (s.indexOf('\n') !== -1) throw new Error('shellQuotePosix: argument contains a newline');
|
|
1936
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
function parseLedgerArgs(argv) {
|
|
1940
|
+
const PREFIX_RE = /^[A-Za-z]+-\d+$/;
|
|
1941
|
+
const result = { prefix: undefined, agent: undefined, attempt: 1, tokens: undefined, dir: undefined, phase: undefined, model: undefined, error: undefined };
|
|
1942
|
+
|
|
1943
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1944
|
+
const flag = argv[i];
|
|
1945
|
+
const val = argv[i + 1];
|
|
1946
|
+
|
|
1947
|
+
if (flag === '--prefix') {
|
|
1948
|
+
i++;
|
|
1949
|
+
if (!val || !PREFIX_RE.test(val)) {
|
|
1950
|
+
throw new Error(
|
|
1951
|
+
'parseLedgerArgs: --prefix must match /^[A-Za-z]+-\\d+$/ (e.g. FTR-016), got: ' + val
|
|
1952
|
+
);
|
|
1953
|
+
}
|
|
1954
|
+
result.prefix = val;
|
|
1955
|
+
} else if (flag === '--agent') {
|
|
1956
|
+
i++;
|
|
1957
|
+
if (!val) {
|
|
1958
|
+
throw new Error('parseLedgerArgs: --agent must be a non-empty string');
|
|
1959
|
+
}
|
|
1960
|
+
result.agent = val;
|
|
1961
|
+
} else if (flag === '--attempt') {
|
|
1962
|
+
i++;
|
|
1963
|
+
const n = Number(val);
|
|
1964
|
+
if (!Number.isInteger(n)) {
|
|
1965
|
+
throw new Error('parseLedgerArgs: --attempt must be an integer, got: ' + val);
|
|
1966
|
+
}
|
|
1967
|
+
result.attempt = n;
|
|
1968
|
+
} else if (flag === '--tokens') {
|
|
1969
|
+
i++;
|
|
1970
|
+
const n = Number(val);
|
|
1971
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
1972
|
+
throw new Error('parseLedgerArgs: --tokens must be an integer >= 1, got: ' + val);
|
|
1973
|
+
}
|
|
1974
|
+
result.tokens = n;
|
|
1975
|
+
} else if (flag === '--dir') {
|
|
1976
|
+
i++;
|
|
1977
|
+
if (!val) {
|
|
1978
|
+
throw new Error('parseLedgerArgs: --dir must be a non-empty string');
|
|
1979
|
+
}
|
|
1980
|
+
result.dir = val;
|
|
1981
|
+
} else if (flag === '--phase') {
|
|
1982
|
+
i++;
|
|
1983
|
+
if (!val) {
|
|
1984
|
+
throw new Error('parseLedgerArgs: --phase must be a non-empty string');
|
|
1985
|
+
}
|
|
1986
|
+
result.phase = val;
|
|
1987
|
+
} else if (flag === '--model') {
|
|
1988
|
+
i++;
|
|
1989
|
+
if (!val) {
|
|
1990
|
+
throw new Error('parseLedgerArgs: --model must be a non-empty string');
|
|
1991
|
+
}
|
|
1992
|
+
result.model = val;
|
|
1993
|
+
} else if (flag === '--error') {
|
|
1994
|
+
i++;
|
|
1995
|
+
if (!val) {
|
|
1996
|
+
throw new Error('parseLedgerArgs: --error must be a non-empty string');
|
|
1997
|
+
}
|
|
1998
|
+
result.error = val;
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
if (!result.prefix) {
|
|
2003
|
+
throw new Error('parseLedgerArgs: --prefix is required');
|
|
2004
|
+
}
|
|
2005
|
+
if (!result.agent) {
|
|
2006
|
+
throw new Error('parseLedgerArgs: --agent is required and must be non-empty');
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
return result;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
// Serialize obj to JSON with all object keys sorted recursively.
|
|
2013
|
+
// Used by the ledger subcommand to produce deterministic stdout output.
|
|
2014
|
+
function sortedJson(obj) {
|
|
2015
|
+
return JSON.stringify(obj, (key, val) => {
|
|
2016
|
+
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
|
|
2017
|
+
const sorted = {};
|
|
2018
|
+
for (const k of Object.keys(val).sort()) sorted[k] = val[k];
|
|
2019
|
+
return sorted;
|
|
2020
|
+
}
|
|
2021
|
+
return val;
|
|
2022
|
+
}, 2);
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
// ── ledger dispatcher ─────────────────────────────────────────────────────────
|
|
2026
|
+
// Routes `ai-toolkit ledger <subcommand> [flags]` to the per-operation
|
|
2027
|
+
// handlers in lib/execution-ledger.js. All operation logic lives in that
|
|
2028
|
+
// module; this function only parses shared flags and routes.
|
|
2029
|
+
function handleLedgerCommand(argv) {
|
|
2030
|
+
const subcommand = argv[0];
|
|
2031
|
+
const flags = argv.slice(1);
|
|
2032
|
+
let args;
|
|
2033
|
+
try {
|
|
2034
|
+
args = parseLedgerArgs(flags);
|
|
2035
|
+
} catch (err) {
|
|
2036
|
+
process.stderr.write(sortedJson({ message: err.message, status: 'error' }) + '\n');
|
|
2037
|
+
process.exitCode = 1;
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
if (subcommand === 'open') {
|
|
2042
|
+
try {
|
|
2043
|
+
const result = executionLedger.open(args.dir, args.prefix, args.agent, args.phase, args.model, args.attempt);
|
|
2044
|
+
process.stdout.write(sortedJson(result) + '\n');
|
|
2045
|
+
process.exitCode = 0;
|
|
2046
|
+
} catch (err) {
|
|
2047
|
+
process.stderr.write(sortedJson({ message: err.message, status: 'error' }) + '\n');
|
|
2048
|
+
process.exitCode = 1;
|
|
2049
|
+
}
|
|
2050
|
+
return;
|
|
2051
|
+
} else if (subcommand === 'close') {
|
|
2052
|
+
try {
|
|
2053
|
+
const result = executionLedger.close(args.dir, args.prefix, args.agent, args.tokens, args.attempt);
|
|
2054
|
+
process.stdout.write(sortedJson(result) + '\n');
|
|
2055
|
+
process.exitCode = 0;
|
|
2056
|
+
} catch (err) {
|
|
2057
|
+
process.stderr.write(sortedJson({ message: err.message, status: 'error' }) + '\n');
|
|
2058
|
+
process.exitCode = 1;
|
|
2059
|
+
}
|
|
2060
|
+
return;
|
|
2061
|
+
} else if (subcommand === 'fail') {
|
|
2062
|
+
try {
|
|
2063
|
+
const result = executionLedger.fail(args.dir, args.prefix, args.agent, args.error, args.attempt);
|
|
2064
|
+
process.stdout.write(sortedJson(result) + '\n');
|
|
2065
|
+
process.exitCode = 0;
|
|
2066
|
+
} catch (err) {
|
|
2067
|
+
process.stderr.write(sortedJson({ message: err.message, status: 'error' }) + '\n');
|
|
2068
|
+
process.exitCode = 1;
|
|
2069
|
+
}
|
|
2070
|
+
return;
|
|
2071
|
+
} else if (subcommand === 'skip') {
|
|
2072
|
+
try {
|
|
2073
|
+
const result = executionLedger.skip(args.dir, args.prefix, args.agent, args.phase, args.model, args.attempt);
|
|
2074
|
+
process.stdout.write(sortedJson(result) + '\n');
|
|
2075
|
+
process.exitCode = 0;
|
|
2076
|
+
} catch (err) {
|
|
2077
|
+
process.stderr.write(sortedJson({ message: err.message, status: 'error' }) + '\n');
|
|
2078
|
+
process.exitCode = 1;
|
|
2079
|
+
}
|
|
2080
|
+
return;
|
|
2081
|
+
} else {
|
|
2082
|
+
throw new Error('handleLedgerCommand: unknown subcommand: ' + subcommand);
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
|
|
786
2086
|
// ── entry point guard ─────────────────────────────────────────────────────────
|
|
787
2087
|
// Run the CLI only when invoked directly (node bin/cli.js).
|
|
788
2088
|
// When required as a module (e.g., by Jest), skip main() and export pure
|
|
@@ -796,10 +2096,10 @@ if (require.main === module) {
|
|
|
796
2096
|
expandMappings,
|
|
797
2097
|
categorize,
|
|
798
2098
|
readInstalledVersion,
|
|
799
|
-
NEVER_COPY,
|
|
800
2099
|
readManifest,
|
|
801
2100
|
computeOrphans,
|
|
802
2101
|
moveToTrash,
|
|
2102
|
+
trashTimestamp,
|
|
803
2103
|
writeManifest,
|
|
804
2104
|
CANONICAL_ALLOW,
|
|
805
2105
|
CANONICAL_ASK,
|
|
@@ -811,7 +2111,21 @@ if (require.main === module) {
|
|
|
811
2111
|
writeSettings,
|
|
812
2112
|
mergeAllowlist,
|
|
813
2113
|
updateGitignore,
|
|
814
|
-
|
|
815
|
-
|
|
2114
|
+
resolveClaudeRuntimeAsset,
|
|
2115
|
+
runDoctorResolution,
|
|
2116
|
+
validatePurityGuard,
|
|
2117
|
+
TOOLKIT_INTERNAL_ASSETS,
|
|
2118
|
+
buildPayloadFileMappings,
|
|
2119
|
+
buildExpectedPayload,
|
|
2120
|
+
validateRuntimeRelativePath,
|
|
2121
|
+
validateManifestFilesField,
|
|
2122
|
+
hasToolkitPayloadFiles,
|
|
2123
|
+
isToolkitInstalled,
|
|
2124
|
+
runVerifyInstall,
|
|
2125
|
+
shellQuotePosix,
|
|
2126
|
+
parseLedgerArgs,
|
|
2127
|
+
handleLedgerCommand,
|
|
2128
|
+
sortedJson,
|
|
2129
|
+
resolveFeaturesRoot,
|
|
816
2130
|
};
|
|
817
2131
|
}
|