agents-handoff 0.0.0-stage → 2.0.2

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/LICENSE +21 -0
  3. package/README.md +110 -2
  4. package/SKILL.md +147 -0
  5. package/capability-registry.json +27 -0
  6. package/docs/ARCHITECTURE.md +164 -0
  7. package/docs/CHANGELOG.md +151 -0
  8. package/docs/CLI.md +196 -0
  9. package/docs/COMPATIBILITY.md +124 -0
  10. package/docs/CONTRIBUTING.md +134 -0
  11. package/docs/FORMAT.md +157 -0
  12. package/docs/INSTALL.md +179 -0
  13. package/docs/INTEGRATION.md +188 -0
  14. package/docs/LEVEL4.md +202 -0
  15. package/docs/LEVEL5.md +96 -0
  16. package/docs/PERMISSIONS.md +145 -0
  17. package/docs/PROVENANCE.md +83 -0
  18. package/docs/SECURITY.md +93 -0
  19. package/docs/SESSIONS.md +66 -0
  20. package/docs/TROUBLESHOOTING.md +158 -0
  21. package/docs/UNINSTALL.md +122 -0
  22. package/docs/UPGRADE.md +139 -0
  23. package/docs/_config.yml +16 -0
  24. package/docs/_data/nav.yml +36 -0
  25. package/docs/_layouts/default.html +31 -0
  26. package/docs/assets/style.css +88 -0
  27. package/docs/index.md +83 -0
  28. package/handoff.config.example.json +35 -0
  29. package/handoff.config.schema.json +117 -0
  30. package/install/CHANGELOG.md +48 -0
  31. package/install/README.md +76 -0
  32. package/install/install.mjs +856 -0
  33. package/install/package.json +39 -0
  34. package/package.json +66 -4
  35. package/permission-policy.json +33 -0
  36. package/refs/ADAPTERS.md +33 -0
  37. package/refs/bootstrap.md +59 -0
  38. package/refs/brief-checklist.md +79 -0
  39. package/refs/handbook.md +58 -0
  40. package/refs/protocol.md +117 -0
  41. package/refs/roles.md +75 -0
  42. package/refs/validator.md +73 -0
  43. package/schemas/handoff.schema.json +275 -0
  44. package/skill.json +147 -0
  45. package/templates/HANDOFF.llm.schema.json +144 -0
  46. package/templates/HANDOFF.template.md +40 -0
  47. package/tests/acceptance/acceptance.yaml +209 -0
  48. package/tests/fixtures/minimal-transcript.jsonl +2 -0
  49. package/tools/agent-handoff.mjs +410 -0
  50. package/tools/capability-registry.mjs +120 -0
  51. package/tools/handoff.mjs +398 -0
  52. package/tools/handoff.test.mjs +465 -0
  53. package/tools/lib/handoff-root.mjs +161 -0
  54. package/tools/runtime-engine.mjs +330 -0
@@ -0,0 +1,856 @@
1
+ #!/usr/bin/env node
2
+ // agents-handoff - npx installer for the agent-handoff skill
3
+ // Commands: install, update, remove, verify, list
4
+ // Zero external dependencies, pure Node.js
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import crypto from 'node:crypto';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { spawnSync } from 'node:child_process';
11
+ import readline from 'node:readline';
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ const INSTALLER_ROOT = path.resolve(__dirname);
15
+ // The tree the skill is read from when this installer runs inside a checkout or a release
16
+ // archive: the parent of install/. A published package has no parent tree, so this path is
17
+ // also the test that decides between copying files and fetching an archive.
18
+ const SOURCE_DIR = path.resolve(INSTALLER_ROOT, '..');
19
+ const SKILL_NAME = 'agent-handoff';
20
+ const REPO_OWNER = 'Alot1z';
21
+ const REPO_NAME = 'agent-handoff';
22
+ const RELEASES_URL = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download`;
23
+ const TARBALL_URL = `https://codeload.github.com/${REPO_OWNER}/${REPO_NAME}/tar.gz`;
24
+ const API_LATEST = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`;
25
+
26
+ // THE VERSION THIS INSTALLER SHIPS. SKILL.md owns it: the same frontmatter that `verify` and
27
+ // `list` read back out of an installed copy. The literal below is only the fallback for the
28
+ // run that has no tree beside it — the published package fetching an archive — and the suite
29
+ // asserts it against SKILL.md, so a release that bumps one cannot leave the other behind.
30
+ const FALLBACK_SKILL_VERSION = '2.0.2';
31
+ function skillVersion() {
32
+ try {
33
+ const md = fs.readFileSync(path.join(SOURCE_DIR, 'SKILL.md'), 'utf8');
34
+ const m = md.match(/^version:\s*(\S+)/m);
35
+ if (m) return m[1];
36
+ } catch { /* no tree beside the installer */ }
37
+ return FALLBACK_SKILL_VERSION;
38
+ }
39
+ const SKILL_VERSION = skillVersion();
40
+
41
+ // The installer's own version, read from the package it ships in — the same file npm
42
+ // publishes, so the banner cannot lag a release.
43
+ function installerVersion() {
44
+ try {
45
+ return JSON.parse(fs.readFileSync(path.join(INSTALLER_ROOT, 'package.json'), 'utf8')).version;
46
+ } catch { return 'unknown'; }
47
+ }
48
+ const INSTALLER_VERSION = installerVersion();
49
+
50
+ // Locations
51
+ const HOME = process.env.USERPROFILE || process.env.HOME || '';
52
+
53
+ // Where a GLOBAL install can go. Desktop clients keep account skills in their own store with
54
+ // two opaque id levels between the store and the skill:
55
+ //
56
+ // <store>/<account-id>/<profile-id>/agent-handoff/
57
+ //
58
+ // No client is named here. A store is DISCOVERED by looking for `account-skills` directories
59
+ // under the platform's application-data roots, so a client that is absent from this machine
60
+ // simply contributes no candidate. These are candidates to search, never a decision:
61
+ // `resolveGlobalRoot` picks one and says why. `AGENT_HANDOFF_GLOBAL_DIR` overrides the
62
+ // search; `--path` bypasses it.
63
+ const DATA_ROOTS = (process.platform === 'win32'
64
+ ? [process.env.APPDATA, process.env.LOCALAPPDATA, path.join(HOME, 'AppData', 'Roaming')]
65
+ : [path.join(HOME, '.config'), path.join(HOME, '.local', 'share')]
66
+ ).filter(Boolean);
67
+ const AGENTS_SKILLS = path.join(HOME, '.agents', 'skills');
68
+
69
+ const PATHS = {
70
+ local: path.join(process.cwd(), 'local', 'skills'),
71
+ project: path.join(process.cwd(), 'skills'),
72
+ };
73
+
74
+ function listDirs(dir) {
75
+ try {
76
+ return fs.readdirSync(dir, { withFileTypes: true })
77
+ .filter(e => e.isDirectory())
78
+ .map(e => path.join(dir, e.name));
79
+ } catch { return []; }
80
+ }
81
+ const mtimeOf = p => { try { return fs.statSync(p).mtimeMs; } catch { return 0; } };
82
+
83
+ // Every `account-skills` store on this machine, whatever client created it.
84
+ function accountSkillStores() {
85
+ const out = [];
86
+ for (const dataRoot of DATA_ROOTS) {
87
+ for (const clientDir of listDirs(dataRoot)) {
88
+ const store = path.join(clientDir, 'account-skills');
89
+ try { if (fs.statSync(store).isDirectory()) out.push(store); } catch { /* no store there */ }
90
+ }
91
+ }
92
+ return [...new Set(out)];
93
+ }
94
+
95
+ // Every directory that could be the PARENT of a global agent-handoff/, from every account
96
+ // store this machine has.
97
+ function accountSkillRoots() {
98
+ const stores = accountSkillStores();
99
+ const out = [];
100
+ for (const store of stores) {
101
+ for (const account of listDirs(store)) {
102
+ out.push(account);
103
+ for (const profile of listDirs(account)) out.push(profile);
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+
109
+ // Resolution order, first hit wins:
110
+ // 1. AGENT_HANDOFF_GLOBAL_DIR — explicit operator override
111
+ // 2. an account-skill root that ALREADY holds agent-handoff (newest install first)
112
+ // 3. ~/.agents/skills — the harness-wide skills home
113
+ // 4. any account-skill root discovered above — keeps the install where a client reads
114
+ // 5. ~/.agents/skills — created on install when nothing exists
115
+ function resolveGlobalRoot() {
116
+ const override = process.env.AGENT_HANDOFF_GLOBAL_DIR;
117
+ if (override) return { root: path.resolve(override), why: 'AGENT_HANDOFF_GLOBAL_DIR' };
118
+
119
+ const roots = accountSkillRoots();
120
+ const installed = roots.filter(d => isInstalled(path.join(d, SKILL_NAME)));
121
+ installed.sort((a, b) => mtimeOf(path.join(b, SKILL_NAME)) - mtimeOf(path.join(a, SKILL_NAME)));
122
+ if (installed.length) {
123
+ return {
124
+ root: installed[0],
125
+ why: 'account-skill store, existing install (newest of ' + installed.length + ' found)',
126
+ others: installed.slice(1),
127
+ };
128
+ }
129
+ if (fs.existsSync(AGENTS_SKILLS)) return { root: AGENTS_SKILLS, why: '~/.agents/skills (harness skills home)' };
130
+ if (roots.length) return { root: roots[0], why: 'account-skill store (no install there yet)' };
131
+ return { root: AGENTS_SKILLS, why: 'harness skills home (created on install)' };
132
+ }
133
+
134
+ // Colors for output
135
+ const COLORS = {
136
+ reset: '\x1b[0m',
137
+ bold: '\x1b[1m',
138
+ green: '\x1b[32m',
139
+ yellow: '\x1b[33m',
140
+ red: '\x1b[31m',
141
+ cyan: '\x1b[36m'
142
+ };
143
+ const C = (color, text) => process.stdout.isTTY ? `${COLORS[color]}${text}${COLORS.reset}` : text;
144
+
145
+ // `log()` is called with no argument for a blank line all over this file, and
146
+ // console.log(undefined) prints the literal word "undefined" — so the default is the empty
147
+ // string, not the argument passed through.
148
+ function log(msg = '') { console.log(msg); }
149
+ function warn(msg) { console.warn(C('yellow', `⚠ ${msg}`)); }
150
+ function error(msg) { console.error(C('red', `✗ ${msg}`)); process.exit(1); }
151
+ function success(msg) { console.log(C('green', `✓ ${msg}`)); }
152
+
153
+ // Parse args
154
+ const args = process.argv.slice(2);
155
+ const getArg = (name, defaultVal = null) => {
156
+ const idx = args.indexOf(name);
157
+ return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : defaultVal;
158
+ };
159
+ const hasFlag = (name) => args.includes(name);
160
+
161
+ // Accept BOTH the bare verb form (`install`, `verify`) and the flag form the help text
162
+ // documents (`--verify`, `--list`, `--remove`, `--update`, `--help`). Previously args[0]
163
+ // had to be a bare verb, so every documented flag form exited 1 with "Unknown command".
164
+ const FLAG_COMMANDS = {
165
+ '--install': 'install', '--update': 'update', '--remove': 'remove',
166
+ '--verify': 'verify', '--list': 'list', '--help': 'help', '-h': 'help'
167
+ };
168
+ const COMMAND = FLAG_COMMANDS[args[0]] || (args[0] && !args[0].startsWith('-') ? args[0] : 'install');
169
+ const LOCATION = getArg('--location', 'global');
170
+ const CUSTOM_PATH = getArg('--path');
171
+ const VERSION = getArg('--version', 'latest');
172
+ const FORCE = hasFlag('--force') || hasFlag('-f');
173
+
174
+ // THE INSTALL MANIFEST. One list, used to copy AND to verify, so an installed copy
175
+ // cannot silently lose a file the runtime needs (the engine now imports
176
+ // tools/lib/handoff-root.mjs, and the previous copy list predated that, the runtime MVP
177
+ // and the schemas). `from` is relative to the tree the installer runs in; `to` is relative
178
+ // to the target. `from` is written in the PUBLIC tree's terms, because that is the tree the
179
+ // installer runs in once it ships — see resolveSource() for how the development tree, which
180
+ // keeps those same files under repo-upstream/, still resolves them.
181
+ const SKILL_FILES = [
182
+ { from: 'SKILL.md', to: 'SKILL.md' },
183
+ { from: 'README.md', to: 'README.md' },
184
+ { from: 'LICENSE', to: 'LICENSE' },
185
+ { from: 'skill.json', to: 'skill.json' },
186
+ { from: 'capability-registry.json', to: 'capability-registry.json' },
187
+ { from: 'permission-policy.json', to: 'permission-policy.json' },
188
+ { from: 'handoff.config.schema.json', to: 'handoff.config.schema.json' },
189
+ { from: 'handoff.config.example.json', to: 'handoff.config.example.json' },
190
+ { from: 'tools/handoff.mjs', to: 'tools/handoff.mjs' },
191
+ { from: 'tools/agent-handoff.mjs', to: 'tools/agent-handoff.mjs' },
192
+ { from: 'tools/handoff.test.mjs', to: 'tools/handoff.test.mjs' },
193
+ { from: 'tools/capability-registry.mjs', to: 'tools/capability-registry.mjs' },
194
+ { from: 'tools/runtime-engine.mjs', to: 'tools/runtime-engine.mjs' },
195
+ { from: 'tools/lib/handoff-root.mjs', to: 'tools/lib/handoff-root.mjs' },
196
+ { from: 'schemas/handoff.schema.json', to: 'schemas/handoff.schema.json' },
197
+ { from: 'tests/acceptance/acceptance.yaml', to: 'tests/acceptance/acceptance.yaml' },
198
+ { from: 'tests/fixtures/minimal-transcript.jsonl', to: 'tests/fixtures/minimal-transcript.jsonl' },
199
+ { from: 'docs/INTEGRATION.md', to: 'docs/INTEGRATION.md' },
200
+ { from: 'docs/LEVEL4.md', to: 'docs/LEVEL4.md' },
201
+ { from: 'docs/LEVEL5.md', to: 'docs/LEVEL5.md' },
202
+ { from: 'docs/FORMAT.md', to: 'docs/FORMAT.md' },
203
+ { from: 'docs/PERMISSIONS.md', to: 'docs/PERMISSIONS.md' },
204
+ { from: 'docs/CONTRIBUTING.md', to: 'docs/CONTRIBUTING.md' },
205
+ { from: 'docs/INSTALL.md', to: 'docs/INSTALL.md' },
206
+ { from: 'docs/UPGRADE.md', to: 'docs/UPGRADE.md' },
207
+ { from: 'docs/UNINSTALL.md', to: 'docs/UNINSTALL.md' },
208
+ { from: 'docs/SECURITY.md', to: 'docs/SECURITY.md' },
209
+ { from: 'docs/COMPATIBILITY.md', to: 'docs/COMPATIBILITY.md' },
210
+ { from: 'docs/PROVENANCE.md', to: 'docs/PROVENANCE.md' },
211
+ { from: 'refs/ADAPTERS.md', to: 'refs/ADAPTERS.md' },
212
+ { from: 'refs/handbook.md', to: 'refs/handbook.md' },
213
+ { from: 'refs/protocol.md', to: 'refs/protocol.md' },
214
+ { from: 'refs/roles.md', to: 'refs/roles.md' },
215
+ { from: 'refs/validator.md', to: 'refs/validator.md' },
216
+ { from: 'refs/brief-checklist.md', to: 'refs/brief-checklist.md' },
217
+ { from: 'refs/bootstrap.md', to: 'refs/bootstrap.md' },
218
+ { from: 'templates/HANDOFF.template.md', to: 'templates/HANDOFF.template.md' },
219
+ { from: 'templates/HANDOFF.llm.schema.json', to: 'templates/HANDOFF.llm.schema.json' },
220
+ ];
221
+
222
+ // Where a manifest `from` lives. The public shape is tried at its own path, and the
223
+ // development shape at repo-upstream/<from>. Development is tried FIRST: a bare `README.md`
224
+ // in the development tree is the internal one, while the file that ships is the one under
225
+ // repo-upstream/. A clone, a release archive and a fetched tarball have no repo-upstream/,
226
+ // so the same list resolves there at the public path — one manifest, both trees.
227
+ function resolveSource(root, from) {
228
+ for (const rel of [path.join('repo-upstream', from), from]) {
229
+ const abs = path.join(root, rel);
230
+ if (fs.existsSync(abs)) return abs;
231
+ }
232
+ return null;
233
+ }
234
+
235
+ // Is there a skill tree to copy from? Only a checkout or a release archive has one; the
236
+ // published package carries install/ alone, which is the case that must fetch instead.
237
+ function haveSourceTree() {
238
+ return fs.existsSync(path.join(SOURCE_DIR, 'tools', 'handoff.mjs'));
239
+ }
240
+
241
+ // The manifest is the copy list AND the completion criterion: a run that cannot resolve a
242
+ // source reports it, and install() fails the install when any target is missing. A file
243
+ // copied but not verified was how the installed engine once went missing its resolver.
244
+ function copyManifest(root, installPath) {
245
+ const unresolved = [];
246
+ let copied = 0;
247
+ for (const file of SKILL_FILES) {
248
+ const srcPath = resolveSource(root, file.from);
249
+ if (!srcPath) { unresolved.push(file.from); continue; }
250
+ const destPath = path.join(installPath, file.to);
251
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
252
+ fs.copyFileSync(srcPath, destPath);
253
+ copied++;
254
+ }
255
+ if (unresolved.length) warn('source file(s) not found: ' + unresolved.join(', '));
256
+ return copied;
257
+ }
258
+
259
+ // The newest published release tag, or null when the repository has none (or the API cannot
260
+ // be reached). Null is not an error here: it selects the main branch and says so.
261
+ async function latestTag() {
262
+ if (typeof fetch === 'undefined') return null;
263
+ try {
264
+ const res = await fetch(API_LATEST, {
265
+ headers: { accept: 'application/vnd.github+json', 'user-agent': 'agents-handoff' },
266
+ });
267
+ if (!res.ok) return null;
268
+ const j = await res.json();
269
+ return j.tag_name || null;
270
+ } catch { return null; }
271
+ }
272
+
273
+ // Fetch and unpack the archive for the requested version, into a throwaway directory the
274
+ // caller removes. Returns the unpacked root, so the same manifest copies from it.
275
+ async function fetchAndUnpack(version) {
276
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-handoff-fetch-'));
277
+ let ref, label;
278
+ if (version === 'latest') {
279
+ const tag = await latestTag();
280
+ ref = tag ? 'refs/tags/' + tag : 'refs/heads/main';
281
+ label = tag || 'main';
282
+ if (!tag) warn('no published release found — fetching the main branch instead');
283
+ } else {
284
+ ref = 'refs/tags/v' + version;
285
+ label = 'v' + version;
286
+ }
287
+ const url = `${TARBALL_URL}/${ref}`;
288
+ const tarball = path.join(tmp, 'agent-handoff.tar.gz');
289
+ log(`Fetching the ${label} archive...`);
290
+ log(` ${url}`);
291
+ await downloadFile(url, tarball);
292
+ const bytes = fs.statSync(tarball).size;
293
+ const hash = crypto.createHash('sha256').update(fs.readFileSync(tarball)).digest('hex');
294
+ log(` ${(bytes / 1024).toFixed(1)} kB, sha256 ${hash.slice(0, 16)}…`);
295
+ const unpack = path.join(tmp, 'unpack');
296
+ fs.mkdirSync(unpack, { recursive: true });
297
+ // Run tar from inside the temp directory with RELATIVE names. An absolute Windows path
298
+ // (`C:\Users\…`) is read by tar as a remote host — `tar (child): Cannot connect to C:
299
+ // resolve failed` — so `-xzf C:\…` fails on the very platform this installer targets first.
300
+ // 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'],
302
+ { cwd: tmp, encoding: 'utf8' });
303
+ if (tar.status !== 0) {
304
+ const why = String(tar.stderr || '').trim().split('\n')[0];
305
+ error(`Cannot extract the archive (tar exited ${tar.status}${why ? ': ' + why : ''}).\n` +
306
+ ` 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`);
310
+ }
311
+ return { root: unpack, cleanup: () => fs.rmSync(tmp, { recursive: true, force: true }) };
312
+ }
313
+
314
+ // Resolve install path
315
+ function resolveInstallPath(location, customPath) {
316
+ if (customPath) {
317
+ return path.resolve(customPath);
318
+ }
319
+
320
+ switch (location) {
321
+ case 'global':
322
+ return path.join(resolveGlobalRoot().root, SKILL_NAME);
323
+ case 'local':
324
+ return path.join(PATHS.local, SKILL_NAME);
325
+ case 'project':
326
+ return path.join(PATHS.project, SKILL_NAME);
327
+ default:
328
+ error(`Unknown location: ${location}. Use global, local, or project.`);
329
+ }
330
+ }
331
+
332
+ // Check if installed
333
+ function isInstalled(installPath) {
334
+ return fs.existsSync(path.join(installPath, 'SKILL.md'));
335
+ }
336
+
337
+ // Get installed version
338
+ function getInstalledVersion(installPath) {
339
+ try {
340
+ const skillMd = fs.readFileSync(path.join(installPath, 'SKILL.md'), 'utf8');
341
+ const match = skillMd.match(/^version:\s*(\S+)/m);
342
+ return match ? match[1] : null;
343
+ } catch {
344
+ return null;
345
+ }
346
+ }
347
+
348
+ // Download file from URL (using fetch if available, fallback to curl/wget)
349
+ async function downloadFile(url, destPath) {
350
+ // Try native fetch first (Node 18+)
351
+ if (typeof fetch !== 'undefined') {
352
+ try {
353
+ const response = await fetch(url);
354
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
355
+ const buffer = Buffer.from(await response.arrayBuffer());
356
+ fs.writeFileSync(destPath, buffer);
357
+ return buffer;
358
+ } catch (e) {
359
+ if (e.message.includes('HTTP 404')) throw e;
360
+ // Fall through to curl/wget
361
+ }
362
+ }
363
+
364
+ // Fallback to curl or wget
365
+ const curl = spawnSync('curl', ['-L', '-o', destPath, url], { encoding: 'utf8' });
366
+ if (curl.status === 0) {
367
+ return fs.readFileSync(destPath);
368
+ }
369
+
370
+ const wget = spawnSync('wget', ['-O', destPath, url], { encoding: 'utf8' });
371
+ if (wget.status === 0) {
372
+ return fs.readFileSync(destPath);
373
+ }
374
+
375
+ throw new Error(`Failed to download from ${url}. Neither fetch, curl, nor wget available.`);
376
+ }
377
+
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
+ }
401
+
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;
408
+ }
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}`);
418
+ }
419
+
420
+ // Install skill to path
421
+ async function install(location, customPath, version, force) {
422
+ const installPath = resolveInstallPath(location, customPath);
423
+ const alreadyInstalled = isInstalled(installPath);
424
+ const currentVersion = alreadyInstalled ? getInstalledVersion(installPath) : null;
425
+
426
+ log(`\n${C('bold', 'agent-handoff installer v' + INSTALLER_VERSION)}`);
427
+ log(`Target: ${installPath}`);
428
+ if (!customPath && location === 'global') {
429
+ const g = resolveGlobalRoot();
430
+ log(`Global root: ${g.root} — ${g.why}`);
431
+ // The search is a decision, so the candidates it rejected are shown rather than hidden:
432
+ // a second account (or a stale ~/.agents/skills copy) stays visible.
433
+ for (const other of g.others || []) log(` not chosen: ${other}`);
434
+ }
435
+ log(`Version: ${version}`);
436
+
437
+ if (alreadyInstalled) {
438
+ warn(`Already installed (v${currentVersion})`);
439
+ if (!force && version === 'latest') {
440
+ log(`Run with --update to upgrade, or --force to reinstall`);
441
+ return { skipped: true, path: installPath, version: currentVersion };
442
+ }
443
+ if (version !== 'latest' && currentVersion === version) {
444
+ log(`Already at requested version ${version}`);
445
+ return { skipped: true, path: installPath, version };
446
+ }
447
+ log(`Reinstalling...`);
448
+ }
449
+
450
+ // Check write permission
451
+ try {
452
+ fs.mkdirSync(installPath, { recursive: true });
453
+ const testFile = path.join(installPath, '.install-test');
454
+ fs.writeFileSync(testFile, 'test');
455
+ fs.unlinkSync(testFile);
456
+ } catch (e) {
457
+ error(`Cannot write to ${installPath}. Check permissions or choose a different location.`);
458
+ }
459
+
460
+ // Two ways in, one manifest out. Inside a checkout or a release archive the skill files sit
461
+ // beside install/ and are copied. From the published package there is no tree to copy —
462
+ // install/ IS the package — so the archive for the requested version is fetched and
463
+ // unpacked into a throwaway directory, and the same manifest copies out of it.
464
+ let sourceRoot = SOURCE_DIR;
465
+ let cleanup = null;
466
+ if (haveSourceTree()) {
467
+ log(`\nInstalling from the tree beside the installer...`);
468
+ } else {
469
+ const fetched = await fetchAndUnpack(version);
470
+ sourceRoot = fetched.root;
471
+ cleanup = fetched.cleanup; // removed after the manifest has read out of it
472
+ }
473
+
474
+ // Create directories
475
+ fs.mkdirSync(installPath, { recursive: true });
476
+ for (const subdir of ['tools/lib', 'docs', 'refs', 'templates', 'schemas', 'tests/acceptance', 'tests/fixtures']) {
477
+ fs.mkdirSync(path.join(installPath, subdir), { recursive: true });
478
+ }
479
+
480
+ const copied = copyManifest(sourceRoot, installPath);
481
+ if (cleanup) cleanup();
482
+
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.
487
+ const missing = SKILL_FILES.filter(f => !fs.existsSync(path.join(installPath, f.to)));
488
+ if (missing.length) {
489
+ error(`Incomplete install: ${missing.length} of ${SKILL_FILES.length} file(s) missing — ` +
490
+ missing.map(f => f.to).join(', '));
491
+ }
492
+
493
+ // Create package.json if not exists
494
+ const pkgPath = path.join(installPath, 'package.json');
495
+ if (!fs.existsSync(pkgPath)) {
496
+ fs.writeFileSync(pkgPath, JSON.stringify({
497
+ name: SKILL_NAME,
498
+ version: version === 'latest' ? SKILL_VERSION : version,
499
+ description: 'Cross-harness session handoff engine',
500
+ type: 'module'
501
+ }, null, 2));
502
+ }
503
+
504
+ // The version reported is the one that landed, read back out of the installed SKILL.md —
505
+ // not the requested string, and not a constant that a release has to remember to bump.
506
+ const installedVersion = getInstalledVersion(installPath) || (version === 'latest' ? SKILL_VERSION : version);
507
+ success(`Installed ${SKILL_NAME} v${installedVersion} to ${installPath}`);
508
+ log(`Copied ${copied} files`);
509
+ // `config` is a real verb that exits 0 and prints the resolved root. `--help` is not a verb
510
+ // (it exits 2), so the old hint told every user to run a failing command.
511
+ log(`\nRun with: node ${path.join(installPath, 'tools', 'handoff.mjs')} config`);
512
+
513
+ return { installed: true, path: installPath, version: installedVersion };
514
+ }
515
+
516
+ // Update skill
517
+ 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.`);
522
+ }
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);
530
+ }
531
+
532
+ // Remove skill
533
+ function remove(location, customPath, force) {
534
+ const installPath = resolveInstallPath(location, customPath);
535
+
536
+ if (!isInstalled(installPath)) {
537
+ warn(`Not installed at ${installPath}`);
538
+ return { removed: false };
539
+ }
540
+
541
+ if (!force) {
542
+ log(`\nThis will remove ${SKILL_NAME} from:`);
543
+ log(` ${installPath}`);
544
+ log(`\nYour handoffs (projects/, handoffs/, links/) will NOT be deleted.`);
545
+ log(`Configuration (handoff.config.json) will NOT be deleted.`);
546
+ log(`\nContinue? (y/N)`);
547
+
548
+ // In non-interactive mode, default to no
549
+ if (!process.stdin.isTTY) {
550
+ warn(`Non-interactive mode, use --force to skip confirmation`);
551
+ return { removed: false };
552
+ }
553
+
554
+ // Simple readline for confirmation
555
+ const rl = readline.createInterface({
556
+ input: process.stdin,
557
+ output: process.stdout
558
+ });
559
+
560
+ return new Promise((resolve) => {
561
+ rl.question('', (answer) => {
562
+ rl.close();
563
+ if (answer.toLowerCase() !== 'y') {
564
+ log('Cancelled');
565
+ resolve({ removed: false });
566
+ return;
567
+ }
568
+ doRemove(installPath, force);
569
+ resolve({ removed: true, path: installPath });
570
+ });
571
+ });
572
+ }
573
+
574
+ return doRemove(installPath, force);
575
+ }
576
+
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
585
+ let entries;
586
+ try {
587
+ entries = fs.readdirSync(installPath, { withFileTypes: true });
588
+ } catch {
589
+ entries = [];
590
+ }
591
+
592
+ for (const entry of entries) {
593
+ 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)) {
612
+ continue;
613
+ }
614
+
615
+ // Remove everything else
616
+ if (entry.isDirectory()) {
617
+ fs.rmSync(entryPath, { recursive: true, force: true });
618
+ log(`Removed directory: ${entry.name}/`);
619
+ } else {
620
+ fs.unlinkSync(entryPath);
621
+ log(`Removed file: ${entry.name}`);
622
+ }
623
+ }
624
+
625
+ // Try to remove install directory if empty
626
+ try {
627
+ if (fs.readdirSync(installPath).length === 0) {
628
+ fs.rmdirSync(installPath);
629
+ log(`Removed empty install directory: ${installPath}`);
630
+ }
631
+ } catch {}
632
+
633
+ success(`Removed ${SKILL_NAME} from ${installPath}`);
634
+ return { removed: true, path: installPath };
635
+ }
636
+
637
+ // Verify installation
638
+ function verify(location, customPath) {
639
+ const installPath = resolveInstallPath(location, customPath);
640
+
641
+ console.log(`\n${C('bold', 'Verifying agent-handoff installation...')}`);
642
+ console.log(`Location: ${installPath}`);
643
+ if (!customPath && location === 'global') console.log(`Global root: ${resolveGlobalRoot().root}`);
644
+ console.log();
645
+
646
+ if (!isInstalled(installPath)) {
647
+ error(`Not installed at ${installPath}`);
648
+ }
649
+
650
+ const checks = [];
651
+ 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);
659
+ checks.push({ name: `File: ${file}`, passed: exists });
660
+ if (!exists) allPassed = false;
661
+ }
662
+
663
+ // Check SKILL.md has version
664
+ try {
665
+ const skillMd = fs.readFileSync(path.join(installPath, 'SKILL.md'), 'utf8');
666
+ const hasVersion = /^version:/m.test(skillMd);
667
+ const hasName = /^name:/m.test(skillMd);
668
+ checks.push({ name: 'SKILL.md: has version', passed: hasVersion });
669
+ checks.push({ name: 'SKILL.md: has name', passed: hasName });
670
+ if (!hasVersion || !hasName) allPassed = false;
671
+ } catch {
672
+ checks.push({ name: 'SKILL.md: readable', passed: false });
673
+ allPassed = false;
674
+ }
675
+
676
+ // Check the tools actually run. `--help` is NOT a verb of the engine (it exits 2 and
677
+ // prints usage to stderr), so the previous probe reported EVERY correct installation as
678
+ // broken. `config` exits 0 and prints a stable marker, and it exercises the engine's
679
+ // import graph - including tools/lib/handoff-root.mjs - so a missing module fails here.
680
+ try {
681
+ const handoffPath = path.join(installPath, 'tools', 'handoff.mjs');
682
+ const result = spawnSync(process.execPath, [handoffPath, 'config'], { cwd: installPath, encoding: 'utf8' });
683
+ const isRunnable = result.status === 0 && String(result.stdout).includes('handoff: config root=');
684
+ checks.push({ name: 'handoff.mjs config: exits 0 with the resolved-root marker', passed: isRunnable });
685
+ if (!isRunnable) {
686
+ console.error(String(result.stderr || '').trim().slice(0, 400));
687
+ allPassed = false;
688
+ }
689
+ } catch {
690
+ checks.push({ name: 'handoff.mjs config: runs', passed: false });
691
+ allPassed = false;
692
+ }
693
+
694
+ // Print results
695
+ for (const check of checks) {
696
+ log(` ${check.passed ? C('green', '✓') : C('red', '✗')} ${check.name}`);
697
+ }
698
+
699
+ console.log();
700
+ if (allPassed) {
701
+ success(`Installation verified ✓`);
702
+ log(`Location: ${installPath}`);
703
+ log(`Version: ${getInstalledVersion(installPath) || 'unknown'}`);
704
+ return { valid: true };
705
+ } else {
706
+ error(`Verification failed. Try reinstalling.`);
707
+ }
708
+ }
709
+
710
+ // List installations
711
+ function listInstallations() {
712
+ console.log(`\n${C('bold', 'Installed agent-handoff locations')}\n`);
713
+
714
+ const g = resolveGlobalRoot();
715
+ const locations = [
716
+ // The resolved global root first: this is the one `install` and `verify` use.
717
+ { name: 'Global (resolved — ' + g.why + ')', root: g.root },
718
+ { name: 'Local (cwd/local/skills)', root: PATHS.local },
719
+ ];
720
+ // Every other candidate root is listed too, so a second account or a stale
721
+ // ~/.agents/skills copy is VISIBLE rather than silently ignored.
722
+ for (const d of accountSkillRoots()) locations.push({ name: 'account-skill candidate', root: d });
723
+ locations.push({ name: 'Harness skills home', root: AGENTS_SKILLS });
724
+ if (fs.existsSync(path.join(process.cwd(), '.git'))) {
725
+ locations.push({ name: 'Project-local (cwd/skills)', root: PATHS.project });
726
+ }
727
+
728
+ let foundAny = false;
729
+ const seen = new Set();
730
+
731
+ for (const loc of locations) {
732
+ const skillPath = path.join(loc.root, SKILL_NAME);
733
+ if (seen.has(skillPath)) continue;
734
+ seen.add(skillPath);
735
+ if (isInstalled(skillPath)) {
736
+ foundAny = true;
737
+ const version = getInstalledVersion(skillPath) || 'unknown';
738
+ log(`${C('green', '✓')} ${loc.name}`);
739
+ log(` Path: ${skillPath}`);
740
+ log(` Version: ${version}`);
741
+ log(` Files from the manifest: ${SKILL_FILES.filter(f => fs.existsSync(path.join(skillPath, f.to))).length}/${SKILL_FILES.length}`);
742
+ log();
743
+ }
744
+ }
745
+
746
+ if (!foundAny) {
747
+ log(`${C('yellow', 'No installations found.')}`);
748
+ log(`Install with: npx agents-handoff`);
749
+ }
750
+
751
+ return foundAny;
752
+ }
753
+
754
+ // Where `--location global` would land, and why — the same answer install/verify/update use.
755
+ function printGlobalResolution() {
756
+ const g = resolveGlobalRoot();
757
+ log(`\nGlobal install root: ${g.root}`);
758
+ log(` chosen because: ${g.why}`);
759
+ for (const other of g.others || []) log(` also holds an install: ${other}`);
760
+ log(` override with: AGENT_HANDOFF_GLOBAL_DIR=<dir> or --path <dir>`);
761
+ return g;
762
+ }
763
+
764
+ // Main
765
+ async function main() {
766
+ try {
767
+ switch (COMMAND) {
768
+ case 'install':
769
+ case 'i':
770
+ await install(LOCATION, CUSTOM_PATH, VERSION, FORCE);
771
+ break;
772
+
773
+ case 'update':
774
+ case 'u':
775
+ await update(LOCATION, CUSTOM_PATH, VERSION);
776
+ break;
777
+
778
+ case 'remove':
779
+ case 'rm':
780
+ await remove(LOCATION, CUSTOM_PATH, FORCE);
781
+ break;
782
+
783
+ case 'verify':
784
+ case 'v':
785
+ await verify(LOCATION, CUSTOM_PATH);
786
+ break;
787
+
788
+ case 'list':
789
+ case 'ls':
790
+ listInstallations();
791
+ break;
792
+
793
+ case 'where':
794
+ printGlobalResolution();
795
+ break;
796
+
797
+ case '--help':
798
+ case '-h':
799
+ case 'help':
800
+ showHelp();
801
+ break;
802
+
803
+ default:
804
+ error(`Unknown command: ${COMMAND}. Use install, update, remove, verify, or list.`);
805
+ }
806
+ } catch (e) {
807
+ error(`Error: ${e.message}`);
808
+ }
809
+ }
810
+
811
+ function showHelp() {
812
+ console.log(`
813
+ ${C('bold', 'agents-handoff')} - Install assistant for agent-handoff skill
814
+
815
+ ${C('bold', 'Usage:')}
816
+ npx agents-handoff <command> [options]
817
+
818
+ ${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
825
+
826
+ ${C('bold', 'Options:')}
827
+ --location L Install location: global (default), local, project
828
+ --path P Custom installation path
829
+ --version V Version to install: latest (default) or specific version
830
+ --force, -f Skip confirmations, overwrite existing
831
+
832
+ ${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
839
+
840
+ ${C('bold', 'Locations:')}
841
+ 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
843
+ 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
845
+ store itself is not an install target.
846
+ Override with AGENT_HANDOFF_GLOBAL_DIR, or target an exact path with --path.
847
+ See it resolved: npx agents-handoff where
848
+ local ./local/skills/agent-handoff
849
+ project ./skills/agent-handoff (only if in a git repo)
850
+
851
+ ${C('bold', 'Repository:')}
852
+ https://github.com/${REPO_OWNER}/${REPO_NAME}
853
+ `);
854
+ }
855
+
856
+ main();