agents-handoff 0.0.0-stage → 2.0.3
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 +192 -0
- package/LICENSE +21 -0
- package/README.md +150 -2
- package/SKILL.md +147 -0
- package/capability-registry.json +27 -0
- package/docs/ARCHITECTURE.md +187 -0
- package/docs/CHANGELOG.md +196 -0
- package/docs/CLI.md +299 -0
- package/docs/COMPATIBILITY.md +124 -0
- package/docs/CONTRIBUTING.md +134 -0
- package/docs/FORMAT.md +185 -0
- package/docs/INSTALL.md +394 -0
- package/docs/INTEGRATION.md +188 -0
- package/docs/LEVEL4.md +202 -0
- package/docs/LEVEL5.md +96 -0
- package/docs/PERMISSIONS.md +145 -0
- package/docs/PROVENANCE.md +110 -0
- package/docs/SECURITY.md +93 -0
- package/docs/SESSIONS.md +97 -0
- package/docs/TROUBLESHOOTING.md +158 -0
- package/docs/UNINSTALL.md +148 -0
- package/docs/UPGRADE.md +177 -0
- package/docs/_config.yml +18 -0
- package/docs/_data/nav.yml +36 -0
- package/docs/_layouts/default.html +31 -0
- package/docs/assets/style.css +88 -0
- package/docs/index.md +92 -0
- package/docs/sessions.json +34 -0
- package/handoff.config.example.json +35 -0
- package/handoff.config.schema.json +117 -0
- package/install/CHANGELOG.md +48 -0
- package/install/README.md +76 -0
- package/install/install.mjs +1455 -0
- package/install/package.json +39 -0
- package/package.json +66 -4
- package/permission-policy.json +33 -0
- package/refs/ADAPTERS.md +33 -0
- package/refs/bootstrap.md +59 -0
- package/refs/brief-checklist.md +79 -0
- package/refs/handbook.md +58 -0
- package/refs/protocol.md +117 -0
- package/refs/roles.md +75 -0
- package/refs/validator.md +73 -0
- package/schemas/handoff.schema.json +275 -0
- package/skill.json +147 -0
- package/templates/HANDOFF.llm.schema.json +144 -0
- package/templates/HANDOFF.template.md +40 -0
- package/tests/acceptance/acceptance.yaml +209 -0
- package/tests/fixtures/minimal-transcript.jsonl +2 -0
- package/tools/agent-handoff.mjs +22 -0
- package/tools/agents-handoff.mjs +410 -0
- package/tools/capability-registry.mjs +120 -0
- package/tools/handoff.mjs +398 -0
- package/tools/handoff.test.mjs +668 -0
- package/tools/lib/handoff-root.mjs +161 -0
- package/tools/runtime-engine.mjs +330 -0
|
@@ -0,0 +1,1455 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// agents-handoff - npx installer for the agents-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 = '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';
|
|
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.
|
|
32
|
+
const REPO_NAME = 'agent-handoff';
|
|
33
|
+
const RELEASES_URL = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download`;
|
|
34
|
+
const TARBALL_URL = `https://codeload.github.com/${REPO_OWNER}/${REPO_NAME}/tar.gz`;
|
|
35
|
+
const API_LATEST = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`;
|
|
36
|
+
|
|
37
|
+
// THE VERSION THIS INSTALLER SHIPS. SKILL.md owns it: the same frontmatter that `verify` and
|
|
38
|
+
// `list` read back out of an installed copy. The literal below is only the fallback for the
|
|
39
|
+
// run that has no tree beside it — the published package fetching an archive — and the suite
|
|
40
|
+
// asserts it against SKILL.md, so a release that bumps one cannot leave the other behind.
|
|
41
|
+
const FALLBACK_SKILL_VERSION = '2.0.3';
|
|
42
|
+
function skillVersion() {
|
|
43
|
+
try {
|
|
44
|
+
const md = fs.readFileSync(path.join(SOURCE_DIR, 'SKILL.md'), 'utf8');
|
|
45
|
+
const m = md.match(/^version:\s*(\S+)/m);
|
|
46
|
+
if (m) return m[1];
|
|
47
|
+
} catch { /* no tree beside the installer */ }
|
|
48
|
+
return FALLBACK_SKILL_VERSION;
|
|
49
|
+
}
|
|
50
|
+
const SKILL_VERSION = skillVersion();
|
|
51
|
+
|
|
52
|
+
// The installer's own version, read from the package it ships in — the same file npm
|
|
53
|
+
// publishes, so the banner cannot lag a release.
|
|
54
|
+
function installerVersion() {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(fs.readFileSync(path.join(INSTALLER_ROOT, 'package.json'), 'utf8')).version;
|
|
57
|
+
} catch { return 'unknown'; }
|
|
58
|
+
}
|
|
59
|
+
const INSTALLER_VERSION = installerVersion();
|
|
60
|
+
|
|
61
|
+
// Locations
|
|
62
|
+
const HOME = process.env.USERPROFILE || process.env.HOME || '';
|
|
63
|
+
|
|
64
|
+
// Where a GLOBAL install can go. Desktop clients keep account skills in their own store with
|
|
65
|
+
// two opaque id levels between the store and the skill:
|
|
66
|
+
//
|
|
67
|
+
// <store>/<account-id>/<profile-id>/agents-handoff/
|
|
68
|
+
//
|
|
69
|
+
// No client is named here. A store is DISCOVERED by looking for `account-skills` directories
|
|
70
|
+
// under the platform's application-data roots, so a client that is absent from this machine
|
|
71
|
+
// simply contributes no candidate. These are candidates to search, never a decision:
|
|
72
|
+
// `resolveGlobalRoot` picks one and says why. `AGENT_HANDOFF_GLOBAL_DIR` overrides the
|
|
73
|
+
// search; `--path` bypasses it.
|
|
74
|
+
const DATA_ROOTS = (process.platform === 'win32'
|
|
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
|
+
]
|
|
85
|
+
: [path.join(HOME, '.config'), path.join(HOME, '.local', 'share')]
|
|
86
|
+
).filter(Boolean);
|
|
87
|
+
const AGENTS_SKILLS = path.join(HOME, '.agents', 'skills');
|
|
88
|
+
|
|
89
|
+
const PATHS = {
|
|
90
|
+
local: path.join(process.cwd(), 'local', 'skills'),
|
|
91
|
+
project: path.join(process.cwd(), 'skills'),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function listDirs(dir) {
|
|
95
|
+
try {
|
|
96
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
97
|
+
.filter(e => e.isDirectory())
|
|
98
|
+
.map(e => path.join(dir, e.name));
|
|
99
|
+
} catch { return []; }
|
|
100
|
+
}
|
|
101
|
+
const mtimeOf = p => { try { return fs.statSync(p).mtimeMs; } catch { return 0; } };
|
|
102
|
+
|
|
103
|
+
// Every `account-skills` store on this machine, whatever client created it.
|
|
104
|
+
function accountSkillStores() {
|
|
105
|
+
const out = [];
|
|
106
|
+
for (const dataRoot of DATA_ROOTS) {
|
|
107
|
+
for (const clientDir of listDirs(dataRoot)) {
|
|
108
|
+
const store = path.join(clientDir, 'account-skills');
|
|
109
|
+
try { if (fs.statSync(store).isDirectory()) out.push(store); } catch { /* no store there */ }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return [...new Set(out)];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Every directory that could be the PARENT of a global agents-handoff/, from every account
|
|
116
|
+
// store this machine has.
|
|
117
|
+
function accountSkillRoots() {
|
|
118
|
+
const stores = accountSkillStores();
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const store of stores) {
|
|
121
|
+
for (const account of listDirs(store)) {
|
|
122
|
+
out.push(account);
|
|
123
|
+
for (const profile of listDirs(account)) out.push(profile);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Resolution order, first hit wins:
|
|
130
|
+
// 1. AGENT_HANDOFF_GLOBAL_DIR — explicit operator override
|
|
131
|
+
// 2. an account-skill root that ALREADY holds agents-handoff (newest install first)
|
|
132
|
+
// 3. ~/.agents/skills — the harness-wide skills home
|
|
133
|
+
// 4. any account-skill root discovered above — keeps the install where a client reads
|
|
134
|
+
// 5. ~/.agents/skills — created on install when nothing exists
|
|
135
|
+
function resolveGlobalRoot() {
|
|
136
|
+
const override = process.env.AGENT_HANDOFF_GLOBAL_DIR;
|
|
137
|
+
if (override) return { root: path.resolve(override), why: 'AGENT_HANDOFF_GLOBAL_DIR' };
|
|
138
|
+
|
|
139
|
+
const roots = accountSkillRoots();
|
|
140
|
+
const installed = roots.filter(d => isInstalled(path.join(d, SKILL_NAME)));
|
|
141
|
+
installed.sort((a, b) => mtimeOf(path.join(b, SKILL_NAME)) - mtimeOf(path.join(a, SKILL_NAME)));
|
|
142
|
+
if (installed.length) {
|
|
143
|
+
return {
|
|
144
|
+
root: installed[0],
|
|
145
|
+
why: 'account-skill store, existing install (newest of ' + installed.length + ' found)',
|
|
146
|
+
others: installed.slice(1),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (fs.existsSync(AGENTS_SKILLS)) return { root: AGENTS_SKILLS, why: '~/.agents/skills (harness skills home)' };
|
|
150
|
+
if (roots.length) return { root: roots[0], why: 'account-skill store (no install there yet)' };
|
|
151
|
+
return { root: AGENTS_SKILLS, why: 'harness skills home (created on install)' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Colors for output
|
|
155
|
+
const COLORS = {
|
|
156
|
+
reset: '\x1b[0m',
|
|
157
|
+
bold: '\x1b[1m',
|
|
158
|
+
green: '\x1b[32m',
|
|
159
|
+
yellow: '\x1b[33m',
|
|
160
|
+
red: '\x1b[31m',
|
|
161
|
+
cyan: '\x1b[36m'
|
|
162
|
+
};
|
|
163
|
+
const C = (color, text) => process.stdout.isTTY ? `${COLORS[color]}${text}${COLORS.reset}` : text;
|
|
164
|
+
|
|
165
|
+
// `log()` is called with no argument for a blank line all over this file, and
|
|
166
|
+
// console.log(undefined) prints the literal word "undefined" — so the default is the empty
|
|
167
|
+
// string, not the argument passed through.
|
|
168
|
+
function log(msg = '') { console.log(msg); }
|
|
169
|
+
function warn(msg) { console.warn(C('yellow', `⚠ ${msg}`)); }
|
|
170
|
+
function error(msg) { console.error(C('red', `✗ ${msg}`)); process.exit(1); }
|
|
171
|
+
function success(msg) { console.log(C('green', `✓ ${msg}`)); }
|
|
172
|
+
|
|
173
|
+
// Parse args
|
|
174
|
+
const args = process.argv.slice(2);
|
|
175
|
+
const getArg = (name, defaultVal = null) => {
|
|
176
|
+
const idx = args.indexOf(name);
|
|
177
|
+
return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : defaultVal;
|
|
178
|
+
};
|
|
179
|
+
const hasFlag = (name) => args.includes(name);
|
|
180
|
+
|
|
181
|
+
// Accept BOTH the bare verb form (`install`, `verify`) and the flag form the help text
|
|
182
|
+
// documents (`--verify`, `--list`, `--remove`, `--update`, `--help`). Previously args[0]
|
|
183
|
+
// had to be a bare verb, so every documented flag form exited 1 with "Unknown command".
|
|
184
|
+
const FLAG_COMMANDS = {
|
|
185
|
+
'--install': 'install', '--update': 'update', '--remove': 'remove',
|
|
186
|
+
'--verify': 'verify', '--list': 'list', '--doctor': 'doctor',
|
|
187
|
+
'--verify-package': 'verify-package',
|
|
188
|
+
'--help': 'help', '-h': 'help'
|
|
189
|
+
};
|
|
190
|
+
const COMMAND = FLAG_COMMANDS[args[0]] || (args[0] && !args[0].startsWith('-') ? args[0] : 'install');
|
|
191
|
+
const LOCATION = getArg('--location', 'global');
|
|
192
|
+
const CUSTOM_PATH = getArg('--path');
|
|
193
|
+
const VERSION = getArg('--version', 'latest');
|
|
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;
|
|
218
|
+
|
|
219
|
+
// THE INSTALL MANIFEST. One list, used to copy AND to verify, so an installed copy
|
|
220
|
+
// cannot silently lose a file the runtime needs (the engine now imports
|
|
221
|
+
// tools/lib/handoff-root.mjs, and the previous copy list predated that, the runtime MVP
|
|
222
|
+
// and the schemas). `from` is relative to the tree the installer runs in; `to` is relative
|
|
223
|
+
// to the target. `from` is written in the PUBLIC tree's terms, because that is the tree the
|
|
224
|
+
// installer runs in once it ships — see resolveSource() for how the development tree, which
|
|
225
|
+
// keeps those same files under repo-upstream/, still resolves them.
|
|
226
|
+
const SKILL_FILES = [
|
|
227
|
+
{ from: 'SKILL.md', to: 'SKILL.md' },
|
|
228
|
+
{ from: 'README.md', to: 'README.md' },
|
|
229
|
+
{ from: 'LICENSE', to: 'LICENSE' },
|
|
230
|
+
{ from: 'skill.json', to: 'skill.json' },
|
|
231
|
+
{ from: 'capability-registry.json', to: 'capability-registry.json' },
|
|
232
|
+
{ from: 'permission-policy.json', to: 'permission-policy.json' },
|
|
233
|
+
{ from: 'handoff.config.schema.json', to: 'handoff.config.schema.json' },
|
|
234
|
+
{ from: 'handoff.config.example.json', to: 'handoff.config.example.json' },
|
|
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.
|
|
238
|
+
{ from: 'tools/agent-handoff.mjs', to: 'tools/agent-handoff.mjs' },
|
|
239
|
+
{ from: 'tools/handoff.test.mjs', to: 'tools/handoff.test.mjs' },
|
|
240
|
+
{ from: 'tools/capability-registry.mjs', to: 'tools/capability-registry.mjs' },
|
|
241
|
+
{ from: 'tools/runtime-engine.mjs', to: 'tools/runtime-engine.mjs' },
|
|
242
|
+
{ from: 'tools/lib/handoff-root.mjs', to: 'tools/lib/handoff-root.mjs' },
|
|
243
|
+
{ from: 'schemas/handoff.schema.json', to: 'schemas/handoff.schema.json' },
|
|
244
|
+
{ from: 'tests/acceptance/acceptance.yaml', to: 'tests/acceptance/acceptance.yaml' },
|
|
245
|
+
{ from: 'tests/fixtures/minimal-transcript.jsonl', to: 'tests/fixtures/minimal-transcript.jsonl' },
|
|
246
|
+
{ from: 'docs/INTEGRATION.md', to: 'docs/INTEGRATION.md' },
|
|
247
|
+
{ from: 'docs/LEVEL4.md', to: 'docs/LEVEL4.md' },
|
|
248
|
+
{ from: 'docs/LEVEL5.md', to: 'docs/LEVEL5.md' },
|
|
249
|
+
{ from: 'docs/FORMAT.md', to: 'docs/FORMAT.md' },
|
|
250
|
+
{ from: 'docs/PERMISSIONS.md', to: 'docs/PERMISSIONS.md' },
|
|
251
|
+
{ from: 'docs/CONTRIBUTING.md', to: 'docs/CONTRIBUTING.md' },
|
|
252
|
+
{ from: 'docs/INSTALL.md', to: 'docs/INSTALL.md' },
|
|
253
|
+
{ from: 'docs/UPGRADE.md', to: 'docs/UPGRADE.md' },
|
|
254
|
+
{ from: 'docs/UNINSTALL.md', to: 'docs/UNINSTALL.md' },
|
|
255
|
+
{ from: 'docs/SECURITY.md', to: 'docs/SECURITY.md' },
|
|
256
|
+
{ from: 'docs/COMPATIBILITY.md', to: 'docs/COMPATIBILITY.md' },
|
|
257
|
+
{ from: 'docs/PROVENANCE.md', to: 'docs/PROVENANCE.md' },
|
|
258
|
+
{ from: 'refs/ADAPTERS.md', to: 'refs/ADAPTERS.md' },
|
|
259
|
+
{ from: 'refs/handbook.md', to: 'refs/handbook.md' },
|
|
260
|
+
{ from: 'refs/protocol.md', to: 'refs/protocol.md' },
|
|
261
|
+
{ from: 'refs/roles.md', to: 'refs/roles.md' },
|
|
262
|
+
{ from: 'refs/validator.md', to: 'refs/validator.md' },
|
|
263
|
+
{ from: 'refs/brief-checklist.md', to: 'refs/brief-checklist.md' },
|
|
264
|
+
{ from: 'refs/bootstrap.md', to: 'refs/bootstrap.md' },
|
|
265
|
+
{ from: 'templates/HANDOFF.template.md', to: 'templates/HANDOFF.template.md' },
|
|
266
|
+
{ from: 'templates/HANDOFF.llm.schema.json', to: 'templates/HANDOFF.llm.schema.json' },
|
|
267
|
+
];
|
|
268
|
+
|
|
269
|
+
// Where a manifest `from` lives. The public shape is tried at its own path, and the
|
|
270
|
+
// development shape at repo-upstream/<from>. Development is tried FIRST: a bare `README.md`
|
|
271
|
+
// in the development tree is the internal one, while the file that ships is the one under
|
|
272
|
+
// repo-upstream/. A clone, a release archive and a fetched tarball have no repo-upstream/,
|
|
273
|
+
// so the same list resolves there at the public path — one manifest, both trees.
|
|
274
|
+
function resolveSource(root, from) {
|
|
275
|
+
for (const rel of [path.join('repo-upstream', from), from]) {
|
|
276
|
+
const abs = path.join(root, rel);
|
|
277
|
+
if (fs.existsSync(abs)) return abs;
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Is there a skill tree to copy from? Only a checkout or a release archive has one; the
|
|
283
|
+
// published package carries install/ alone, which is the case that must fetch instead.
|
|
284
|
+
function haveSourceTree() {
|
|
285
|
+
return fs.existsSync(path.join(SOURCE_DIR, 'tools', 'handoff.mjs'));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// The manifest is the copy list AND the completion criterion: a run that cannot resolve a
|
|
289
|
+
// source reports it, and install() fails the install when any target is missing. A file
|
|
290
|
+
// copied but not verified was how the installed engine once went missing its resolver.
|
|
291
|
+
function copyManifest(root, installPath) {
|
|
292
|
+
const unresolved = [];
|
|
293
|
+
let copied = 0;
|
|
294
|
+
for (const file of SKILL_FILES) {
|
|
295
|
+
const srcPath = resolveSource(root, file.from);
|
|
296
|
+
if (!srcPath) { unresolved.push(file.from); continue; }
|
|
297
|
+
const destPath = path.join(installPath, file.to);
|
|
298
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
299
|
+
fs.copyFileSync(srcPath, destPath);
|
|
300
|
+
copied++;
|
|
301
|
+
}
|
|
302
|
+
if (unresolved.length) warn('source file(s) not found: ' + unresolved.join(', '));
|
|
303
|
+
return { copied, unresolved };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// The newest published release tag, or null when the repository has none (or the API cannot
|
|
307
|
+
// be reached). Null is not an error here: it selects the main branch and says so.
|
|
308
|
+
async function latestTag() {
|
|
309
|
+
if (typeof fetch === 'undefined') return null;
|
|
310
|
+
try {
|
|
311
|
+
const res = await fetch(API_LATEST, {
|
|
312
|
+
headers: { accept: 'application/vnd.github+json', 'user-agent': 'agents-handoff' },
|
|
313
|
+
});
|
|
314
|
+
if (!res.ok) return null;
|
|
315
|
+
const j = await res.json();
|
|
316
|
+
return j.tag_name || null;
|
|
317
|
+
} catch { return null; }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Fetch and unpack the archive for the requested version, into a throwaway directory the
|
|
321
|
+
// caller removes. Returns the unpacked root, so the same manifest copies from it.
|
|
322
|
+
async function fetchAndUnpack(version) {
|
|
323
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-handoff-fetch-'));
|
|
324
|
+
let ref, label;
|
|
325
|
+
if (version === 'latest') {
|
|
326
|
+
const tag = await latestTag();
|
|
327
|
+
ref = tag ? 'refs/tags/' + tag : 'refs/heads/main';
|
|
328
|
+
label = tag || 'main';
|
|
329
|
+
if (!tag) warn('no published release found — fetching the main branch instead');
|
|
330
|
+
} else {
|
|
331
|
+
ref = 'refs/tags/v' + version;
|
|
332
|
+
label = 'v' + version;
|
|
333
|
+
}
|
|
334
|
+
const url = `${TARBALL_URL}/${ref}`;
|
|
335
|
+
const tarball = path.join(tmp, 'agents-handoff.tar.gz');
|
|
336
|
+
log(`Fetching the ${label} archive...`);
|
|
337
|
+
log(` ${url}`);
|
|
338
|
+
await downloadFile(url, tarball);
|
|
339
|
+
const bytes = fs.statSync(tarball).size;
|
|
340
|
+
const hash = crypto.createHash('sha256').update(fs.readFileSync(tarball)).digest('hex');
|
|
341
|
+
log(` ${(bytes / 1024).toFixed(1)} kB, sha256 ${hash.slice(0, 16)}…`);
|
|
342
|
+
const unpack = path.join(tmp, 'unpack');
|
|
343
|
+
fs.mkdirSync(unpack, { recursive: true });
|
|
344
|
+
// Run tar from inside the temp directory with RELATIVE names. An absolute Windows path
|
|
345
|
+
// (`C:\Users\…`) is read by tar as a remote host — `tar (child): Cannot connect to C:
|
|
346
|
+
// resolve failed` — so `-xzf C:\…` fails on the very platform this installer targets first.
|
|
347
|
+
// Relative operands, with cwd doing the locating, work the same in GNU tar and bsdtar.
|
|
348
|
+
const tar = spawnSync('tar', ['-xzf', 'agents-handoff.tar.gz', '-C', 'unpack', '--strip-components=1'],
|
|
349
|
+
{ cwd: tmp, encoding: 'utf8' });
|
|
350
|
+
if (tar.status !== 0) {
|
|
351
|
+
const why = String(tar.stderr || '').trim().split('\n')[0];
|
|
352
|
+
error(`Cannot extract the archive (tar exited ${tar.status}${why ? ': ' + why : ''}).\n` +
|
|
353
|
+
` Extract it yourself, then install from the unpacked tree:\n\n` +
|
|
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`);
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
root: unpack,
|
|
360
|
+
cleanup: () => fs.rmSync(tmp, { recursive: true, force: true }),
|
|
361
|
+
ref,
|
|
362
|
+
label,
|
|
363
|
+
url,
|
|
364
|
+
sha256: hash,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Resolve install path
|
|
369
|
+
function resolveInstallPath(location, customPath) {
|
|
370
|
+
if (customPath) {
|
|
371
|
+
return path.resolve(customPath);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
switch (location) {
|
|
375
|
+
case 'global':
|
|
376
|
+
return path.join(resolveGlobalRoot().root, SKILL_NAME);
|
|
377
|
+
case 'local':
|
|
378
|
+
return path.join(PATHS.local, SKILL_NAME);
|
|
379
|
+
case 'project':
|
|
380
|
+
return path.join(PATHS.project, SKILL_NAME);
|
|
381
|
+
default:
|
|
382
|
+
error(`Unknown location: ${location}. Use global, local, or project.`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
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
|
+
|
|
553
|
+
// Check if installed
|
|
554
|
+
function isInstalled(installPath) {
|
|
555
|
+
return fs.existsSync(path.join(installPath, 'SKILL.md'));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Get installed version
|
|
559
|
+
function getInstalledVersion(installPath) {
|
|
560
|
+
try {
|
|
561
|
+
const skillMd = fs.readFileSync(path.join(installPath, 'SKILL.md'), 'utf8');
|
|
562
|
+
const match = skillMd.match(/^version:\s*(\S+)/m);
|
|
563
|
+
return match ? match[1] : null;
|
|
564
|
+
} catch {
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Download file from URL (using fetch if available, fallback to curl/wget)
|
|
570
|
+
async function downloadFile(url, destPath) {
|
|
571
|
+
// Try native fetch first (Node 18+)
|
|
572
|
+
if (typeof fetch !== 'undefined') {
|
|
573
|
+
try {
|
|
574
|
+
const response = await fetch(url);
|
|
575
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
576
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
577
|
+
fs.writeFileSync(destPath, buffer);
|
|
578
|
+
return buffer;
|
|
579
|
+
} catch (e) {
|
|
580
|
+
if (e.message.includes('HTTP 404')) throw e;
|
|
581
|
+
// Fall through to curl/wget
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Fallback to curl or wget
|
|
586
|
+
const curl = spawnSync('curl', ['-L', '-o', destPath, url], { encoding: 'utf8' });
|
|
587
|
+
if (curl.status === 0) {
|
|
588
|
+
return fs.readFileSync(destPath);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const wget = spawnSync('wget', ['-O', destPath, url], { encoding: 'utf8' });
|
|
592
|
+
if (wget.status === 0) {
|
|
593
|
+
return fs.readFileSync(destPath);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
throw new Error(`Failed to download from ${url}. Neither fetch, curl, nor wget available.`);
|
|
597
|
+
}
|
|
598
|
+
|
|
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.
|
|
602
|
+
|
|
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));
|
|
612
|
+
}
|
|
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;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function installTarget(target, version, force) {
|
|
623
|
+
const installPath = target.path;
|
|
624
|
+
const alreadyInstalled = isInstalled(installPath);
|
|
625
|
+
const currentVersion = alreadyInstalled ? getInstalledVersion(installPath) : null;
|
|
626
|
+
|
|
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') {
|
|
630
|
+
const g = resolveGlobalRoot();
|
|
631
|
+
log(`Global root: ${g.root} — ${g.why}`);
|
|
632
|
+
// The search is a decision, so the candidates it rejected are shown rather than hidden:
|
|
633
|
+
// a second account (or a stale ~/.agents/skills copy) stays visible.
|
|
634
|
+
for (const other of g.others || []) log(` not chosen: ${other}`);
|
|
635
|
+
}
|
|
636
|
+
log(`Version: ${version}`);
|
|
637
|
+
|
|
638
|
+
if (alreadyInstalled) {
|
|
639
|
+
warn(`Already installed (v${currentVersion})`);
|
|
640
|
+
if (!force && version === 'latest') {
|
|
641
|
+
log(`Run with --update to upgrade, or --force to reinstall`);
|
|
642
|
+
return { skipped: true, path: installPath, version: currentVersion };
|
|
643
|
+
}
|
|
644
|
+
if (version !== 'latest' && currentVersion === version) {
|
|
645
|
+
log(`Already at requested version ${version}`);
|
|
646
|
+
return { skipped: true, path: installPath, version };
|
|
647
|
+
}
|
|
648
|
+
log(`Reinstalling...`);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Check write permission
|
|
652
|
+
try {
|
|
653
|
+
fs.mkdirSync(installPath, { recursive: true });
|
|
654
|
+
const testFile = path.join(installPath, '.install-test');
|
|
655
|
+
fs.writeFileSync(testFile, 'test');
|
|
656
|
+
fs.unlinkSync(testFile);
|
|
657
|
+
} catch (e) {
|
|
658
|
+
error(`Cannot write to ${installPath}. Check permissions or choose a different location.`);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Two ways in, one manifest out. Inside a checkout or a release archive the skill files sit
|
|
662
|
+
// beside install/ and are copied. From the published package there is no tree to copy —
|
|
663
|
+
// install/ IS the package — so the archive for the requested version is fetched and
|
|
664
|
+
// unpacked into a throwaway directory, and the same manifest copies out of it.
|
|
665
|
+
let sourceRoot = SOURCE_DIR;
|
|
666
|
+
let cleanup = null;
|
|
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) {
|
|
675
|
+
log(`\nInstalling from the tree beside the installer...`);
|
|
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
|
+
}
|
|
680
|
+
const fetched = await fetchAndUnpack(version);
|
|
681
|
+
sourceRoot = fetched.root;
|
|
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
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Create directories
|
|
693
|
+
fs.mkdirSync(installPath, { recursive: true });
|
|
694
|
+
for (const subdir of ['tools/lib', 'docs', 'refs', 'templates', 'schemas', 'tests/acceptance', 'tests/fixtures']) {
|
|
695
|
+
fs.mkdirSync(path.join(installPath, subdir), { recursive: true });
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const { copied } = copyManifest(sourceRoot, installPath);
|
|
699
|
+
|
|
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.
|
|
708
|
+
const missing = SKILL_FILES.filter(f => !fs.existsSync(path.join(installPath, f.to)));
|
|
709
|
+
if (missing.length) {
|
|
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
|
+
}
|
|
720
|
+
}
|
|
721
|
+
if (cleanup) cleanup();
|
|
722
|
+
|
|
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.
|
|
729
|
+
const pkgPath = path.join(installPath, 'package.json');
|
|
730
|
+
if (!fs.existsSync(pkgPath)) {
|
|
731
|
+
fs.writeFileSync(pkgPath, JSON.stringify({
|
|
732
|
+
name: SKILL_NAME,
|
|
733
|
+
version: version === 'latest' ? SKILL_VERSION : version,
|
|
734
|
+
description: 'Cross-harness session handoff engine (installed copy — not a publishable package; the npm package is agents-handoff)',
|
|
735
|
+
private: true,
|
|
736
|
+
type: 'module'
|
|
737
|
+
}, null, 2));
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// The version reported is the one that landed, read back out of the installed SKILL.md —
|
|
741
|
+
// not the requested string, and not a constant that a release has to remember to bump.
|
|
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 });
|
|
747
|
+
success(`Installed ${SKILL_NAME} v${installedVersion} to ${installPath}`);
|
|
748
|
+
log(`Copied ${copied} files`);
|
|
749
|
+
log(`Provenance: ${describeSource(sourceInfo)} · file-set sha256 ${record.files_sha256.slice(0, 16)}… (${PROVENANCE_FILE})`);
|
|
750
|
+
// `config` is a real verb that exits 0 and prints the resolved root. `--help` is not a verb
|
|
751
|
+
// (it exits 2), so the old hint told every user to run a failing command.
|
|
752
|
+
log(`\nRun with: node ${path.join(installPath, 'tools', 'handoff.mjs')} config`);
|
|
753
|
+
|
|
754
|
+
return { installed: true, path: installPath, version: installedVersion };
|
|
755
|
+
}
|
|
756
|
+
|
|
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.
|
|
766
|
+
async function update(location, customPath, version) {
|
|
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>');
|
|
774
|
+
}
|
|
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;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Remove skill
|
|
803
|
+
function remove(location, customPath, force) {
|
|
804
|
+
const installPath = resolveInstallPath(location, customPath);
|
|
805
|
+
|
|
806
|
+
if (!isInstalled(installPath)) {
|
|
807
|
+
warn(`Not installed at ${installPath}`);
|
|
808
|
+
return { removed: false };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (!force) {
|
|
812
|
+
log(`\nThis will remove ${SKILL_NAME} from:`);
|
|
813
|
+
log(` ${installPath}`);
|
|
814
|
+
log(`\nYour handoffs and anything else you put in this directory will NOT be deleted.`);
|
|
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.`);
|
|
817
|
+
log(`\nContinue? (y/N)`);
|
|
818
|
+
|
|
819
|
+
// In non-interactive mode, default to no
|
|
820
|
+
if (!process.stdin.isTTY) {
|
|
821
|
+
warn(`Non-interactive mode, use --force to skip confirmation`);
|
|
822
|
+
return { removed: false };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Simple readline for confirmation
|
|
826
|
+
const rl = readline.createInterface({
|
|
827
|
+
input: process.stdin,
|
|
828
|
+
output: process.stdout
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
return new Promise((resolve) => {
|
|
832
|
+
rl.question('', (answer) => {
|
|
833
|
+
rl.close();
|
|
834
|
+
if (answer.toLowerCase() !== 'y') {
|
|
835
|
+
log('Cancelled');
|
|
836
|
+
resolve({ removed: false });
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
doRemove(installPath, force);
|
|
840
|
+
resolve({ removed: true, path: installPath });
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
return doRemove(installPath, force);
|
|
846
|
+
}
|
|
847
|
+
|
|
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
|
+
|
|
858
|
+
let entries;
|
|
859
|
+
try {
|
|
860
|
+
entries = fs.readdirSync(installPath, { withFileTypes: true });
|
|
861
|
+
} catch {
|
|
862
|
+
entries = [];
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const kept = [];
|
|
866
|
+
for (const entry of entries) {
|
|
867
|
+
const entryPath = path.join(installPath, entry.name);
|
|
868
|
+
const isOwned = owned.has(entry.name) && !KEPT_FILES.has(entry.name);
|
|
869
|
+
if (!isOwned) {
|
|
870
|
+
kept.push(entry.name + (entry.isDirectory() ? '/' : ''));
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
if (entry.isDirectory()) {
|
|
874
|
+
fs.rmSync(entryPath, { recursive: true, force: true });
|
|
875
|
+
log(`Removed directory: ${entry.name}/`);
|
|
876
|
+
} else {
|
|
877
|
+
fs.unlinkSync(entryPath);
|
|
878
|
+
log(`Removed file: ${entry.name}`);
|
|
879
|
+
}
|
|
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
|
+
|
|
908
|
+
|
|
909
|
+
// Try to remove install directory if empty
|
|
910
|
+
try {
|
|
911
|
+
if (fs.readdirSync(installPath).length === 0) {
|
|
912
|
+
fs.rmdirSync(installPath);
|
|
913
|
+
log(`Removed empty install directory: ${installPath}`);
|
|
914
|
+
}
|
|
915
|
+
} catch {}
|
|
916
|
+
|
|
917
|
+
success(`Removed ${SKILL_NAME} from ${installPath}`);
|
|
918
|
+
return { removed: true, path: installPath };
|
|
919
|
+
}
|
|
920
|
+
|
|
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.
|
|
925
|
+
function verify(location, customPath) {
|
|
926
|
+
const targets = MULTI_TARGET
|
|
927
|
+
? resolveTargets()
|
|
928
|
+
: discoverInstalls().map((t) => ({ label: t.label, dir: t.dir, path: t.path }));
|
|
929
|
+
if (!targets.length) {
|
|
930
|
+
error('Nothing to verify: no agents-handoff installation found.\n' +
|
|
931
|
+
' Install one with: npx agents-handoff --all');
|
|
932
|
+
}
|
|
933
|
+
if (targets.length > 1) log(`\n${C('bold', 'Verifying ' + targets.length + ' installation(s)')}`);
|
|
934
|
+
let ok = true;
|
|
935
|
+
for (const target of targets) {
|
|
936
|
+
const r = verifyOne(target, PROVENANCE);
|
|
937
|
+
if (!r.valid) ok = false;
|
|
938
|
+
}
|
|
939
|
+
if (!ok) error('Verification failed. Try reinstalling.');
|
|
940
|
+
return { valid: true };
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// ----------------------------------------------------------- verify against the package
|
|
944
|
+
// The strongest check this installer can make: does the copy on disk match the tarball npm is
|
|
945
|
+
// actually serving for its version? Everything else compares an installation with itself, or
|
|
946
|
+
// with the tree it came from. This compares it with the published artifact, and it works
|
|
947
|
+
// whichever way the install happened — tree, GitHub archive, or `npx`.
|
|
948
|
+
//
|
|
949
|
+
// Three things are checked, in this order, because each one makes the next meaningful:
|
|
950
|
+
// 1. the tarball we downloaded matches the hashes the REGISTRY declares for that version
|
|
951
|
+
// (otherwise "the published package" is whatever this network handed us)
|
|
952
|
+
// 2. the tarball's manifest file set hashes to the same value as the installed file set
|
|
953
|
+
// (per-file differences are named)
|
|
954
|
+
// 3. if the install record already holds a package sha256, it matches too
|
|
955
|
+
async function registryMeta(version) {
|
|
956
|
+
if (typeof fetch === 'undefined') return null;
|
|
957
|
+
try {
|
|
958
|
+
const res = await fetch(`${REGISTRY}/${NPM_PACKAGE}/${encodeURIComponent(version)}`, {
|
|
959
|
+
headers: { accept: 'application/json', 'user-agent': NPM_PACKAGE },
|
|
960
|
+
});
|
|
961
|
+
if (!res.ok) return null;
|
|
962
|
+
const j = await res.json();
|
|
963
|
+
return {
|
|
964
|
+
tarball: (j.dist && j.dist.tarball) || null,
|
|
965
|
+
integrity: (j.dist && j.dist.integrity) || null,
|
|
966
|
+
shasum: (j.dist && j.dist.shasum) || null,
|
|
967
|
+
};
|
|
968
|
+
} catch { return null; }
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function recordPackageVerification(installPath, actual, meta) {
|
|
972
|
+
const prov = readProvenance(installPath);
|
|
973
|
+
if (!prov) {
|
|
974
|
+
warn('no install record to update — reinstall to create one');
|
|
975
|
+
return false;
|
|
976
|
+
}
|
|
977
|
+
prov.package = {
|
|
978
|
+
...packageRecord(installPath),
|
|
979
|
+
...(prov.package || {}),
|
|
980
|
+
tarball: meta.tarball,
|
|
981
|
+
integrity: meta.integrity || null,
|
|
982
|
+
shasum: meta.shasum || null,
|
|
983
|
+
sha256: actual.sha256,
|
|
984
|
+
sha512: actual.sha512,
|
|
985
|
+
verified_at: new Date().toISOString(),
|
|
986
|
+
};
|
|
987
|
+
fs.writeFileSync(path.join(installPath, PROVENANCE_FILE), JSON.stringify(prov, null, 2) + '\n');
|
|
988
|
+
return true;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
async function verifyPackageOne(target) {
|
|
992
|
+
const installPath = target.path;
|
|
993
|
+
const version = getInstalledVersion(installPath);
|
|
994
|
+
console.log(`\n${C('bold', 'Verifying ' + NPM_PACKAGE + '@' + (version || '?') + ' against the published tarball')}`);
|
|
995
|
+
console.log(`Install: ${installPath}`);
|
|
996
|
+
if (!isInstalled(installPath)) {
|
|
997
|
+
console.error(` ${C('red', '✗')} not installed at ${installPath}`);
|
|
998
|
+
return false;
|
|
999
|
+
}
|
|
1000
|
+
if (!version) {
|
|
1001
|
+
console.error(` ${C('red', '✗')} SKILL.md carries no version — nothing to look up`);
|
|
1002
|
+
return false;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const meta = await registryMeta(version);
|
|
1006
|
+
if (!meta || !meta.tarball) {
|
|
1007
|
+
console.error(` ${C('red', '✗')} ${NPM_PACKAGE}@${version} is not on the npm registry, or the registry is unreachable`);
|
|
1008
|
+
console.error(' the installation itself is untouched by this result — this check needs the network');
|
|
1009
|
+
return false;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-handoff-npm-'));
|
|
1013
|
+
try {
|
|
1014
|
+
const tgz = path.join(tmp, 'package.tgz');
|
|
1015
|
+
await downloadFile(meta.tarball, tgz);
|
|
1016
|
+
const bytes = fs.readFileSync(tgz);
|
|
1017
|
+
const actual = {
|
|
1018
|
+
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
|
|
1019
|
+
sha512: crypto.createHash('sha512').update(bytes).digest('hex'),
|
|
1020
|
+
sha1: crypto.createHash('sha1').update(bytes).digest('hex'),
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
const checks = [];
|
|
1024
|
+
if (meta.integrity) {
|
|
1025
|
+
checks.push({
|
|
1026
|
+
name: 'tarball matches the registry integrity (' + meta.integrity.slice(0, 20) + '…)',
|
|
1027
|
+
passed: meta.integrity === 'sha512-' + Buffer.from(actual.sha512, 'hex').toString('base64'),
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
if (meta.shasum) {
|
|
1031
|
+
checks.push({
|
|
1032
|
+
name: 'tarball matches the registry shasum (' + meta.shasum.slice(0, 12) + '…)',
|
|
1033
|
+
passed: meta.shasum === actual.sha1,
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const unpack = path.join(tmp, 'unpack');
|
|
1038
|
+
fs.mkdirSync(unpack, { recursive: true });
|
|
1039
|
+
const tar = spawnSync('tar', ['-xzf', 'package.tgz', '-C', 'unpack', '--strip-components=1'],
|
|
1040
|
+
{ cwd: tmp, encoding: 'utf8' });
|
|
1041
|
+
if (tar.status !== 0) {
|
|
1042
|
+
console.error(` ${C('red', '✗')} cannot extract the published tarball (tar exited ${tar.status})`);
|
|
1043
|
+
return false;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
const differing = [];
|
|
1047
|
+
for (const rel of SKILL_FILES.map((f) => f.to).sort()) {
|
|
1048
|
+
const inPackage = path.join(unpack, ...rel.split('/'));
|
|
1049
|
+
const installed = path.join(installPath, ...rel.split('/'));
|
|
1050
|
+
const hPackage = fs.existsSync(inPackage) ? fileSha256(inPackage) : 'missing';
|
|
1051
|
+
const hInstalled = fs.existsSync(installed) ? fileSha256(installed) : 'missing';
|
|
1052
|
+
if (hPackage !== hInstalled) {
|
|
1053
|
+
differing.push(rel + ' (' + (hInstalled === 'missing' ? 'not installed'
|
|
1054
|
+
: hPackage === 'missing' ? 'not in the published package' : 'content differs') + ')');
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
checks.push({
|
|
1058
|
+
name: `all ${SKILL_FILES.length} manifest file(s) are identical to the published tarball`,
|
|
1059
|
+
passed: differing.length === 0,
|
|
1060
|
+
});
|
|
1061
|
+
for (const d of differing.slice(0, 12)) console.error(` ${d}`);
|
|
1062
|
+
if (differing.length > 12) console.error(` … ${differing.length - 12} more`);
|
|
1063
|
+
|
|
1064
|
+
const prov = readProvenance(installPath);
|
|
1065
|
+
const recorded = prov && prov.package ? prov.package : null;
|
|
1066
|
+
if (recorded && recorded.sha256) {
|
|
1067
|
+
checks.push({
|
|
1068
|
+
name: 'tarball sha256 matches the hash recorded for this install',
|
|
1069
|
+
passed: recorded.sha256 === actual.sha256,
|
|
1070
|
+
});
|
|
1071
|
+
} else {
|
|
1072
|
+
log(` ${C('yellow', '·')} no package sha256 recorded for this install${RECORD ? '' : ' — pass --record to store it'}`);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
for (const c of checks) log(` ${c.passed ? C('green', '✓') : C('red', '✗')} ${c.name}`);
|
|
1076
|
+
log(` package: ${NPM_PACKAGE}@${version}`);
|
|
1077
|
+
log(` tarball: ${meta.tarball}`);
|
|
1078
|
+
log(` sha256: ${actual.sha256}`);
|
|
1079
|
+
|
|
1080
|
+
if (RECORD) {
|
|
1081
|
+
if (recordPackageVerification(installPath, actual, meta)) {
|
|
1082
|
+
log(` recorded in ${PROVENANCE_FILE} (package.sha256 / sha512 / integrity)`);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const ok = checks.every((c) => c.passed);
|
|
1087
|
+
console.log();
|
|
1088
|
+
if (ok) {
|
|
1089
|
+
success(`Installation matches the published package`);
|
|
1090
|
+
return true;
|
|
1091
|
+
}
|
|
1092
|
+
console.error(`The installation does NOT match agents-handoff@${version} as published`);
|
|
1093
|
+
return false;
|
|
1094
|
+
} finally {
|
|
1095
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
async function verifyPackage() {
|
|
1100
|
+
const targets = MULTI_TARGET
|
|
1101
|
+
? resolveTargets()
|
|
1102
|
+
: discoverInstalls().map((t) => ({ label: t.label, dir: t.dir, path: t.path }));
|
|
1103
|
+
if (!targets.length) {
|
|
1104
|
+
error('Nothing to verify: no agents-handoff installation found.\n' +
|
|
1105
|
+
' Install one with: npx agents-handoff --all');
|
|
1106
|
+
}
|
|
1107
|
+
let ok = true;
|
|
1108
|
+
for (const target of targets) {
|
|
1109
|
+
if (!(await verifyPackageOne(target))) ok = false;
|
|
1110
|
+
}
|
|
1111
|
+
if (!ok) error('Package verification failed.');
|
|
1112
|
+
return { valid: true };
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function verifyOne(target, showProvenance) {
|
|
1116
|
+
const installPath = target.path;
|
|
1117
|
+
|
|
1118
|
+
console.log(`\n${C('bold', 'Verifying agents-handoff installation...')}`);
|
|
1119
|
+
console.log(`Location: ${installPath}`);
|
|
1120
|
+
if (!MULTI_TARGET && !CUSTOM_PATH && LOCATION === 'global') console.log(`Global root: ${resolveGlobalRoot().root}`);
|
|
1121
|
+
console.log();
|
|
1122
|
+
|
|
1123
|
+
if (!isInstalled(installPath)) {
|
|
1124
|
+
error(`Not installed at ${installPath}`);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const checks = [];
|
|
1128
|
+
let allPassed = true;
|
|
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));
|
|
1145
|
+
checks.push({ name: `File: ${file}`, passed: exists });
|
|
1146
|
+
if (!exists) allPassed = false;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// Check SKILL.md has version
|
|
1150
|
+
try {
|
|
1151
|
+
const skillMd = fs.readFileSync(path.join(installPath, 'SKILL.md'), 'utf8');
|
|
1152
|
+
const hasVersion = /^version:/m.test(skillMd);
|
|
1153
|
+
const hasName = /^name:/m.test(skillMd);
|
|
1154
|
+
checks.push({ name: 'SKILL.md: has version', passed: hasVersion });
|
|
1155
|
+
checks.push({ name: 'SKILL.md: has name', passed: hasName });
|
|
1156
|
+
if (!hasVersion || !hasName) allPassed = false;
|
|
1157
|
+
} catch {
|
|
1158
|
+
checks.push({ name: 'SKILL.md: readable', passed: false });
|
|
1159
|
+
allPassed = false;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Check the tools actually run. `--help` is NOT a verb of the engine (it exits 2 and
|
|
1163
|
+
// prints usage to stderr), so the previous probe reported EVERY correct installation as
|
|
1164
|
+
// broken. `config` exits 0 and prints a stable marker, and it exercises the engine's
|
|
1165
|
+
// import graph - including tools/lib/handoff-root.mjs - so a missing module fails here.
|
|
1166
|
+
try {
|
|
1167
|
+
const handoffPath = path.join(installPath, 'tools', 'handoff.mjs');
|
|
1168
|
+
const result = spawnSync(process.execPath, [handoffPath, 'config'], { cwd: installPath, encoding: 'utf8' });
|
|
1169
|
+
const isRunnable = result.status === 0 && String(result.stdout).includes('handoff: config root=');
|
|
1170
|
+
checks.push({ name: 'handoff.mjs config: exits 0 with the resolved-root marker', passed: isRunnable });
|
|
1171
|
+
if (!isRunnable) {
|
|
1172
|
+
console.error(String(result.stderr || '').trim().slice(0, 400));
|
|
1173
|
+
allPassed = false;
|
|
1174
|
+
}
|
|
1175
|
+
} catch {
|
|
1176
|
+
checks.push({ name: 'handoff.mjs config: runs', passed: false });
|
|
1177
|
+
allPassed = false;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Print results
|
|
1181
|
+
for (const check of checks) {
|
|
1182
|
+
log(` ${check.passed ? C('green', '✓') : C('red', '✗')} ${check.name}`);
|
|
1183
|
+
}
|
|
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
|
+
|
|
1205
|
+
console.log();
|
|
1206
|
+
if (allPassed) {
|
|
1207
|
+
success(`Installation verified ✓`);
|
|
1208
|
+
log(`Location: ${installPath}`);
|
|
1209
|
+
log(`Version: ${getInstalledVersion(installPath) || 'unknown'}`);
|
|
1210
|
+
return { valid: true, path: installPath };
|
|
1211
|
+
}
|
|
1212
|
+
console.error(`Verification failed for ${installPath}`);
|
|
1213
|
+
return { valid: false, path: installPath };
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// List installations
|
|
1217
|
+
function listInstallations() {
|
|
1218
|
+
console.log(`\n${C('bold', 'Installed agents-handoff locations')}\n`);
|
|
1219
|
+
|
|
1220
|
+
const g = resolveGlobalRoot();
|
|
1221
|
+
const locations = [
|
|
1222
|
+
// The resolved global root first: this is the one `install` and `verify` use.
|
|
1223
|
+
{ name: 'Global (resolved — ' + g.why + ')', root: g.root },
|
|
1224
|
+
{ name: 'Local (cwd/local/skills)', root: PATHS.local },
|
|
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
|
+
}
|
|
1232
|
+
// Every other candidate root is listed too, so a second account or a stale
|
|
1233
|
+
// ~/.agents/skills copy is VISIBLE rather than silently ignored.
|
|
1234
|
+
for (const d of accountSkillRoots()) locations.push({ name: 'account-skill candidate', root: d });
|
|
1235
|
+
locations.push({ name: 'Harness skills home', root: AGENTS_SKILLS });
|
|
1236
|
+
if (fs.existsSync(path.join(process.cwd(), '.git'))) {
|
|
1237
|
+
locations.push({ name: 'Project-local (cwd/skills)', root: PATHS.project });
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
let foundAny = false;
|
|
1241
|
+
const seen = new Set();
|
|
1242
|
+
|
|
1243
|
+
const candidates = [];
|
|
1244
|
+
for (const loc of locations) {
|
|
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;
|
|
1253
|
+
if (seen.has(skillPath)) continue;
|
|
1254
|
+
seen.add(skillPath);
|
|
1255
|
+
if (isInstalled(skillPath)) {
|
|
1256
|
+
const loc = { name: cand.name };
|
|
1257
|
+
foundAny = true;
|
|
1258
|
+
const version = getInstalledVersion(skillPath) || 'unknown';
|
|
1259
|
+
log(`${C('green', '✓')} ${loc.name}`);
|
|
1260
|
+
log(` Path: ${skillPath}`);
|
|
1261
|
+
log(` Version: ${version}`);
|
|
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'}`);
|
|
1267
|
+
log();
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
if (!foundAny) {
|
|
1272
|
+
log(`${C('yellow', 'No installations found.')}`);
|
|
1273
|
+
log(`Install with: npx agents-handoff`);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
return foundAny;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// Where `--location global` would land, and why — the same answer install/verify/update use.
|
|
1280
|
+
function printGlobalResolution() {
|
|
1281
|
+
const g = resolveGlobalRoot();
|
|
1282
|
+
log(`\nGlobal install root: ${g.root}`);
|
|
1283
|
+
log(` chosen because: ${g.why}`);
|
|
1284
|
+
for (const other of g.others || []) log(` also holds an install: ${other}`);
|
|
1285
|
+
log(` override with: AGENT_HANDOFF_GLOBAL_DIR=<dir> or --path <dir>`);
|
|
1286
|
+
return g;
|
|
1287
|
+
}
|
|
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
|
+
|
|
1331
|
+
// Main
|
|
1332
|
+
async function main() {
|
|
1333
|
+
try {
|
|
1334
|
+
switch (COMMAND) {
|
|
1335
|
+
case 'install':
|
|
1336
|
+
case 'i':
|
|
1337
|
+
await install(LOCATION, CUSTOM_PATH, VERSION, FORCE);
|
|
1338
|
+
break;
|
|
1339
|
+
|
|
1340
|
+
case 'update':
|
|
1341
|
+
case 'u':
|
|
1342
|
+
await update(LOCATION, CUSTOM_PATH, VERSION);
|
|
1343
|
+
break;
|
|
1344
|
+
|
|
1345
|
+
case 'remove':
|
|
1346
|
+
case 'rm':
|
|
1347
|
+
await remove(LOCATION, CUSTOM_PATH, FORCE);
|
|
1348
|
+
break;
|
|
1349
|
+
|
|
1350
|
+
case 'verify':
|
|
1351
|
+
case 'v':
|
|
1352
|
+
await verify(LOCATION, CUSTOM_PATH);
|
|
1353
|
+
break;
|
|
1354
|
+
|
|
1355
|
+
case 'verify-package':
|
|
1356
|
+
case 'vp':
|
|
1357
|
+
await verifyPackage();
|
|
1358
|
+
break;
|
|
1359
|
+
|
|
1360
|
+
case 'list':
|
|
1361
|
+
case 'ls':
|
|
1362
|
+
listInstallations();
|
|
1363
|
+
break;
|
|
1364
|
+
|
|
1365
|
+
case 'where':
|
|
1366
|
+
printGlobalResolution();
|
|
1367
|
+
break;
|
|
1368
|
+
|
|
1369
|
+
case 'doctor':
|
|
1370
|
+
case 'doc':
|
|
1371
|
+
doctor();
|
|
1372
|
+
break;
|
|
1373
|
+
|
|
1374
|
+
case '--help':
|
|
1375
|
+
case '-h':
|
|
1376
|
+
case 'help':
|
|
1377
|
+
showHelp();
|
|
1378
|
+
break;
|
|
1379
|
+
|
|
1380
|
+
default:
|
|
1381
|
+
error(`Unknown command: ${COMMAND}. Use install, update, remove, verify, or list.`);
|
|
1382
|
+
}
|
|
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);
|
|
1388
|
+
error(`Error: ${e.message}`);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function showHelp() {
|
|
1393
|
+
console.log(`
|
|
1394
|
+
${C('bold', 'agents-handoff')} - Install assistant for agents-handoff skill
|
|
1395
|
+
|
|
1396
|
+
${C('bold', 'Usage:')}
|
|
1397
|
+
npx agents-handoff <command> [options]
|
|
1398
|
+
|
|
1399
|
+
${C('bold', 'Commands:')}
|
|
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)
|
|
1420
|
+
|
|
1421
|
+
${C('bold', 'Options:')}
|
|
1422
|
+
--location L Install location: global (default), local, project
|
|
1423
|
+
--path P Custom installation path
|
|
1424
|
+
--version V Version to install: latest (default) or specific version
|
|
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
|
|
1428
|
+
|
|
1429
|
+
${C('bold', 'Examples:')}
|
|
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
|
|
1438
|
+
|
|
1439
|
+
${C('bold', 'Locations:')}
|
|
1440
|
+
global resolved, not hard-coded: an account-skill root that already holds
|
|
1441
|
+
agents-handoff, else ~/.agents/skills, else any account-skill store found on this
|
|
1442
|
+
machine, else ~/.agents/skills (created on install). A store holds the skill two
|
|
1443
|
+
id levels below it — <store>/<account-id>/<profile-id>/agents-handoff/ — so the
|
|
1444
|
+
store itself is not an install target.
|
|
1445
|
+
Override with AGENT_HANDOFF_GLOBAL_DIR, or target an exact path with --path.
|
|
1446
|
+
See it resolved: npx agents-handoff where
|
|
1447
|
+
local ./local/skills/agents-handoff
|
|
1448
|
+
project ./skills/agents-handoff (only if in a git repo)
|
|
1449
|
+
|
|
1450
|
+
${C('bold', 'Repository:')}
|
|
1451
|
+
https://github.com/${REPO_OWNER}/${REPO_NAME}
|
|
1452
|
+
`);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
main();
|