agents-handoff 2.0.2 → 2.0.4

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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // agents-handoff - npx installer for the agent-handoff skill
2
+ // agents-handoff - npx installer for the agents-handoff skill
3
3
  // Commands: install, update, remove, verify, list
4
4
  // Zero external dependencies, pure Node.js
5
5
  import fs from 'node:fs';
@@ -16,8 +16,19 @@ const INSTALLER_ROOT = path.resolve(__dirname);
16
16
  // archive: the parent of install/. A published package has no parent tree, so this path is
17
17
  // also the test that decides between copying files and fetching an archive.
18
18
  const SOURCE_DIR = path.resolve(INSTALLER_ROOT, '..');
19
- const SKILL_NAME = 'agent-handoff';
19
+ const SKILL_NAME = 'agents-handoff';
20
+ // The directory name every release before 2.0.3 installed under. An upgraded machine holds these
21
+ // in exactly the places a new install would go, so the search looks for both names: `--update`
22
+ // and `--verify` act on a pre-rename copy in place instead of reporting that nothing is
23
+ // installed while it sits right there. A NEW install always uses SKILL_NAME.
24
+ const LEGACY_SKILL_NAME = 'agent-handoff';
25
+ // The npm package users `npx`. Same name as the product, and the reason the install record can
26
+ // verify an installation against the tarball npm is actually serving.
27
+ const NPM_PACKAGE = 'agents-handoff';
28
+ const REGISTRY = 'https://registry.npmjs.org';
20
29
  const REPO_OWNER = 'Alot1z';
30
+ // The PRODUCT is agents-handoff; the REPOSITORY is still Alot1z/agent-handoff. The two names
31
+ // are not the same string, and this constant is the one place the repository's name lives.
21
32
  const REPO_NAME = 'agent-handoff';
22
33
  const RELEASES_URL = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download`;
23
34
  const TARBALL_URL = `https://codeload.github.com/${REPO_OWNER}/${REPO_NAME}/tar.gz`;
@@ -27,7 +38,7 @@ const API_LATEST = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/rele
27
38
  // `list` read back out of an installed copy. The literal below is only the fallback for the
28
39
  // run that has no tree beside it — the published package fetching an archive — and the suite
29
40
  // asserts it against SKILL.md, so a release that bumps one cannot leave the other behind.
30
- const FALLBACK_SKILL_VERSION = '2.0.2';
41
+ const FALLBACK_SKILL_VERSION = '2.0.4';
31
42
  function skillVersion() {
32
43
  try {
33
44
  const md = fs.readFileSync(path.join(SOURCE_DIR, 'SKILL.md'), 'utf8');
@@ -53,7 +64,7 @@ const HOME = process.env.USERPROFILE || process.env.HOME || '';
53
64
  // Where a GLOBAL install can go. Desktop clients keep account skills in their own store with
54
65
  // two opaque id levels between the store and the skill:
55
66
  //
56
- // <store>/<account-id>/<profile-id>/agent-handoff/
67
+ // <store>/<account-id>/<profile-id>/agents-handoff/
57
68
  //
58
69
  // No client is named here. A store is DISCOVERED by looking for `account-skills` directories
59
70
  // under the platform's application-data roots, so a client that is absent from this machine
@@ -61,7 +72,16 @@ const HOME = process.env.USERPROFILE || process.env.HOME || '';
61
72
  // `resolveGlobalRoot` picks one and says why. `AGENT_HANDOFF_GLOBAL_DIR` overrides the
62
73
  // search; `--path` bypasses it.
63
74
  const DATA_ROOTS = (process.platform === 'win32'
64
- ? [process.env.APPDATA, process.env.LOCALAPPDATA, path.join(HOME, 'AppData', 'Roaming')]
75
+ ? [
76
+ process.env.APPDATA,
77
+ process.env.LOCALAPPDATA,
78
+ path.join(HOME, 'AppData', 'Roaming'),
79
+ // A desktop client keeps its account-skill store under ~/.config even on Windows, where
80
+ // that directory is not an application-data root at all. Leaving it out is not a cosmetic
81
+ // gap: the machine's real installation is then invisible to every command that resolves a
82
+ // global root or searches for installations, while the store it lives in keeps working.
83
+ path.join(HOME, '.config'),
84
+ ]
65
85
  : [path.join(HOME, '.config'), path.join(HOME, '.local', 'share')]
66
86
  ).filter(Boolean);
67
87
  const AGENTS_SKILLS = path.join(HOME, '.agents', 'skills');
@@ -92,7 +112,7 @@ function accountSkillStores() {
92
112
  return [...new Set(out)];
93
113
  }
94
114
 
95
- // Every directory that could be the PARENT of a global agent-handoff/, from every account
115
+ // Every directory that could be the PARENT of a global agents-handoff/, from every account
96
116
  // store this machine has.
97
117
  function accountSkillRoots() {
98
118
  const stores = accountSkillStores();
@@ -108,7 +128,7 @@ function accountSkillRoots() {
108
128
 
109
129
  // Resolution order, first hit wins:
110
130
  // 1. AGENT_HANDOFF_GLOBAL_DIR — explicit operator override
111
- // 2. an account-skill root that ALREADY holds agent-handoff (newest install first)
131
+ // 2. an account-skill root that ALREADY holds agents-handoff (newest install first)
112
132
  // 3. ~/.agents/skills — the harness-wide skills home
113
133
  // 4. any account-skill root discovered above — keeps the install where a client reads
114
134
  // 5. ~/.agents/skills — created on install when nothing exists
@@ -163,13 +183,38 @@ const hasFlag = (name) => args.includes(name);
163
183
  // had to be a bare verb, so every documented flag form exited 1 with "Unknown command".
164
184
  const FLAG_COMMANDS = {
165
185
  '--install': 'install', '--update': 'update', '--remove': 'remove',
166
- '--verify': 'verify', '--list': 'list', '--help': 'help', '-h': 'help'
186
+ '--verify': 'verify', '--list': 'list', '--doctor': 'doctor',
187
+ '--verify-package': 'verify-package',
188
+ '--help': 'help', '-h': 'help'
167
189
  };
168
190
  const COMMAND = FLAG_COMMANDS[args[0]] || (args[0] && !args[0].startsWith('-') ? args[0] : 'install');
169
191
  const LOCATION = getArg('--location', 'global');
170
192
  const CUSTOM_PATH = getArg('--path');
171
193
  const VERSION = getArg('--version', 'latest');
172
194
  const FORCE = hasFlag('--force') || hasFlag('-f');
195
+ const PROJECT_TARGET = hasFlag('--project');
196
+ const PROVENANCE = hasFlag('--provenance');
197
+ // `--record` writes what `verify-package` fetched (tarball sha256/sha512, the registry's
198
+ // integrity string) into the install record, so a later run compares against a stored value
199
+ // instead of re-deriving one.
200
+ const RECORD = hasFlag('--record');
201
+
202
+ // Which harnesses to install into. `--claude`, `--codex`, `--agents`, `--harness claude,codex`
203
+ // (repeatable) and `--all` (every harness whose home directory exists here). `--skills-dir
204
+ // <path>` (repeatable) targets any other stack exactly. NO harness flag keeps the historical
205
+ // single target: the resolved global root, ./local/skills, ./skills, or --path.
206
+ const HARNESS_FLAGS = { '--claude': 'claude', '--codex': 'codex', '--agents': 'agents' };
207
+ const HARNESSES_CHOSEN = [];
208
+ const SKILLS_DIRS = [];
209
+ for (let i = 0; i < args.length; i += 1) {
210
+ const a = args[i];
211
+ if (HARNESS_FLAGS[a]) HARNESSES_CHOSEN.push(HARNESS_FLAGS[a]);
212
+ else if (a === '--harness') {
213
+ HARNESSES_CHOSEN.push(...String(args[i + 1] || '').split(',').map(s => s.trim()).filter(Boolean));
214
+ } else if (a === '--all') HARNESSES_CHOSEN.push('all');
215
+ else if (a === '--skills-dir') SKILLS_DIRS.push(String(args[i + 1] || ''));
216
+ }
217
+ const MULTI_TARGET = HARNESSES_CHOSEN.length > 0 || SKILLS_DIRS.length > 0;
173
218
 
174
219
  // THE INSTALL MANIFEST. One list, used to copy AND to verify, so an installed copy
175
220
  // cannot silently lose a file the runtime needs (the engine now imports
@@ -188,6 +233,8 @@ const SKILL_FILES = [
188
233
  { from: 'handoff.config.schema.json', to: 'handoff.config.schema.json' },
189
234
  { from: 'handoff.config.example.json', to: 'handoff.config.example.json' },
190
235
  { from: 'tools/handoff.mjs', to: 'tools/handoff.mjs' },
236
+ { from: 'tools/agents-handoff.mjs', to: 'tools/agents-handoff.mjs' },
237
+ // The pre-rename runtime path, installed as a forwarder so old notes keep working.
191
238
  { from: 'tools/agent-handoff.mjs', to: 'tools/agent-handoff.mjs' },
192
239
  { from: 'tools/handoff.test.mjs', to: 'tools/handoff.test.mjs' },
193
240
  { from: 'tools/capability-registry.mjs', to: 'tools/capability-registry.mjs' },
@@ -253,7 +300,7 @@ function copyManifest(root, installPath) {
253
300
  copied++;
254
301
  }
255
302
  if (unresolved.length) warn('source file(s) not found: ' + unresolved.join(', '));
256
- return copied;
303
+ return { copied, unresolved };
257
304
  }
258
305
 
259
306
  // The newest published release tag, or null when the repository has none (or the API cannot
@@ -273,7 +320,7 @@ async function latestTag() {
273
320
  // Fetch and unpack the archive for the requested version, into a throwaway directory the
274
321
  // caller removes. Returns the unpacked root, so the same manifest copies from it.
275
322
  async function fetchAndUnpack(version) {
276
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-handoff-fetch-'));
323
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-handoff-fetch-'));
277
324
  let ref, label;
278
325
  if (version === 'latest') {
279
326
  const tag = await latestTag();
@@ -285,7 +332,7 @@ async function fetchAndUnpack(version) {
285
332
  label = 'v' + version;
286
333
  }
287
334
  const url = `${TARBALL_URL}/${ref}`;
288
- const tarball = path.join(tmp, 'agent-handoff.tar.gz');
335
+ const tarball = path.join(tmp, 'agents-handoff.tar.gz');
289
336
  log(`Fetching the ${label} archive...`);
290
337
  log(` ${url}`);
291
338
  await downloadFile(url, tarball);
@@ -298,17 +345,24 @@ async function fetchAndUnpack(version) {
298
345
  // (`C:\Users\…`) is read by tar as a remote host — `tar (child): Cannot connect to C:
299
346
  // resolve failed` — so `-xzf C:\…` fails on the very platform this installer targets first.
300
347
  // Relative operands, with cwd doing the locating, work the same in GNU tar and bsdtar.
301
- const tar = spawnSync('tar', ['-xzf', 'agent-handoff.tar.gz', '-C', 'unpack', '--strip-components=1'],
348
+ const tar = spawnSync('tar', ['-xzf', 'agents-handoff.tar.gz', '-C', 'unpack', '--strip-components=1'],
302
349
  { cwd: tmp, encoding: 'utf8' });
303
350
  if (tar.status !== 0) {
304
351
  const why = String(tar.stderr || '').trim().split('\n')[0];
305
352
  error(`Cannot extract the archive (tar exited ${tar.status}${why ? ': ' + why : ''}).\n` +
306
353
  ` Extract it yourself, then install from the unpacked tree:\n\n` +
307
- ` curl -L -o agent-handoff.tar.gz ${url}\n` +
308
- ` mkdir -p agent-handoff && tar -xzf agent-handoff.tar.gz -C agent-handoff --strip-components=1\n` +
309
- ` node agent-handoff/install/install.mjs`);
354
+ ` curl -L -o agents-handoff.tar.gz ${url}\n` +
355
+ ` mkdir -p agents-handoff && tar -xzf agents-handoff.tar.gz -C agents-handoff --strip-components=1\n` +
356
+ ` node agents-handoff/install/install.mjs`);
310
357
  }
311
- return { root: unpack, cleanup: () => fs.rmSync(tmp, { recursive: true, force: true }) };
358
+ return {
359
+ root: unpack,
360
+ cleanup: () => fs.rmSync(tmp, { recursive: true, force: true }),
361
+ ref,
362
+ label,
363
+ url,
364
+ sha256: hash,
365
+ };
312
366
  }
313
367
 
314
368
  // Resolve install path
@@ -329,6 +383,173 @@ function resolveInstallPath(location, customPath) {
329
383
  }
330
384
  }
331
385
 
386
+ // ---------------------------------------------------------------- harness targets
387
+ // The directories each harness actually reads for personal skills, so an install here needs no
388
+ // configuration afterwards. `--project` switches to the per-repository form, which is what a
389
+ // harness reads when it is run inside that repository.
390
+ // claude ~/.claude/skills Claude Code personal skills project: ./.claude/skills
391
+ // codex ~/.codex/skills Codex CLI personal skills project: ./.codex/skills
392
+ // agents ~/.agents/skills harness-neutral store project: ./.agents/skills
393
+ const HARNESSES = {
394
+ claude: { user: path.join(HOME, '.claude', 'skills'), project: path.join(process.cwd(), '.claude', 'skills') },
395
+ codex: { user: path.join(HOME, '.codex', 'skills'), project: path.join(process.cwd(), '.codex', 'skills') },
396
+ agents: { user: AGENTS_SKILLS, project: path.join(process.cwd(), '.agents', 'skills') },
397
+ };
398
+
399
+ // A harness is DETECTED when its configuration directory exists. Detection is a suggestion,
400
+ // never a decision: `--all` installs into the detected ones and says which were skipped, and an
401
+ // explicit `--claude` installs there whether or not the directory exists yet.
402
+ function harnessDetected(name) {
403
+ const home = { claude: '.claude', codex: '.codex', agents: '.agents' }[name];
404
+ return Boolean(home && fs.existsSync(path.join(HOME, home)));
405
+ }
406
+
407
+ function dirForHarness(name, projectForm) {
408
+ const h = HARNESSES[name];
409
+ if (!h) error(`Unknown harness: ${name}. Known harnesses: ${Object.keys(HARNESSES).join(', ')} (or use --skills-dir <path>)`);
410
+ return projectForm ? h.project : h.user;
411
+ }
412
+
413
+ // Every installation target this run will write to, in the order it will write them.
414
+ function resolveTargets() {
415
+ if (!MULTI_TARGET) {
416
+ return [{ label: 'resolved target', dir: null, path: resolveInstallPath(LOCATION, CUSTOM_PATH) }];
417
+ }
418
+ const targets = [];
419
+ const names = HARNESSES_CHOSEN.includes('all')
420
+ ? [...new Set([...HARNESSES_CHOSEN.filter(n => n !== 'all'), ...Object.keys(HARNESSES).filter(harnessDetected)])]
421
+ : [...new Set(HARNESSES_CHOSEN)];
422
+ for (const name of names) {
423
+ if (name === 'all') continue;
424
+ const dir = dirForHarness(name, PROJECT_TARGET);
425
+ targets.push({ label: name + (PROJECT_TARGET ? ' (project)' : ''), harness: name, dir, path: path.join(dir, SKILL_NAME) });
426
+ }
427
+ for (const d of SKILLS_DIRS) {
428
+ if (!d) continue;
429
+ const dir = path.resolve(d);
430
+ targets.push({ label: 'custom dir ' + dir, dir, path: path.join(dir, SKILL_NAME) });
431
+ }
432
+ if (!targets.length) error('no installation targets: pass a harness (--claude, --codex, --agents), --all, or --skills-dir <path>');
433
+ return targets;
434
+ }
435
+
436
+ // EVERY installation this machine has, not just the resolved one. `update` and `verify` with no
437
+ // harness flag act on this list, because "keep my install current" is about the copies that
438
+ // exist, not about the one target the resolver happens to pick: a machine can hold the same
439
+ // skill in ~/.claude/skills and ~/.agents/skills, and updating only the resolved one is how a
440
+ // second harness silently keeps an old engine.
441
+ function discoverInstalls() {
442
+ const out = [];
443
+ const seen = new Set();
444
+ const push = (label, dir) => {
445
+ if (!dir) return;
446
+ for (const [name, suffix] of [[SKILL_NAME, ''], [LEGACY_SKILL_NAME, ' — pre-2.0.3 name, updated in place']]) {
447
+ const p = path.join(dir, name);
448
+ if (seen.has(p)) continue;
449
+ seen.add(p);
450
+ if (isInstalled(p)) out.push({ label: label + suffix, dir, path: p });
451
+ }
452
+ };
453
+ const g = resolveGlobalRoot();
454
+ push('global (resolved: ' + g.why + ')', g.root);
455
+ push('local', PATHS.local);
456
+ push('project', PATHS.project);
457
+ for (const name of Object.keys(HARNESSES)) push('harness ' + name, HARNESSES[name].user);
458
+ for (const name of Object.keys(HARNESSES)) push('harness ' + name + ' (project)', HARNESSES[name].project);
459
+ for (const d of accountSkillRoots()) push('account-skill candidate', d);
460
+ push('harness skills home', AGENTS_SKILLS);
461
+ for (const d of SKILLS_DIRS) if (d) push('skills-dir ' + d, path.resolve(d));
462
+ return out;
463
+ }
464
+
465
+ // ---------------------------------------------------------------- install provenance
466
+ // What landed, and what it was made from. `verify` re-computes the file-set hash and compares
467
+ // it against this record, so an installation can be checked against the tree it was installed
468
+ // from instead of against the manifest alone — and an install that came from a tagged archive
469
+ // can say WHICH archive, by hash.
470
+ const PROVENANCE_FILE = '.agents-handoff-install.json';
471
+ const fileSha256 = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
472
+
473
+ // The file-set hash: every manifest path with its own sha256, sorted, folded into one value.
474
+ // Paths are part of the hash, so a file moved to another name changes it.
475
+ function fileSetHash(installPath) {
476
+ const parts = [];
477
+ for (const f of SKILL_FILES.map(x => x.to).sort()) {
478
+ const abs = path.join(installPath, f);
479
+ parts.push(f + '\u0000' + (fs.existsSync(abs) ? fileSha256(abs) : 'missing'));
480
+ }
481
+ return crypto.createHash('sha256').update(parts.join('\n')).digest('hex');
482
+ }
483
+
484
+ // The npm identity this install came from. `verify-package` fills in the hashes on first run
485
+ // (they cannot be known while installing from a tree — that is a different artifact from the
486
+ // published tarball), and compares against them afterwards, so an install carries its own
487
+ // record of the package it should match.
488
+ function packageRecord(installPath) {
489
+ const version = getInstalledVersion(installPath) || null;
490
+ return {
491
+ name: NPM_PACKAGE,
492
+ version,
493
+ registry: REGISTRY,
494
+ tarball: version ? `${REGISTRY}/${NPM_PACKAGE}/-/${NPM_PACKAGE}-${version}.tgz` : null,
495
+ sha256: null,
496
+ sha512: null,
497
+ integrity: null,
498
+ shasum: null,
499
+ verified_at: null,
500
+ };
501
+ }
502
+
503
+ function writeProvenance(installPath, info) {
504
+ const record = {
505
+ schema_version: '1.0-install-provenance',
506
+ product: SKILL_NAME,
507
+ version: getInstalledVersion(installPath) || info.version || null,
508
+ installer_version: INSTALLER_VERSION,
509
+ installed_at: new Date().toISOString(),
510
+ harness: info.harness || null,
511
+ target: installPath,
512
+ source: info.source,
513
+ package: packageRecord(installPath),
514
+ file_count: SKILL_FILES.filter(f => fs.existsSync(path.join(installPath, f.to))).length,
515
+ manifest_entries: SKILL_FILES.length,
516
+ files_sha256: fileSetHash(installPath),
517
+ files: Object.fromEntries(SKILL_FILES.map(entry => entry.to).sort().map(rel => [
518
+ rel,
519
+ fs.existsSync(path.join(installPath, rel)) ? fileSha256(path.join(installPath, rel)) : 'missing',
520
+ ])),
521
+ };
522
+ fs.writeFileSync(path.join(installPath, PROVENANCE_FILE), JSON.stringify(record, null, 2) + '\n');
523
+ return record;
524
+ }
525
+
526
+ function readProvenance(installPath) {
527
+ try {
528
+ return JSON.parse(fs.readFileSync(path.join(installPath, PROVENANCE_FILE), 'utf8'));
529
+ } catch { return null; }
530
+ }
531
+
532
+ // Compare an installation with its own record. Returns the verdict; the caller decides whether
533
+ // a missing record is a failure (it is not: installations predating this feature have none).
534
+ function checkProvenance(installPath) {
535
+ const record = readProvenance(installPath);
536
+ if (!record) return { present: false };
537
+ const actual = fileSetHash(installPath);
538
+ const changed = [];
539
+ for (const [rel, want] of Object.entries(record.files || {})) {
540
+ const abs = path.join(installPath, rel);
541
+ const have = fs.existsSync(abs) ? fileSha256(abs) : 'missing';
542
+ if (have !== want) changed.push(rel + ' (' + (have === 'missing' ? 'missing' : 'changed') + ')');
543
+ }
544
+ return { present: true, record, match: actual === record.files_sha256, actual, changed };
545
+ }
546
+
547
+ function describeSource(source) {
548
+ if (!source) return 'unrecorded';
549
+ if (source.kind === 'tree') return 'the tree beside the installer (' + (source.path || 'checkout') + ')';
550
+ return (source.ref || 'archive') + (source.archive_sha256 ? ' archive sha256 ' + source.archive_sha256.slice(0, 16) + '…' : '');
551
+ }
552
+
332
553
  // Check if installed
333
554
  function isInstalled(installPath) {
334
555
  return fs.existsSync(path.join(installPath, 'SKILL.md'));
@@ -375,57 +596,37 @@ async function downloadFile(url, destPath) {
375
596
  throw new Error(`Failed to download from ${url}. Neither fetch, curl, nor wget available.`);
376
597
  }
377
598
 
378
- // Download and verify package
379
- async function downloadAndVerify(installPath, version) {
380
- const isLatest = version === 'latest';
381
- const zipName = isLatest ? `${SKILL_NAME}-latest.zip` : `${SKILL_NAME}-v${version}.zip`;
382
- const zipUrl = isLatest
383
- ? `${RELEASES_URL}/latest/${zipName}`
384
- : `${RELEASES_URL}/v${version}/${zipName}`;
385
- const zipPath = path.join(installPath, zipName);
386
-
387
- log(`Downloading ${SKILL_NAME} v${version}...`);
388
-
389
- try {
390
- const data = await downloadFile(zipUrl, zipPath);
391
- const hash = crypto.createHash('sha256').update(data).digest('hex');
392
- log(`Downloaded: ${hash.slice(0, 16)}...`);
393
- return { zipPath, hash, zipUrl };
394
- } catch (e) {
395
- if (e.message.includes('HTTP 404') || e.message.includes('404')) {
396
- error(`Version ${version} not found. Available versions: Check ${RELEASES_URL}`);
397
- }
398
- throw e;
399
- }
400
- }
599
+ // The zip path a release used to ship (agent-handoff-<tag>.zip, named after the repository) is
600
+ // gone: releases attach that zip for humans, and every automated path uses the tag tarball,
601
+ // which is what an install records when it fetches instead of copying.
401
602
 
402
- // Extract zip (using unzip or native)
403
- function extractZip(zipPath, destDir) {
404
- // Try unzip first
405
- const unzip = spawnSync('unzip', ['-o', zipPath, '-d', destDir], { encoding: 'utf8' });
406
- if (unzip.status === 0) {
407
- return true;
603
+ // Install into every target this run selected: one harness, several at once, or the historical
604
+ // single target. Each target gets its own copy and its own provenance record, so one run can
605
+ // cover Claude Code and Codex without installing twice.
606
+ async function install(location, customPath, version, force) {
607
+ const targets = resolveTargets();
608
+ const results = [];
609
+ for (const [i, target] of targets.entries()) {
610
+ if (targets.length > 1) log(`\n${C('bold', '── target ' + (i + 1) + ' of ' + targets.length + ': ' + target.label + ' ──')}`);
611
+ results.push(await installTarget(target, version, force));
408
612
  }
409
-
410
- // Fallback: Node.js doesn't have native unzip, suggest installing unzip
411
- error(`Cannot extract zip. Please install 'unzip' or use a different method:
412
-
413
- # Download manually
414
- curl -L -o ${SKILL_NAME}.zip ${zipPath.replace(/\\/g, '\\\\')}
415
- unzip ${SKILL_NAME}.zip -d ${destDir}
416
-
417
- Or download from: ${RELEASES_URL}`);
613
+ if (targets.length > 1) {
614
+ log(`\n${C('bold', 'Summary')}`);
615
+ for (const r of results) {
616
+ log(` ${r.skipped ? C('yellow', '–') + ' kept' : C('green', '✓') + ' installed'} ${r.path} — v${r.version}`);
617
+ }
618
+ }
619
+ return results;
418
620
  }
419
621
 
420
- // Install skill to path
421
- async function install(location, customPath, version, force) {
422
- const installPath = resolveInstallPath(location, customPath);
622
+ async function installTarget(target, version, force) {
623
+ const installPath = target.path;
423
624
  const alreadyInstalled = isInstalled(installPath);
424
625
  const currentVersion = alreadyInstalled ? getInstalledVersion(installPath) : null;
425
626
 
426
- log(`\n${C('bold', 'agent-handoff installer v' + INSTALLER_VERSION)}`);
427
- log(`Target: ${installPath}`);
428
- if (!customPath && location === 'global') {
627
+ log(`\n${C('bold', 'agents-handoff installer v' + INSTALLER_VERSION)}`);
628
+ log(`Target: ${installPath}` + (target.label && target.label !== 'resolved target' ? ` (${target.label})` : ''));
629
+ if (!MULTI_TARGET && !CUSTOM_PATH && LOCATION === 'global') {
429
630
  const g = resolveGlobalRoot();
430
631
  log(`Global root: ${g.root} — ${g.why}`);
431
632
  // The search is a decision, so the candidates it rejected are shown rather than hidden:
@@ -463,12 +664,29 @@ async function install(location, customPath, version, force) {
463
664
  // unpacked into a throwaway directory, and the same manifest copies out of it.
464
665
  let sourceRoot = SOURCE_DIR;
465
666
  let cleanup = null;
466
- if (haveSourceTree()) {
667
+ // What this install is made from, recorded so `verify` can say more than "the files are here".
668
+ let sourceInfo = { kind: 'tree', path: path.relative(process.cwd(), SOURCE_DIR) || '.' };
669
+ // `--version` has to mean something. The tree beside the installer wins only when it IS the
670
+ // requested version (or when nothing was requested); asking for another version fetches that
671
+ // version's archive instead of quietly installing the tree in front of it.
672
+ const treeVersion = haveSourceTree() ? getInstalledVersion(SOURCE_DIR) : null;
673
+ const wantsOtherVersion = version !== 'latest' && treeVersion && treeVersion !== version;
674
+ if (haveSourceTree() && !wantsOtherVersion) {
467
675
  log(`\nInstalling from the tree beside the installer...`);
468
676
  } else {
677
+ if (wantsOtherVersion) {
678
+ log(`\nRequested v${version}; the tree beside the installer is v${treeVersion}, so the v${version} archive is fetched.`);
679
+ }
469
680
  const fetched = await fetchAndUnpack(version);
470
681
  sourceRoot = fetched.root;
471
682
  cleanup = fetched.cleanup; // removed after the manifest has read out of it
683
+ sourceInfo = {
684
+ kind: 'archive',
685
+ ref: fetched.ref,
686
+ label: fetched.label,
687
+ archive_url: fetched.url,
688
+ archive_sha256: fetched.sha256,
689
+ };
472
690
  }
473
691
 
474
692
  // Create directories
@@ -477,26 +695,44 @@ async function install(location, customPath, version, force) {
477
695
  fs.mkdirSync(path.join(installPath, subdir), { recursive: true });
478
696
  }
479
697
 
480
- const copied = copyManifest(sourceRoot, installPath);
481
- if (cleanup) cleanup();
698
+ const { copied } = copyManifest(sourceRoot, installPath);
482
699
 
483
- // The manifest is the completion criterion, so a short install fails here instead of
484
- // reporting success. This is the check that was missing when the installer looked for its
485
- // guides under a path that only exists in the development tree: it warned fourteen times
486
- // and installed a copy with no README, no LICENSE and no docs/ at all.
700
+ // The manifest is the completion criterion, so a short install fails here instead of reporting
701
+ // success. This is the check that was missing when the installer looked for its guides under a
702
+ // path that only exists in the development tree: it warned fourteen times and installed a copy
703
+ // with no README, no LICENSE and no docs/ at all.
704
+ //
705
+ // Two different things look the same here, and conflating them was a bug: a file the SOURCE
706
+ // does not have (pinning an older version legitimately has fewer files) and a file the source
707
+ // HAS but that did not land (a broken copy). The first is reported, the second fails.
487
708
  const missing = SKILL_FILES.filter(f => !fs.existsSync(path.join(installPath, f.to)));
488
709
  if (missing.length) {
489
- error(`Incomplete install: ${missing.length} of ${SKILL_FILES.length} file(s) missing — ` +
490
- missing.map(f => f.to).join(', '));
710
+ const absentFromSource = missing.filter(f => !resolveSource(sourceRoot, f.from));
711
+ const notCopied = missing.filter(f => resolveSource(sourceRoot, f.from));
712
+ if (absentFromSource.length) {
713
+ warn(`${absentFromSource.length} file(s) are not part of this version: ` +
714
+ absentFromSource.map(f => f.to).join(', '));
715
+ }
716
+ if (notCopied.length) {
717
+ error(`Incomplete install: ${notCopied.length} of ${SKILL_FILES.length} file(s) missing — ` +
718
+ notCopied.map(f => f.to).join(', '));
719
+ }
491
720
  }
721
+ if (cleanup) cleanup();
492
722
 
493
- // Create package.json if not exists
723
+ // Create package.json if not exists. An installed copy is a skill, not a package: it carries
724
+ // no `files` allowlist and no bin, so `npm publish` run inside one would ship whatever happens
725
+ // to be in the directory — including a handoff store. `private` makes npm refuse there with
726
+ // EPRIVATE before it authenticates (verified); `npm pack` still packs the directory, so the
727
+ // flag is a guard against publishing, not a sandbox. The publishable manifest is the
728
+ // repository root's, which is the package users npx.
494
729
  const pkgPath = path.join(installPath, 'package.json');
495
730
  if (!fs.existsSync(pkgPath)) {
496
731
  fs.writeFileSync(pkgPath, JSON.stringify({
497
732
  name: SKILL_NAME,
498
733
  version: version === 'latest' ? SKILL_VERSION : version,
499
- description: 'Cross-harness session handoff engine',
734
+ description: 'Cross-harness session handoff engine (installed copy — not a publishable package; the npm package is agents-handoff)',
735
+ private: true,
500
736
  type: 'module'
501
737
  }, null, 2));
502
738
  }
@@ -504,8 +740,13 @@ async function install(location, customPath, version, force) {
504
740
  // The version reported is the one that landed, read back out of the installed SKILL.md —
505
741
  // not the requested string, and not a constant that a release has to remember to bump.
506
742
  const installedVersion = getInstalledVersion(installPath) || (version === 'latest' ? SKILL_VERSION : version);
743
+
744
+ // Record what was installed and what it was made from. `verify` re-hashes the same file set
745
+ // and compares, which is what makes an installation checkable rather than merely present.
746
+ const record = writeProvenance(installPath, { version: installedVersion, harness: target.harness, source: sourceInfo });
507
747
  success(`Installed ${SKILL_NAME} v${installedVersion} to ${installPath}`);
508
748
  log(`Copied ${copied} files`);
749
+ log(`Provenance: ${describeSource(sourceInfo)} · file-set sha256 ${record.files_sha256.slice(0, 16)}… (${PROVENANCE_FILE})`);
509
750
  // `config` is a real verb that exits 0 and prints the resolved root. `--help` is not a verb
510
751
  // (it exits 2), so the old hint told every user to run a failing command.
511
752
  log(`\nRun with: node ${path.join(installPath, 'tools', 'handoff.mjs')} config`);
@@ -513,20 +754,49 @@ async function install(location, customPath, version, force) {
513
754
  return { installed: true, path: installPath, version: installedVersion };
514
755
  }
515
756
 
516
- // Update skill
757
+ // Update skill.
758
+ //
759
+ // With a harness flag (`--update --claude`) the update is scoped to that target. With NO target
760
+ // flag it updates EVERY installation this machine has, which is the question the bare verb is
761
+ // actually asking: a machine that holds agents-handoff in ~/.claude/skills and in ~/.agents/skills
762
+ // has two copies, and updating only the resolved one leaves the other silently stale.
763
+ //
764
+ // An update copies the manifest and nothing else, so a store (projects/, handoffs/,
765
+ // .agent-handoff/) and handoff.config.json are never touched.
517
766
  async function update(location, customPath, version) {
518
- const installPath = resolveInstallPath(location, customPath);
519
-
520
- if (!isInstalled(installPath)) {
521
- error(`Not installed at ${installPath}. Run 'install' first.`);
767
+ if (MULTI_TARGET) return install(location, customPath, version, true);
768
+
769
+ const found = discoverInstalls();
770
+ if (!found.length) {
771
+ error('Nothing to update: no agents-handoff installation found.\n' +
772
+ ' Install one with: npx agents-handoff --all\n' +
773
+ ' Or name a target: --claude, --codex, --agents, --all, --skills-dir <dir>, --path <dir>');
522
774
  }
523
-
524
- const currentVersion = getInstalledVersion(installPath);
525
- log(`\nCurrent version: ${currentVersion}`);
526
- log(`Updating to: ${version}`);
527
-
528
- // For now, just reinstall
529
- return install(location, customPath, version, true);
775
+
776
+ log(`\n${C('bold', 'agents-handoff updater v' + INSTALLER_VERSION)}`);
777
+ log(`Updating ${found.length} installation(s) to ${version === 'latest' ? 'the latest version' : 'v' + version}`);
778
+ for (const f of found) log(` · ${f.label}: ${f.path}`);
779
+
780
+ const results = [];
781
+ for (const target of found) {
782
+ const before = getInstalledVersion(target.path);
783
+ const r = await installTarget(
784
+ { label: target.label, dir: target.dir, path: target.path },
785
+ version,
786
+ true,
787
+ );
788
+ results.push({ ...r, before });
789
+ }
790
+
791
+ log(`\n${C('bold', 'Update summary')}`);
792
+ for (const r of results) {
793
+ log(` ${r.skipped ? C('yellow', '–') + ' unchanged' : C('green', '✓') + ' updated'} ${r.path} — ${r.before} → v${r.version}`);
794
+ }
795
+ const changed = results.filter((r) => !r.skipped).length;
796
+ success(changed
797
+ ? `${changed} of ${results.length} installation(s) updated`
798
+ : `already current — ${results.length} installation(s) at v${results[0] && results[0].version}`);
799
+ return results;
530
800
  }
531
801
 
532
802
  // Remove skill
@@ -541,8 +811,9 @@ function remove(location, customPath, force) {
541
811
  if (!force) {
542
812
  log(`\nThis will remove ${SKILL_NAME} from:`);
543
813
  log(` ${installPath}`);
544
- log(`\nYour handoffs (projects/, handoffs/, links/) will NOT be deleted.`);
814
+ log(`\nYour handoffs and anything else you put in this directory will NOT be deleted.`);
545
815
  log(`Configuration (handoff.config.json) will NOT be deleted.`);
816
+ log(`Only what the install manifest owns is removed, and what is kept is listed at the end.`);
546
817
  log(`\nContinue? (y/N)`);
547
818
 
548
819
  // In non-interactive mode, default to no
@@ -574,45 +845,31 @@ function remove(location, customPath, force) {
574
845
  return doRemove(installPath, force);
575
846
  }
576
847
 
577
- function doRemove(installPath, force) {
578
- log(`\nRemoving from ${installPath}...`);
579
-
580
- // Keep handoffs, projects, links directories (user data)
581
- const keepDirs = ['handoffs', 'projects', 'links'];
582
- const keepFiles = ['handoff.config.json', '.env.example'];
583
-
584
- // Read directory contents
848
+ function doRemove(installPath, force) { log(`\nRemoving from ${installPath}...`);
849
+
850
+ // WHAT AN INSTALL OWNS IS THE MANIFEST. Everything else in the directory belongs to the user,
851
+ // so the rule below is a removal set, not a keep list: a keep list removes whatever nobody
852
+ // remembered to name, which is how a store called anything other than the three expected
853
+ // directories would have been deleted by `remove --force`. Store directories, notes, config
854
+ // and anything a future version writes as user data therefore survive by default.
855
+ const owned = new Set(SKILL_FILES.map((f) => f.to.split('/')[0]));
856
+ const KEPT_FILES = new Set(['handoff.config.json', '.env.example']);
857
+
585
858
  let entries;
586
859
  try {
587
860
  entries = fs.readdirSync(installPath, { withFileTypes: true });
588
861
  } catch {
589
862
  entries = [];
590
863
  }
591
-
864
+
865
+ const kept = [];
592
866
  for (const entry of entries) {
593
867
  const entryPath = path.join(installPath, entry.name);
594
-
595
- // Skip user data directories
596
- if (entry.isDirectory() && keepDirs.includes(entry.name)) {
597
- if (force) {
598
- // Check if empty
599
- try {
600
- const contents = fs.readdirSync(entryPath);
601
- if (contents.length === 0) {
602
- fs.rmdirSync(entryPath);
603
- log(`Removed empty directory: ${entry.name}/`);
604
- }
605
- } catch {}
606
- }
607
- continue;
608
- }
609
-
610
- // Skip kept files
611
- if (!entry.isDirectory() && keepFiles.includes(entry.name)) {
868
+ const isOwned = owned.has(entry.name) && !KEPT_FILES.has(entry.name);
869
+ if (!isOwned) {
870
+ kept.push(entry.name + (entry.isDirectory() ? '/' : ''));
612
871
  continue;
613
872
  }
614
-
615
- // Remove everything else
616
873
  if (entry.isDirectory()) {
617
874
  fs.rmSync(entryPath, { recursive: true, force: true });
618
875
  log(`Removed directory: ${entry.name}/`);
@@ -621,6 +878,33 @@ function doRemove(installPath, force) {
621
878
  log(`Removed file: ${entry.name}`);
622
879
  }
623
880
  }
881
+
882
+ // The installer's own record, and the private package.json stub it writes. The stub is only
883
+ // removed when it is still the stub — a package.json a user has edited is theirs.
884
+ try {
885
+ fs.unlinkSync(path.join(installPath, PROVENANCE_FILE));
886
+ log(`Removed file: ${PROVENANCE_FILE}`);
887
+ } catch { /* none written */ }
888
+ let stubRemoved = false;
889
+ try {
890
+ const pkg = JSON.parse(fs.readFileSync(path.join(installPath, 'package.json'), 'utf8'));
891
+ if (pkg.private === true && pkg.name === SKILL_NAME) {
892
+ fs.unlinkSync(path.join(installPath, 'package.json'));
893
+ stubRemoved = true;
894
+ log('Removed file: package.json (installer stub)');
895
+ } else {
896
+ kept.push('package.json (yours — kept)');
897
+ }
898
+ } catch { /* no package.json, or not JSON: leave it */ }
899
+
900
+ const survivors = kept
901
+ .filter((k) => k !== PROVENANCE_FILE && !(k === 'package.json' && stubRemoved))
902
+ .sort();
903
+ if (survivors.length) {
904
+ log(`\nKept — not the installer's to delete:`);
905
+ for (const k of survivors) log(` ${k}`);
906
+ }
907
+
624
908
 
625
909
  // Try to remove install directory if empty
626
910
  try {
@@ -634,13 +918,206 @@ function doRemove(installPath, force) {
634
918
  return { removed: true, path: installPath };
635
919
  }
636
920
 
637
- // Verify installation
921
+ // Verify every target this run selected. One harness behaves exactly as before; several are
922
+ // verified in turn, and the run fails if any of them fails. With no target flag, every
923
+ // installation found on the machine is verified — the same list `update` acts on, so
924
+ // "update everything" and "verify everything" cannot disagree about what exists.
638
925
  function verify(location, customPath) {
639
- const installPath = resolveInstallPath(location, customPath);
926
+ const targets = MULTI_TARGET
927
+ ? resolveTargets()
928
+ : discoverInstalls().map((t) => ({ label: t.label, dir: t.dir, path: t.path }));
929
+ if (!targets.length) {
930
+ error('Nothing to verify: no agents-handoff installation found.\n' +
931
+ ' Install one with: npx agents-handoff --all');
932
+ }
933
+ if (targets.length > 1) log(`\n${C('bold', 'Verifying ' + targets.length + ' installation(s)')}`);
934
+ let ok = true;
935
+ for (const target of targets) {
936
+ const r = verifyOne(target, PROVENANCE);
937
+ if (!r.valid) ok = false;
938
+ }
939
+ if (!ok) error('Verification failed. Try reinstalling.');
940
+ return { valid: true };
941
+ }
942
+
943
+ // ----------------------------------------------------------- verify against the package
944
+ // The strongest check this installer can make: does the copy on disk match the tarball npm is
945
+ // actually serving for its version? Everything else compares an installation with itself, or
946
+ // with the tree it came from. This compares it with the published artifact, and it works
947
+ // whichever way the install happened — tree, GitHub archive, or `npx`.
948
+ //
949
+ // Three things are checked, in this order, because each one makes the next meaningful:
950
+ // 1. the tarball we downloaded matches the hashes the REGISTRY declares for that version
951
+ // (otherwise "the published package" is whatever this network handed us)
952
+ // 2. the tarball's manifest file set hashes to the same value as the installed file set
953
+ // (per-file differences are named)
954
+ // 3. if the install record already holds a package sha256, it matches too
955
+ async function registryMeta(version) {
956
+ if (typeof fetch === 'undefined') return null;
957
+ try {
958
+ const res = await fetch(`${REGISTRY}/${NPM_PACKAGE}/${encodeURIComponent(version)}`, {
959
+ headers: { accept: 'application/json', 'user-agent': NPM_PACKAGE },
960
+ });
961
+ if (!res.ok) return null;
962
+ const j = await res.json();
963
+ return {
964
+ tarball: (j.dist && j.dist.tarball) || null,
965
+ integrity: (j.dist && j.dist.integrity) || null,
966
+ shasum: (j.dist && j.dist.shasum) || null,
967
+ };
968
+ } catch { return null; }
969
+ }
970
+
971
+ function recordPackageVerification(installPath, actual, meta) {
972
+ const prov = readProvenance(installPath);
973
+ if (!prov) {
974
+ warn('no install record to update — reinstall to create one');
975
+ return false;
976
+ }
977
+ prov.package = {
978
+ ...packageRecord(installPath),
979
+ ...(prov.package || {}),
980
+ tarball: meta.tarball,
981
+ integrity: meta.integrity || null,
982
+ shasum: meta.shasum || null,
983
+ sha256: actual.sha256,
984
+ sha512: actual.sha512,
985
+ verified_at: new Date().toISOString(),
986
+ };
987
+ fs.writeFileSync(path.join(installPath, PROVENANCE_FILE), JSON.stringify(prov, null, 2) + '\n');
988
+ return true;
989
+ }
990
+
991
+ async function verifyPackageOne(target) {
992
+ const installPath = target.path;
993
+ const version = getInstalledVersion(installPath);
994
+ console.log(`\n${C('bold', 'Verifying ' + NPM_PACKAGE + '@' + (version || '?') + ' against the published tarball')}`);
995
+ console.log(`Install: ${installPath}`);
996
+ if (!isInstalled(installPath)) {
997
+ console.error(` ${C('red', '✗')} not installed at ${installPath}`);
998
+ return false;
999
+ }
1000
+ if (!version) {
1001
+ console.error(` ${C('red', '✗')} SKILL.md carries no version — nothing to look up`);
1002
+ return false;
1003
+ }
1004
+
1005
+ const meta = await registryMeta(version);
1006
+ if (!meta || !meta.tarball) {
1007
+ console.error(` ${C('red', '✗')} ${NPM_PACKAGE}@${version} is not on the npm registry, or the registry is unreachable`);
1008
+ console.error(' the installation itself is untouched by this result — this check needs the network');
1009
+ return false;
1010
+ }
1011
+
1012
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-handoff-npm-'));
1013
+ try {
1014
+ const tgz = path.join(tmp, 'package.tgz');
1015
+ await downloadFile(meta.tarball, tgz);
1016
+ const bytes = fs.readFileSync(tgz);
1017
+ const actual = {
1018
+ sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
1019
+ sha512: crypto.createHash('sha512').update(bytes).digest('hex'),
1020
+ sha1: crypto.createHash('sha1').update(bytes).digest('hex'),
1021
+ };
1022
+
1023
+ const checks = [];
1024
+ if (meta.integrity) {
1025
+ checks.push({
1026
+ name: 'tarball matches the registry integrity (' + meta.integrity.slice(0, 20) + '…)',
1027
+ passed: meta.integrity === 'sha512-' + Buffer.from(actual.sha512, 'hex').toString('base64'),
1028
+ });
1029
+ }
1030
+ if (meta.shasum) {
1031
+ checks.push({
1032
+ name: 'tarball matches the registry shasum (' + meta.shasum.slice(0, 12) + '…)',
1033
+ passed: meta.shasum === actual.sha1,
1034
+ });
1035
+ }
1036
+
1037
+ const unpack = path.join(tmp, 'unpack');
1038
+ fs.mkdirSync(unpack, { recursive: true });
1039
+ const tar = spawnSync('tar', ['-xzf', 'package.tgz', '-C', 'unpack', '--strip-components=1'],
1040
+ { cwd: tmp, encoding: 'utf8' });
1041
+ if (tar.status !== 0) {
1042
+ console.error(` ${C('red', '✗')} cannot extract the published tarball (tar exited ${tar.status})`);
1043
+ return false;
1044
+ }
1045
+
1046
+ const differing = [];
1047
+ for (const rel of SKILL_FILES.map((f) => f.to).sort()) {
1048
+ const inPackage = path.join(unpack, ...rel.split('/'));
1049
+ const installed = path.join(installPath, ...rel.split('/'));
1050
+ const hPackage = fs.existsSync(inPackage) ? fileSha256(inPackage) : 'missing';
1051
+ const hInstalled = fs.existsSync(installed) ? fileSha256(installed) : 'missing';
1052
+ if (hPackage !== hInstalled) {
1053
+ differing.push(rel + ' (' + (hInstalled === 'missing' ? 'not installed'
1054
+ : hPackage === 'missing' ? 'not in the published package' : 'content differs') + ')');
1055
+ }
1056
+ }
1057
+ checks.push({
1058
+ name: `all ${SKILL_FILES.length} manifest file(s) are identical to the published tarball`,
1059
+ passed: differing.length === 0,
1060
+ });
1061
+ for (const d of differing.slice(0, 12)) console.error(` ${d}`);
1062
+ if (differing.length > 12) console.error(` … ${differing.length - 12} more`);
1063
+
1064
+ const prov = readProvenance(installPath);
1065
+ const recorded = prov && prov.package ? prov.package : null;
1066
+ if (recorded && recorded.sha256) {
1067
+ checks.push({
1068
+ name: 'tarball sha256 matches the hash recorded for this install',
1069
+ passed: recorded.sha256 === actual.sha256,
1070
+ });
1071
+ } else {
1072
+ log(` ${C('yellow', '·')} no package sha256 recorded for this install${RECORD ? '' : ' — pass --record to store it'}`);
1073
+ }
1074
+
1075
+ for (const c of checks) log(` ${c.passed ? C('green', '✓') : C('red', '✗')} ${c.name}`);
1076
+ log(` package: ${NPM_PACKAGE}@${version}`);
1077
+ log(` tarball: ${meta.tarball}`);
1078
+ log(` sha256: ${actual.sha256}`);
1079
+
1080
+ if (RECORD) {
1081
+ if (recordPackageVerification(installPath, actual, meta)) {
1082
+ log(` recorded in ${PROVENANCE_FILE} (package.sha256 / sha512 / integrity)`);
1083
+ }
1084
+ }
1085
+
1086
+ const ok = checks.every((c) => c.passed);
1087
+ console.log();
1088
+ if (ok) {
1089
+ success(`Installation matches the published package`);
1090
+ return true;
1091
+ }
1092
+ console.error(`The installation does NOT match agents-handoff@${version} as published`);
1093
+ return false;
1094
+ } finally {
1095
+ fs.rmSync(tmp, { recursive: true, force: true });
1096
+ }
1097
+ }
1098
+
1099
+ async function verifyPackage() {
1100
+ const targets = MULTI_TARGET
1101
+ ? resolveTargets()
1102
+ : discoverInstalls().map((t) => ({ label: t.label, dir: t.dir, path: t.path }));
1103
+ if (!targets.length) {
1104
+ error('Nothing to verify: no agents-handoff installation found.\n' +
1105
+ ' Install one with: npx agents-handoff --all');
1106
+ }
1107
+ let ok = true;
1108
+ for (const target of targets) {
1109
+ if (!(await verifyPackageOne(target))) ok = false;
1110
+ }
1111
+ if (!ok) error('Package verification failed.');
1112
+ return { valid: true };
1113
+ }
1114
+
1115
+ function verifyOne(target, showProvenance) {
1116
+ const installPath = target.path;
640
1117
 
641
- console.log(`\n${C('bold', 'Verifying agent-handoff installation...')}`);
1118
+ console.log(`\n${C('bold', 'Verifying agents-handoff installation...')}`);
642
1119
  console.log(`Location: ${installPath}`);
643
- if (!customPath && location === 'global') console.log(`Global root: ${resolveGlobalRoot().root}`);
1120
+ if (!MULTI_TARGET && !CUSTOM_PATH && LOCATION === 'global') console.log(`Global root: ${resolveGlobalRoot().root}`);
644
1121
  console.log();
645
1122
 
646
1123
  if (!isInstalled(installPath)) {
@@ -649,13 +1126,22 @@ function verify(location, customPath) {
649
1126
 
650
1127
  const checks = [];
651
1128
  let allPassed = true;
652
-
653
- // Check required files
654
- const requiredFiles = SKILL_FILES.map(f => f.to);
655
-
656
- for (const file of requiredFiles) {
657
- const filePath = path.join(installPath, file);
658
- const exists = fs.existsSync(filePath);
1129
+
1130
+ // What this installation is supposed to contain: the manifest, minus the entries its own
1131
+ // install record marks absent because that version never had them. Pinning an older version
1132
+ // is legitimate, so an expected absence is reported as such rather than failed — the same
1133
+ // distinction the install path makes.
1134
+ const prov = checkProvenance(installPath);
1135
+ const expectedAbsent = new Set(prov.present
1136
+ ? Object.entries(prov.record.files || {}).filter(([, v]) => v === 'missing').map(([k]) => k)
1137
+ : []);
1138
+
1139
+ for (const file of SKILL_FILES.map(f => f.to)) {
1140
+ if (expectedAbsent.has(file)) {
1141
+ checks.push({ name: `File: ${file} (not part of v${prov.record.version})`, passed: true });
1142
+ continue;
1143
+ }
1144
+ const exists = fs.existsSync(path.join(installPath, file));
659
1145
  checks.push({ name: `File: ${file}`, passed: exists });
660
1146
  if (!exists) allPassed = false;
661
1147
  }
@@ -696,20 +1182,40 @@ function verify(location, customPath) {
696
1182
  log(` ${check.passed ? C('green', '✓') : C('red', '✗')} ${check.name}`);
697
1183
  }
698
1184
 
1185
+ // Provenance: compare the installation with the record written when it was installed. An
1186
+ // installation without a record is NOT a failure — it predates this feature — so that case is
1187
+ // reported and skipped, never silently passed.
1188
+ if (prov.present) {
1189
+ checks.push({ name: 'provenance: file-set sha256 matches the install record', passed: prov.match });
1190
+ if (!prov.match) {
1191
+ allPassed = false;
1192
+ for (const c of prov.changed) console.error(` ${c}`);
1193
+ }
1194
+ if (showProvenance) {
1195
+ log(` provenance record`);
1196
+ log(` installed_at: ${prov.record.installed_at}`);
1197
+ log(` source: ${describeSource(prov.record.source)}`);
1198
+ log(` harness: ${prov.record.harness || '(resolved target)'}`);
1199
+ log(` files: ${prov.record.file_count} · file-set sha256 ${prov.record.files_sha256}`);
1200
+ }
1201
+ } else {
1202
+ log(` ${C('yellow', '·')} no provenance record — installed before 2.0.3; reinstall to record one`);
1203
+ }
1204
+
699
1205
  console.log();
700
1206
  if (allPassed) {
701
1207
  success(`Installation verified ✓`);
702
1208
  log(`Location: ${installPath}`);
703
1209
  log(`Version: ${getInstalledVersion(installPath) || 'unknown'}`);
704
- return { valid: true };
705
- } else {
706
- error(`Verification failed. Try reinstalling.`);
1210
+ return { valid: true, path: installPath };
707
1211
  }
1212
+ console.error(`Verification failed for ${installPath}`);
1213
+ return { valid: false, path: installPath };
708
1214
  }
709
1215
 
710
1216
  // List installations
711
1217
  function listInstallations() {
712
- console.log(`\n${C('bold', 'Installed agent-handoff locations')}\n`);
1218
+ console.log(`\n${C('bold', 'Installed agents-handoff locations')}\n`);
713
1219
 
714
1220
  const g = resolveGlobalRoot();
715
1221
  const locations = [
@@ -717,6 +1223,12 @@ function listInstallations() {
717
1223
  { name: 'Global (resolved — ' + g.why + ')', root: g.root },
718
1224
  { name: 'Local (cwd/local/skills)', root: PATHS.local },
719
1225
  ];
1226
+ // Every harness this installer knows about, present on this machine or not, so a missing
1227
+ // install is visible as "not there" instead of invisible.
1228
+ for (const name of Object.keys(HARNESSES)) {
1229
+ locations.push({ name: `Harness ${name}${harnessDetected(name) ? '' : ' (not detected)'}`, root: HARNESSES[name].user });
1230
+ locations.push({ name: `Harness ${name} (project form)`, root: HARNESSES[name].project });
1231
+ }
720
1232
  // Every other candidate root is listed too, so a second account or a stale
721
1233
  // ~/.agents/skills copy is VISIBLE rather than silently ignored.
722
1234
  for (const d of accountSkillRoots()) locations.push({ name: 'account-skill candidate', root: d });
@@ -728,17 +1240,30 @@ function listInstallations() {
728
1240
  let foundAny = false;
729
1241
  const seen = new Set();
730
1242
 
1243
+ const candidates = [];
731
1244
  for (const loc of locations) {
732
- const skillPath = path.join(loc.root, SKILL_NAME);
1245
+ candidates.push({ name: loc.name, skillPath: path.join(loc.root, SKILL_NAME) });
1246
+ // A copy installed before the rename is listed too — under the name it actually has, so a
1247
+ // stale `agent-handoff/` is visible rather than silently absent from `list`.
1248
+ candidates.push({ name: loc.name + ' (pre-2.0.3 name)', skillPath: path.join(loc.root, LEGACY_SKILL_NAME) });
1249
+ }
1250
+
1251
+ for (const cand of candidates) {
1252
+ const skillPath = cand.skillPath;
733
1253
  if (seen.has(skillPath)) continue;
734
1254
  seen.add(skillPath);
735
1255
  if (isInstalled(skillPath)) {
1256
+ const loc = { name: cand.name };
736
1257
  foundAny = true;
737
1258
  const version = getInstalledVersion(skillPath) || 'unknown';
738
1259
  log(`${C('green', '✓')} ${loc.name}`);
739
1260
  log(` Path: ${skillPath}`);
740
1261
  log(` Version: ${version}`);
741
1262
  log(` Files from the manifest: ${SKILL_FILES.filter(f => fs.existsSync(path.join(skillPath, f.to))).length}/${SKILL_FILES.length}`);
1263
+ const prov = checkProvenance(skillPath);
1264
+ log(` Provenance: ${prov.present
1265
+ ? (prov.match ? 'matches the install record' : 'MISMATCH — ' + prov.changed.join(', '))
1266
+ : 'none recorded'}`);
742
1267
  log();
743
1268
  }
744
1269
  }
@@ -761,6 +1286,48 @@ function printGlobalResolution() {
761
1286
  return g;
762
1287
  }
763
1288
 
1289
+ // `doctor` answers the whole question in one shot: which harnesses exist here, what is
1290
+ // installed where, whether each installation still matches the record written when it was
1291
+ // installed, and where the engine will keep handoffs. It reads and reports; it never installs.
1292
+ function doctor() {
1293
+ console.log(`\n${C('bold', 'agents-handoff doctor')}`);
1294
+ console.log(` product v${SKILL_VERSION} · installer v${INSTALLER_VERSION} · node ${process.version}`);
1295
+ console.log(`\n${C('bold', 'Harnesses')}`);
1296
+ let installed = 0;
1297
+ for (const name of Object.keys(HARNESSES)) {
1298
+ const dir = HARNESSES[name].user;
1299
+ const target = path.join(dir, SKILL_NAME);
1300
+ const present = harnessDetected(name);
1301
+ const here = isInstalled(target);
1302
+ if (here) installed += 1;
1303
+ console.log(` ${present ? C('green', '✓') : C('yellow', '·')} ${name.padEnd(7)} ${present ? 'present' : 'not found'} ${dir}`);
1304
+ if (!here) continue;
1305
+ const prov = checkProvenance(target);
1306
+ const files = SKILL_FILES.filter(f => fs.existsSync(path.join(target, f.to))).length;
1307
+ console.log(` v${getInstalledVersion(target) || '?'} · ${files}/${SKILL_FILES.length} files · ` +
1308
+ (prov.present
1309
+ ? (prov.match ? 'provenance OK' : C('red', 'PROVENANCE MISMATCH') + ' (' + prov.changed.length + ' file(s))')
1310
+ : 'no provenance record'));
1311
+ }
1312
+ const g = resolveGlobalRoot();
1313
+ console.log(`\n${C('bold', 'Global resolution')}`);
1314
+ console.log(` ${g.root} — ${g.why}`);
1315
+ for (const other of g.others || []) console.log(` also holds an install: ${other}`);
1316
+ console.log(`\n${C('bold', 'Store (where handoffs are written)')}`);
1317
+ if (haveSourceTree()) {
1318
+ const r = spawnSync(process.execPath, [path.join(SOURCE_DIR, 'tools', 'handoff.mjs'), 'config'], { encoding: 'utf8' });
1319
+ for (const line of String(r.stdout || '').trim().split('\n')) console.log(' ' + line);
1320
+ } else {
1321
+ console.log(` ask an installed engine: node <install>${path.sep}tools${path.sep}handoff.mjs config`);
1322
+ }
1323
+ console.log(`\n${C('bold', 'Verdict')}`);
1324
+ console.log(installed
1325
+ ? ` ${installed} harness installation(s) found.`
1326
+ : ' nothing installed yet — run: npx agents-handoff --all');
1327
+ log('');
1328
+ return installed;
1329
+ }
1330
+
764
1331
  // Main
765
1332
  async function main() {
766
1333
  try {
@@ -784,7 +1351,12 @@ async function main() {
784
1351
  case 'v':
785
1352
  await verify(LOCATION, CUSTOM_PATH);
786
1353
  break;
787
-
1354
+
1355
+ case 'verify-package':
1356
+ case 'vp':
1357
+ await verifyPackage();
1358
+ break;
1359
+
788
1360
  case 'list':
789
1361
  case 'ls':
790
1362
  listInstallations();
@@ -793,6 +1365,11 @@ async function main() {
793
1365
  case 'where':
794
1366
  printGlobalResolution();
795
1367
  break;
1368
+
1369
+ case 'doctor':
1370
+ case 'doc':
1371
+ doctor();
1372
+ break;
796
1373
 
797
1374
  case '--help':
798
1375
  case '-h':
@@ -804,49 +1381,71 @@ async function main() {
804
1381
  error(`Unknown command: ${COMMAND}. Use install, update, remove, verify, or list.`);
805
1382
  }
806
1383
  } catch (e) {
1384
+ // An unexpected failure prints its stack when AGENTS_HANDOFF_DEBUG is set, because a bare
1385
+ // message is not enough to repair one; the message alone stays the default so the normal
1386
+ // output stays readable.
1387
+ if (process.env.AGENTS_HANDOFF_DEBUG) console.error(e.stack || e);
807
1388
  error(`Error: ${e.message}`);
808
1389
  }
809
1390
  }
810
1391
 
811
1392
  function showHelp() {
812
1393
  console.log(`
813
- ${C('bold', 'agents-handoff')} - Install assistant for agent-handoff skill
1394
+ ${C('bold', 'agents-handoff')} - Install assistant for agents-handoff skill
814
1395
 
815
1396
  ${C('bold', 'Usage:')}
816
1397
  npx agents-handoff <command> [options]
817
1398
 
818
1399
  ${C('bold', 'Commands:')}
819
- install, i Install the skill (default)
820
- update, u Update to latest or specified version
821
- remove, rm Remove the installation
822
- verify, v Verify installation integrity
823
- list, ls List all installed locations
824
- where Show the resolved global root and why it was chosen
1400
+ install, i Install the skill (default)
1401
+ update, u Update to the latest (or a named) version. With no harness flag it
1402
+ updates EVERY installation found on this machine
1403
+ remove, rm Remove the installation; your store and config are never deleted
1404
+ verify, v Verify integrity against the install record. With no harness flag it
1405
+ verifies every installation found
1406
+ verify-package, vp Verify the installation against the tarball npm actually serves,
1407
+ including the registry's own hashes
1408
+ list, ls List all installed locations
1409
+ where Show the resolved global root and why it was chosen
1410
+ doctor Which harnesses exist here, what is installed, and whether it still matches
1411
+
1412
+ ${C('bold', 'Harness targets (install into the one you use, or several at once):')}
1413
+ --claude Claude Code ~/.claude/skills
1414
+ --codex Codex CLI ~/.codex/skills
1415
+ --agents neutral store ~/.agents/skills
1416
+ --harness H named harness(es), comma separated; repeatable
1417
+ --all every harness whose directory exists on this machine
1418
+ --skills-dir D any other stack, exactly; repeatable
1419
+ --project use the per-repository form (./.claude/skills, ./.codex/skills)
825
1420
 
826
1421
  ${C('bold', 'Options:')}
827
1422
  --location L Install location: global (default), local, project
828
1423
  --path P Custom installation path
829
1424
  --version V Version to install: latest (default) or specific version
830
1425
  --force, -f Skip confirmations, overwrite existing
1426
+ --provenance With verify: print the install record it was checked against
1427
+ --record With verify-package: store the tarball hashes in the install record
831
1428
 
832
1429
  ${C('bold', 'Examples:')}
833
- npx agents-handoff # Install to global
834
- npx agents-handoff --location project # Install to project
835
- npx agents-handoff --update # Update to latest
836
- npx agents-handoff --remove --force # Remove without asking
837
- npx agents-handoff --verify # Check installation
838
- npx agents-handoff --list # Show all installations
1430
+ npx agents-handoff --all # every harness found here, one run
1431
+ npx agents-handoff --claude --codex # exactly these two
1432
+ npx agents-handoff --project --claude # this repository, for Claude Code
1433
+ npx agents-handoff --skills-dir ~/.config/mytool/skills
1434
+ npx agents-handoff --doctor # what is here, and is it intact
1435
+ npx agents-handoff --verify --provenance
1436
+ npx agents-handoff --verify-package --record # prove the install matches the npm tarball
1437
+ npx agents-handoff --update # every installation on this machine, one run
839
1438
 
840
1439
  ${C('bold', 'Locations:')}
841
1440
  global resolved, not hard-coded: an account-skill root that already holds
842
- agent-handoff, else ~/.agents/skills, else any account-skill store found on this
1441
+ agents-handoff, else ~/.agents/skills, else any account-skill store found on this
843
1442
  machine, else ~/.agents/skills (created on install). A store holds the skill two
844
- id levels below it — <store>/<account-id>/<profile-id>/agent-handoff/ — so the
1443
+ id levels below it — <store>/<account-id>/<profile-id>/agents-handoff/ — so the
845
1444
  store itself is not an install target.
846
1445
  Override with AGENT_HANDOFF_GLOBAL_DIR, or target an exact path with --path.
847
1446
  See it resolved: npx agents-handoff where
848
- local ./local/skills/agent-handoff
849
- project ./skills/agent-handoff (only if in a git repo)
1447
+ local ./local/skills/agents-handoff
1448
+ project ./skills/agents-handoff (only if in a git repo)
850
1449
 
851
1450
  ${C('bold', 'Repository:')}
852
1451
  https://github.com/${REPO_OWNER}/${REPO_NAME}