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.
- package/CHANGELOG.md +77 -6
- package/README.md +59 -12
- package/SKILL.md +12 -12
- package/capability-registry.json +1 -1
- package/docs/ARCHITECTURE.md +28 -5
- package/docs/CHANGELOG.md +91 -17
- package/docs/CLI.md +115 -12
- package/docs/COMPATIBILITY.md +24 -0
- package/docs/CONTRIBUTING.md +2 -2
- package/docs/FORMAT.md +28 -0
- package/docs/INSTALL.md +239 -24
- package/docs/INTEGRATION.md +4 -4
- package/docs/LEVEL4.md +10 -10
- package/docs/LEVEL5.md +1 -1
- package/docs/PERMISSIONS.md +2 -2
- package/docs/PROVENANCE.md +27 -0
- package/docs/SECURITY.md +1 -1
- package/docs/SESSIONS.md +31 -0
- package/docs/UNINSTALL.md +47 -21
- package/docs/UPGRADE.md +59 -21
- package/docs/_config.yml +3 -1
- package/docs/index.md +17 -6
- package/docs/sessions.json +34 -0
- package/install/CHANGELOG.md +1 -1
- package/install/README.md +7 -7
- package/install/install.mjs +746 -147
- package/install/package.json +2 -2
- package/package.json +2 -2
- package/permission-policy.json +1 -1
- package/refs/protocol.md +1 -1
- package/schemas/handoff.schema.json +1 -1
- package/skill.json +11 -11
- package/tests/acceptance/acceptance.yaml +2 -2
- package/tools/agent-handoff.mjs +16 -404
- package/tools/agents-handoff.mjs +410 -0
- package/tools/capability-registry.mjs +2 -2
- package/tools/handoff.test.mjs +203 -0
- package/tools/lib/handoff-root.mjs +1 -1
- package/tools/runtime-engine.mjs +2 -2
package/install/install.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// agents-handoff - npx installer for the
|
|
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 = '
|
|
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.
|
|
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>/
|
|
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
|
-
? [
|
|
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
|
|
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
|
|
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', '--
|
|
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(), '
|
|
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, '
|
|
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', '
|
|
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
|
|
308
|
-
` mkdir -p
|
|
309
|
-
` node
|
|
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 {
|
|
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
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
//
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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', '
|
|
427
|
-
log(`Target: ${installPath}`);
|
|
428
|
-
if (!
|
|
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
|
-
|
|
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
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
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
|
-
|
|
490
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
525
|
-
log(
|
|
526
|
-
log(`
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
//
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
596
|
-
|
|
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
|
|
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
|
|
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
|
|
1118
|
+
console.log(`\n${C('bold', 'Verifying agents-handoff installation...')}`);
|
|
642
1119
|
console.log(`Location: ${installPath}`);
|
|
643
|
-
if (!
|
|
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
|
-
//
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
820
|
-
update, u
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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
|
|
834
|
-
npx agents-handoff --
|
|
835
|
-
npx agents-handoff --
|
|
836
|
-
npx agents-handoff --
|
|
837
|
-
npx agents-handoff --
|
|
838
|
-
npx agents-handoff --
|
|
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
|
-
|
|
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>/
|
|
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/
|
|
849
|
-
project ./skills/
|
|
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}
|