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.
- package/CHANGELOG.md +150 -0
- package/LICENSE +21 -0
- package/README.md +110 -2
- package/SKILL.md +147 -0
- package/capability-registry.json +27 -0
- package/docs/ARCHITECTURE.md +164 -0
- package/docs/CHANGELOG.md +151 -0
- package/docs/CLI.md +196 -0
- package/docs/COMPATIBILITY.md +124 -0
- package/docs/CONTRIBUTING.md +134 -0
- package/docs/FORMAT.md +157 -0
- package/docs/INSTALL.md +179 -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 +83 -0
- package/docs/SECURITY.md +93 -0
- package/docs/SESSIONS.md +66 -0
- package/docs/TROUBLESHOOTING.md +158 -0
- package/docs/UNINSTALL.md +122 -0
- package/docs/UPGRADE.md +139 -0
- package/docs/_config.yml +16 -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 +83 -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 +856 -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 +410 -0
- package/tools/capability-registry.mjs +120 -0
- package/tools/handoff.mjs +398 -0
- package/tools/handoff.test.mjs +465 -0
- package/tools/lib/handoff-root.mjs +161 -0
- 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();
|