agent-director 0.4.0 → 0.4.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/README.md +62 -0
- package/dist/client.d.ts +99 -0
- package/dist/errors.d.ts +173 -0
- package/dist/ffi.d.ts +42 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +603 -0
- package/dist/internal/bindingSpec.d.ts +37 -0
- package/dist/internal/bootstrapFfi.d.ts +37 -0
- package/dist/internal/freeGuard.d.ts +39 -0
- package/dist/internal/tilde.d.ts +16 -0
- package/dist/internal/tsOnlyErrors.d.ts +25 -0
- package/dist/internal/verbs.d.ts +57 -0
- package/dist/internal/worker.d.ts +36 -0
- package/dist/internal/workerProxy.d.ts +45 -0
- package/dist/platform.d.ts +80 -0
- package/dist/types.d.ts +301 -0
- package/package.json +18 -5
- package/scripts/postinstall.ts +646 -0
- package/skills/install-agent-director/SKILL.md +481 -0
- package/skills/install-agent-director/install.sh +620 -0
- package/skills/install-agent-director/uninstall.sh +210 -0
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* postinstall.ts — runs after `bun add agent-director` on a supported host.
|
|
3
|
+
*
|
|
4
|
+
* Copies the bundled install skill body from
|
|
5
|
+
* node_modules/agent-director/skills/install-agent-director/
|
|
6
|
+
* into
|
|
7
|
+
* ${HOME}/.claude/skills/install-agent-director/
|
|
8
|
+
* via an atomic-rename with a three-way decision
|
|
9
|
+
* (identical-noop / older-or-absent-overwrite-with-backup / newer-leave-alone).
|
|
10
|
+
*
|
|
11
|
+
* Authoritative contract: SRD `t1.fg3.7i` §SR-1 (the postinstall surface),
|
|
12
|
+
* Plan Bee `b.3d3` Epic 1 (postinstall-skill-copy).
|
|
13
|
+
*
|
|
14
|
+
* Constraints:
|
|
15
|
+
* - Bun runtime only. Pure node stdlib imports (SR-1.1) — no third-party
|
|
16
|
+
* deps, no import from `src/` (the FFI native binary may not be linked
|
|
17
|
+
* into `node_modules` at postinstall time).
|
|
18
|
+
* - Never writes outside `${HOME}/.claude/skills/install-agent-director/`
|
|
19
|
+
* (plus its sibling tmp + backup paths). Never touches PATH, the state
|
|
20
|
+
* DB, ~/.claude/settings.json, or install.sh.
|
|
21
|
+
*
|
|
22
|
+
* This file lands the helpers (T2) — source/destination resolution,
|
|
23
|
+
* semver compare, tree-hash, frontmatter `version:` extractor,
|
|
24
|
+
* AD_POSTINSTALL_VERBOSE reader, and a stub `main()`. The three-way
|
|
25
|
+
* branches and atomic-write algorithm land in T3; output budget in T4;
|
|
26
|
+
* the platform refusal lands in Epic 2 T2.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
30
|
+
import {
|
|
31
|
+
closeSync,
|
|
32
|
+
copyFileSync,
|
|
33
|
+
existsSync,
|
|
34
|
+
fsyncSync,
|
|
35
|
+
mkdirSync,
|
|
36
|
+
openSync,
|
|
37
|
+
readdirSync,
|
|
38
|
+
readFileSync,
|
|
39
|
+
renameSync,
|
|
40
|
+
rmSync,
|
|
41
|
+
statSync,
|
|
42
|
+
} from "node:fs";
|
|
43
|
+
import { homedir } from "node:os";
|
|
44
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
45
|
+
import { fileURLToPath } from "node:url";
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Host-pair refusal (SR-3.2)
|
|
49
|
+
// Runs before everything else — the dev-tree guard, source resolution, and
|
|
50
|
+
// any filesystem operation. Catches the darwin/x64 and linux/arm64 cross-
|
|
51
|
+
// product members admitted by the umbrella's coarse `os`/`cpu` gate
|
|
52
|
+
// (package.json SR-3.1) that should NOT actually install on this host.
|
|
53
|
+
// Refused hosts exit 1 with a single stderr line in the SR-3.2 format.
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
const platform = process.platform;
|
|
58
|
+
const arch = process.arch;
|
|
59
|
+
const supported =
|
|
60
|
+
(platform === "linux" && arch === "x64") ||
|
|
61
|
+
(platform === "darwin" && arch === "arm64");
|
|
62
|
+
if (!supported) {
|
|
63
|
+
process.stderr.write(
|
|
64
|
+
`agent-director: unsupported host: ${platform}/${arch}. Supported: linux/x64, darwin/arm64. See b.fg3 for cross-platform expansion status.\n`,
|
|
65
|
+
);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Constants
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
const PACKAGE_NAME = "agent-director";
|
|
75
|
+
const SKILL_REL_PATH = join("skills", "install-agent-director");
|
|
76
|
+
const DEST_REL_FROM_HOME = join(".claude", "skills", "install-agent-director");
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// AD_POSTINSTALL_VERBOSE env reader (SR-1.6)
|
|
80
|
+
// Truthy iff "1", "true", or "yes" (case-insensitive). Anything else
|
|
81
|
+
// (including empty/unset) is falsy.
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
export function isVerbose(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
85
|
+
const raw = env["AD_POSTINSTALL_VERBOSE"];
|
|
86
|
+
if (raw === undefined) return false;
|
|
87
|
+
const v = raw.trim().toLowerCase();
|
|
88
|
+
return v === "1" || v === "true" || v === "yes";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Source resolution (SR-1.2)
|
|
93
|
+
// Walk up from import.meta.url to the package root, then descend into
|
|
94
|
+
// skills/install-agent-director/. The package root is recognized by a
|
|
95
|
+
// sibling package.json whose `name` is "agent-director".
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
export function resolvePackageRoot(startFile: string): string {
|
|
99
|
+
let dir = dirname(resolve(startFile));
|
|
100
|
+
// Bounded walk so a misplaced script never traverses to "/".
|
|
101
|
+
for (let i = 0; i < 32; i++) {
|
|
102
|
+
const pkgJson = join(dir, "package.json");
|
|
103
|
+
try {
|
|
104
|
+
const raw = readFileSync(pkgJson, "utf8");
|
|
105
|
+
const parsed = JSON.parse(raw) as { name?: string };
|
|
106
|
+
if (parsed.name === PACKAGE_NAME) return dir;
|
|
107
|
+
} catch {
|
|
108
|
+
// Either missing package.json or not the umbrella — keep walking.
|
|
109
|
+
}
|
|
110
|
+
const parent = dirname(dir);
|
|
111
|
+
if (parent === dir) break;
|
|
112
|
+
dir = parent;
|
|
113
|
+
}
|
|
114
|
+
throw new Error(
|
|
115
|
+
`postinstall: could not locate ${PACKAGE_NAME} package.json walking up from ${startFile}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function resolveSourceSkillDir(packageRoot: string): string {
|
|
120
|
+
return join(packageRoot, SKILL_REL_PATH);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function resolveDestSkillDir(): string {
|
|
124
|
+
return join(homedir(), DEST_REL_FROM_HOME);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Frontmatter `version:` extractor (SR-1.3)
|
|
129
|
+
// Reads a SKILL.md, parses the `---`-delimited YAML frontmatter block, and
|
|
130
|
+
// returns the `version:` value as a string. Missing file, missing
|
|
131
|
+
// frontmatter, or missing `version` key → "0.0.0" (the older-or-absent
|
|
132
|
+
// fallback).
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
export function readFrontmatterVersion(skillMdPath: string): string {
|
|
136
|
+
let raw: string;
|
|
137
|
+
try {
|
|
138
|
+
raw = readFileSync(skillMdPath, "utf8");
|
|
139
|
+
} catch {
|
|
140
|
+
return "0.0.0";
|
|
141
|
+
}
|
|
142
|
+
// Frontmatter must be the very first thing in the file. The opening fence
|
|
143
|
+
// is "---" on its own line; the closing fence is the next "---" on its
|
|
144
|
+
// own line.
|
|
145
|
+
const lines = raw.split(/\r?\n/);
|
|
146
|
+
if (lines[0] !== "---") return "0.0.0";
|
|
147
|
+
for (let i = 1; i < lines.length; i++) {
|
|
148
|
+
const line = lines[i] ?? "";
|
|
149
|
+
if (line === "---") break;
|
|
150
|
+
// Flat YAML only: `key: value` per line.
|
|
151
|
+
const m = /^version:\s*(.+?)\s*$/.exec(line);
|
|
152
|
+
if (m && m[1] !== undefined) {
|
|
153
|
+
// Strip surrounding quotes if any (YAML allows both).
|
|
154
|
+
return m[1].replace(/^["']|["']$/g, "");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return "0.0.0";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Semver comparator (SR-1.3, semver §11)
|
|
162
|
+
// Parses M.m.p[-prerelease][+build]. Returns -1/0/+1. Inputs that fail to
|
|
163
|
+
// parse as semver are treated as "0.0.0".
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
type ParsedSemver = {
|
|
167
|
+
major: number;
|
|
168
|
+
minor: number;
|
|
169
|
+
patch: number;
|
|
170
|
+
prerelease: string[]; // empty array means no prerelease (higher precedence)
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
function parseSemver(s: string): ParsedSemver {
|
|
174
|
+
// Strip build metadata (after "+") — ignored for precedence per semver §10.
|
|
175
|
+
const noBuild = s.split("+", 1)[0] ?? s;
|
|
176
|
+
const dashIdx = noBuild.indexOf("-");
|
|
177
|
+
const core = dashIdx >= 0 ? noBuild.slice(0, dashIdx) : noBuild;
|
|
178
|
+
const pre = dashIdx >= 0 ? noBuild.slice(dashIdx + 1) : "";
|
|
179
|
+
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(core);
|
|
180
|
+
if (!m) return { major: 0, minor: 0, patch: 0, prerelease: [] };
|
|
181
|
+
const prerelease = pre === "" ? [] : pre.split(".");
|
|
182
|
+
// Validate prerelease identifiers — alphanumerics + hyphens only per §9.
|
|
183
|
+
for (const id of prerelease) {
|
|
184
|
+
if (id === "" || !/^[0-9A-Za-z-]+$/.test(id)) {
|
|
185
|
+
return { major: 0, minor: 0, patch: 0, prerelease: [] };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
major: Number(m[1]),
|
|
190
|
+
minor: Number(m[2]),
|
|
191
|
+
patch: Number(m[3]),
|
|
192
|
+
prerelease,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function compareIds(a: string, b: string): number {
|
|
197
|
+
const aNum = /^\d+$/.test(a);
|
|
198
|
+
const bNum = /^\d+$/.test(b);
|
|
199
|
+
if (aNum && bNum) {
|
|
200
|
+
const an = Number(a);
|
|
201
|
+
const bn = Number(b);
|
|
202
|
+
return an < bn ? -1 : an > bn ? 1 : 0;
|
|
203
|
+
}
|
|
204
|
+
// Numeric identifiers have lower precedence than non-numeric (§11).
|
|
205
|
+
if (aNum) return -1;
|
|
206
|
+
if (bNum) return 1;
|
|
207
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function compareSemver(a: string, b: string): -1 | 0 | 1 {
|
|
211
|
+
const pa = parseSemver(a);
|
|
212
|
+
const pb = parseSemver(b);
|
|
213
|
+
if (pa.major !== pb.major) return pa.major < pb.major ? -1 : 1;
|
|
214
|
+
if (pa.minor !== pb.minor) return pa.minor < pb.minor ? -1 : 1;
|
|
215
|
+
if (pa.patch !== pb.patch) return pa.patch < pb.patch ? -1 : 1;
|
|
216
|
+
// §11.4: a version with prerelease has lower precedence than one without.
|
|
217
|
+
const aPre = pa.prerelease.length > 0;
|
|
218
|
+
const bPre = pb.prerelease.length > 0;
|
|
219
|
+
if (aPre && !bPre) return -1;
|
|
220
|
+
if (!aPre && bPre) return 1;
|
|
221
|
+
if (!aPre && !bPre) return 0;
|
|
222
|
+
// Compare prerelease identifiers left-to-right.
|
|
223
|
+
const len = Math.min(pa.prerelease.length, pb.prerelease.length);
|
|
224
|
+
for (let i = 0; i < len; i++) {
|
|
225
|
+
const cmp = compareIds(pa.prerelease[i]!, pb.prerelease[i]!);
|
|
226
|
+
if (cmp !== 0) return cmp < 0 ? -1 : 1;
|
|
227
|
+
}
|
|
228
|
+
// Longer prerelease wins (§11.4.4).
|
|
229
|
+
if (pa.prerelease.length < pb.prerelease.length) return -1;
|
|
230
|
+
if (pa.prerelease.length > pb.prerelease.length) return 1;
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Tree-hash (SR-1.4)
|
|
236
|
+
// SHA-256 of `<rel-path>\n<sha256-of-file-bytes>\n` concatenated in sorted
|
|
237
|
+
// order. Stable across operator chmod/chown. Cheaper than mtime walks.
|
|
238
|
+
// Directories that don't exist hash to the empty string's SHA-256 of the
|
|
239
|
+
// canonical empty manifest — they cannot match a non-empty source, so the
|
|
240
|
+
// caller's "destination missing" branch never collides with identical.
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
function listFilesRecursive(root: string): string[] {
|
|
244
|
+
const out: string[] = [];
|
|
245
|
+
const walk = (dir: string): void => {
|
|
246
|
+
let entries;
|
|
247
|
+
try {
|
|
248
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
249
|
+
} catch {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
for (const ent of entries) {
|
|
253
|
+
const full = join(dir, ent.name);
|
|
254
|
+
if (ent.isDirectory()) walk(full);
|
|
255
|
+
else if (ent.isFile()) out.push(full);
|
|
256
|
+
// Symlinks in the *source* are followed (they're package content); the
|
|
257
|
+
// copy step in T3 materializes them as regular files. For tree-hashing
|
|
258
|
+
// we only ever read source/destination trees that are themselves
|
|
259
|
+
// regular files post-copy, so symlinks here would be source-side only.
|
|
260
|
+
else if (ent.isSymbolicLink()) {
|
|
261
|
+
try {
|
|
262
|
+
const st = statSync(full);
|
|
263
|
+
if (st.isFile()) out.push(full);
|
|
264
|
+
} catch {
|
|
265
|
+
// dangling symlink — skip
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
walk(root);
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function treeHash(root: string): string {
|
|
275
|
+
const files = listFilesRecursive(root).sort();
|
|
276
|
+
const outer = createHash("sha256");
|
|
277
|
+
for (const abs of files) {
|
|
278
|
+
const rel = relative(root, abs);
|
|
279
|
+
const inner = createHash("sha256");
|
|
280
|
+
inner.update(readFileSync(abs));
|
|
281
|
+
outer.update(rel);
|
|
282
|
+
outer.update("\n");
|
|
283
|
+
outer.update(inner.digest("hex"));
|
|
284
|
+
outer.update("\n");
|
|
285
|
+
}
|
|
286
|
+
return outer.digest("hex");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Defensive tmp cleanup (SR-1.5)
|
|
291
|
+
// Scan ${HOME}/.claude/skills/ for siblings whose name begins with the literal
|
|
292
|
+
// prefix ".install-agent-director.tmp." and rmSync them recursively. Must
|
|
293
|
+
// NOT match the destination "install-agent-director" itself, nor any backup
|
|
294
|
+
// sibling "install-agent-director.bak.<ts>".
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
const TMP_PREFIX = ".install-agent-director.tmp.";
|
|
298
|
+
|
|
299
|
+
function cleanupOrphanedTmpSiblings(skillsParent: string): void {
|
|
300
|
+
let entries;
|
|
301
|
+
try {
|
|
302
|
+
entries = readdirSync(skillsParent, { withFileTypes: true });
|
|
303
|
+
} catch {
|
|
304
|
+
return; // parent doesn't exist yet — nothing to clean
|
|
305
|
+
}
|
|
306
|
+
for (const ent of entries) {
|
|
307
|
+
if (!ent.name.startsWith(TMP_PREFIX)) continue;
|
|
308
|
+
const full = join(skillsParent, ent.name);
|
|
309
|
+
try {
|
|
310
|
+
rmSync(full, { recursive: true, force: true });
|
|
311
|
+
} catch {
|
|
312
|
+
// best-effort; don't abort the install over a leftover we can't remove
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// Recursive copy (SR-1.5)
|
|
319
|
+
// Mirrors the source tree under a fresh destination directory. Symlinks in
|
|
320
|
+
// the source (package content, not operator-supplied) are followed and
|
|
321
|
+
// materialized as regular files. fsyncs every written file and the containing
|
|
322
|
+
// directories so a crash-before-rename leaves nothing half-flushed.
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
function fsyncDir(dir: string): void {
|
|
326
|
+
// POSIX: open + fsync the directory inode so the entry rename is durable.
|
|
327
|
+
try {
|
|
328
|
+
const fd = openSync(dir, "r");
|
|
329
|
+
try {
|
|
330
|
+
fsyncSync(fd);
|
|
331
|
+
} finally {
|
|
332
|
+
closeSync(fd);
|
|
333
|
+
}
|
|
334
|
+
} catch {
|
|
335
|
+
// fsync on a directory may not be supported on every fs; non-fatal.
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function fsyncFile(path: string): void {
|
|
340
|
+
try {
|
|
341
|
+
const fd = openSync(path, "r");
|
|
342
|
+
try {
|
|
343
|
+
fsyncSync(fd);
|
|
344
|
+
} finally {
|
|
345
|
+
closeSync(fd);
|
|
346
|
+
}
|
|
347
|
+
} catch {
|
|
348
|
+
// non-fatal
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function copyTree(src: string, dest: string): void {
|
|
353
|
+
const st = statSync(src); // follows symlinks → regular files materialized
|
|
354
|
+
if (st.isDirectory()) {
|
|
355
|
+
mkdirSync(dest, { recursive: true, mode: st.mode & 0o777 });
|
|
356
|
+
const entries = readdirSync(src, { withFileTypes: true });
|
|
357
|
+
for (const ent of entries) {
|
|
358
|
+
const childSrc = join(src, ent.name);
|
|
359
|
+
const childDest = join(dest, ent.name);
|
|
360
|
+
copyTree(childSrc, childDest);
|
|
361
|
+
}
|
|
362
|
+
fsyncDir(dest);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (st.isFile()) {
|
|
366
|
+
copyFileSync(src, dest);
|
|
367
|
+
fsyncFile(dest);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
// Anything else (FIFO, socket, char/block device) is not expected in a
|
|
371
|
+
// skill body and is silently skipped.
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// Atomic-write algorithm (SR-1.5)
|
|
376
|
+
// 1. Compute tmp dir under skillsParent: ".install-agent-director.tmp.<pid>.<8hex>"
|
|
377
|
+
// 2. Copy source tree into tmp dir; fsync files + dir.
|
|
378
|
+
// 3. If prior dest exists, copy to "install-agent-director.bak.<unix-ts>" (best-effort).
|
|
379
|
+
// 4. If prior dest exists, rmSync it.
|
|
380
|
+
// 5. rename(tmp, dest).
|
|
381
|
+
// 6. On rename failure: rmSync tmp and propagate.
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
type AtomicWriteResult = {
|
|
385
|
+
backupPath: string | null;
|
|
386
|
+
backupError: Error | null;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
function atomicWriteSkillTree(
|
|
390
|
+
source: string,
|
|
391
|
+
dest: string,
|
|
392
|
+
skillsParent: string,
|
|
393
|
+
): AtomicWriteResult {
|
|
394
|
+
const pid = process.pid;
|
|
395
|
+
const rand = randomBytes(8).toString("hex").slice(0, 8);
|
|
396
|
+
const tmpDir = join(skillsParent, `${TMP_PREFIX}${pid}.${rand}`);
|
|
397
|
+
|
|
398
|
+
// 1+2: stage tmp tree
|
|
399
|
+
try {
|
|
400
|
+
copyTree(source, tmpDir);
|
|
401
|
+
} catch (err) {
|
|
402
|
+
try {
|
|
403
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
404
|
+
} catch {
|
|
405
|
+
// ignore
|
|
406
|
+
}
|
|
407
|
+
throw err;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 3: best-effort backup of prior dest, if any
|
|
411
|
+
let backupPath: string | null = null;
|
|
412
|
+
let backupError: Error | null = null;
|
|
413
|
+
const priorExists = existsSync(dest);
|
|
414
|
+
if (priorExists) {
|
|
415
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
416
|
+
backupPath = join(skillsParent, `install-agent-director.bak.${ts}`);
|
|
417
|
+
try {
|
|
418
|
+
// If a same-second backup somehow already exists, fall back to a
|
|
419
|
+
// collision-safe sibling so we never overwrite an older backup.
|
|
420
|
+
let target = backupPath;
|
|
421
|
+
let suffix = 0;
|
|
422
|
+
while (existsSync(target)) {
|
|
423
|
+
suffix += 1;
|
|
424
|
+
target = `${backupPath}.${suffix}`;
|
|
425
|
+
}
|
|
426
|
+
backupPath = target;
|
|
427
|
+
copyTree(dest, backupPath);
|
|
428
|
+
} catch (err) {
|
|
429
|
+
backupError = err instanceof Error ? err : new Error(String(err));
|
|
430
|
+
// continue per SR-1.5: backup is best-effort; never abort the overwrite
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// 4: remove prior dest so rename target is clear
|
|
435
|
+
if (priorExists) {
|
|
436
|
+
try {
|
|
437
|
+
rmSync(dest, { recursive: true, force: true });
|
|
438
|
+
} catch (err) {
|
|
439
|
+
try {
|
|
440
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
441
|
+
} catch {
|
|
442
|
+
// ignore
|
|
443
|
+
}
|
|
444
|
+
throw err;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// 5: rename tmp into place
|
|
449
|
+
try {
|
|
450
|
+
renameSync(tmpDir, dest);
|
|
451
|
+
} catch (err) {
|
|
452
|
+
try {
|
|
453
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
454
|
+
} catch {
|
|
455
|
+
// ignore
|
|
456
|
+
}
|
|
457
|
+
throw err;
|
|
458
|
+
}
|
|
459
|
+
fsyncDir(dirname(dest));
|
|
460
|
+
|
|
461
|
+
return { backupPath, backupError };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
// OutputBudget (SR-1.6)
|
|
466
|
+
// Default-quiet: ≤ 5 lines combined across stdout + stderr. Verbose mode
|
|
467
|
+
// (AD_POSTINSTALL_VERBOSE=1|true|yes) lifts the cap. Every emitted line is
|
|
468
|
+
// prefixed `agent-director: `. The newer-branch warning carries
|
|
469
|
+
// neverSuppress=true so it always reaches stderr (still counts against the
|
|
470
|
+
// cap; defensive in case future code emits before reaching the warning).
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
const OUTPUT_LINE_PREFIX = "agent-director: ";
|
|
474
|
+
const DEFAULT_LINE_CAP = 5;
|
|
475
|
+
|
|
476
|
+
class OutputBudget {
|
|
477
|
+
private emitted = 0;
|
|
478
|
+
|
|
479
|
+
constructor(
|
|
480
|
+
private readonly verbose: boolean,
|
|
481
|
+
private readonly cap: number = DEFAULT_LINE_CAP,
|
|
482
|
+
) {}
|
|
483
|
+
|
|
484
|
+
info(message: string): void {
|
|
485
|
+
this.emit("stdout", message, false);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
warn(message: string, options: { neverSuppress?: boolean } = {}): void {
|
|
489
|
+
this.emit("stderr", message, options.neverSuppress === true);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
error(message: string, options: { neverSuppress?: boolean } = {}): void {
|
|
493
|
+
this.emit("stderr", message, options.neverSuppress === true);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private emit(
|
|
497
|
+
stream: "stdout" | "stderr",
|
|
498
|
+
message: string,
|
|
499
|
+
neverSuppress: boolean,
|
|
500
|
+
): void {
|
|
501
|
+
if (!this.verbose && !neverSuppress && this.emitted >= this.cap) {
|
|
502
|
+
return; // budget exhausted; drop silently per SR-1.6
|
|
503
|
+
}
|
|
504
|
+
const line = `${OUTPUT_LINE_PREFIX}${message}\n`;
|
|
505
|
+
if (stream === "stdout") {
|
|
506
|
+
process.stdout.write(line);
|
|
507
|
+
} else {
|
|
508
|
+
process.stderr.write(line);
|
|
509
|
+
}
|
|
510
|
+
this.emitted += 1;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
// main() — three-way decision + atomic-write
|
|
516
|
+
// All output flows through OutputBudget. The identical branch never reaches
|
|
517
|
+
// the budget at all (zero output by construction).
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
const HOME_TILDE_DEST = "~/.claude/skills/install-agent-director/";
|
|
521
|
+
|
|
522
|
+
function main(): number {
|
|
523
|
+
const verbose = isVerbose();
|
|
524
|
+
const out = new OutputBudget(verbose);
|
|
525
|
+
|
|
526
|
+
if (verbose) {
|
|
527
|
+
out.info("postinstall start");
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const here = fileURLToPath(import.meta.url);
|
|
531
|
+
|
|
532
|
+
// Dev-tree guard. The postinstall is meant to fire when the umbrella is
|
|
533
|
+
// installed as a dependency (i.e. the script lives under node_modules/).
|
|
534
|
+
// Inside the dev checkout — say a maintainer running `bun install` in
|
|
535
|
+
// pkg/ts-bun-client/ — there is no consumer to copy the skill into; we
|
|
536
|
+
// silently no-op so the maintainer's real ~/.claude/skills/ is never
|
|
537
|
+
// touched by a routine dev install.
|
|
538
|
+
if (!here.includes("/node_modules/")) {
|
|
539
|
+
if (verbose) {
|
|
540
|
+
out.info("postinstall skipped (dev tree; not under node_modules/)");
|
|
541
|
+
}
|
|
542
|
+
return 0;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
let packageRoot: string;
|
|
546
|
+
try {
|
|
547
|
+
packageRoot = resolvePackageRoot(here);
|
|
548
|
+
} catch (err) {
|
|
549
|
+
out.error(
|
|
550
|
+
`postinstall: ${err instanceof Error ? err.message : String(err)}`,
|
|
551
|
+
);
|
|
552
|
+
return 1;
|
|
553
|
+
}
|
|
554
|
+
const sourceDir = resolveSourceSkillDir(packageRoot);
|
|
555
|
+
const destDir = resolveDestSkillDir();
|
|
556
|
+
const skillsParent = dirname(destDir);
|
|
557
|
+
|
|
558
|
+
if (verbose) {
|
|
559
|
+
out.info(`source=${sourceDir}`);
|
|
560
|
+
out.info(`dest=${destDir}`);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Source must exist — otherwise the published tarball is malformed.
|
|
564
|
+
if (!existsSync(sourceDir)) {
|
|
565
|
+
out.error(`postinstall: bundled skill body missing at ${sourceDir}`);
|
|
566
|
+
return 1;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Ensure ${HOME}/.claude/skills/ exists.
|
|
570
|
+
mkdirSync(skillsParent, { recursive: true });
|
|
571
|
+
|
|
572
|
+
// Defensive cleanup of any prior interrupted run's tmp tree.
|
|
573
|
+
cleanupOrphanedTmpSiblings(skillsParent);
|
|
574
|
+
|
|
575
|
+
// Three-way decision.
|
|
576
|
+
const sourceSkillMd = join(sourceDir, "SKILL.md");
|
|
577
|
+
const destSkillMd = join(destDir, "SKILL.md");
|
|
578
|
+
const sourceVersion = readFrontmatterVersion(sourceSkillMd);
|
|
579
|
+
const destExists = existsSync(destDir);
|
|
580
|
+
|
|
581
|
+
if (verbose) {
|
|
582
|
+
out.info(`source-version=${sourceVersion}`);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (destExists) {
|
|
586
|
+
// identical branch: tree-hash match → silent no-op (no emits)
|
|
587
|
+
const sourceHash = treeHash(sourceDir);
|
|
588
|
+
const destHash = treeHash(destDir);
|
|
589
|
+
if (verbose) {
|
|
590
|
+
out.info(`source-tree-hash=${sourceHash}`);
|
|
591
|
+
out.info(`dest-tree-hash=${destHash}`);
|
|
592
|
+
}
|
|
593
|
+
if (sourceHash === destHash) {
|
|
594
|
+
return 0;
|
|
595
|
+
}
|
|
596
|
+
// newer branch: dest version strictly greater than source per semver §11
|
|
597
|
+
const destVersion = readFrontmatterVersion(destSkillMd);
|
|
598
|
+
if (verbose) {
|
|
599
|
+
out.info(`dest-version=${destVersion}`);
|
|
600
|
+
}
|
|
601
|
+
const cmp = compareSemver(destVersion, sourceVersion);
|
|
602
|
+
if (cmp > 0) {
|
|
603
|
+
out.warn(
|
|
604
|
+
`skill at ${HOME_TILDE_DEST} is version ${destVersion} (newer than package's ${sourceVersion}); leaving operator copy in place`,
|
|
605
|
+
{ neverSuppress: true },
|
|
606
|
+
);
|
|
607
|
+
return 0;
|
|
608
|
+
}
|
|
609
|
+
// older-or-absent branch: fall through to atomic write
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// older-or-absent branch — atomic write + best-effort backup
|
|
613
|
+
try {
|
|
614
|
+
const result = atomicWriteSkillTree(sourceDir, destDir, skillsParent);
|
|
615
|
+
if (result.backupError !== null) {
|
|
616
|
+
out.warn(
|
|
617
|
+
`backup of prior skill failed: ${result.backupError.message}; proceeding with overwrite`,
|
|
618
|
+
{ neverSuppress: true },
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
if (verbose) {
|
|
622
|
+
if (result.backupPath !== null && result.backupError === null) {
|
|
623
|
+
out.info(`backed up prior skill to ${result.backupPath}`);
|
|
624
|
+
}
|
|
625
|
+
out.info(`installed skill at ${destDir}`);
|
|
626
|
+
}
|
|
627
|
+
return 0;
|
|
628
|
+
} catch (err) {
|
|
629
|
+
out.error(
|
|
630
|
+
`postinstall: atomic write failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
631
|
+
);
|
|
632
|
+
return 1;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Run if invoked directly (not when imported by a sibling test).
|
|
637
|
+
// `import.meta.main` is a Bun-ism; fall back to argv comparison for portability.
|
|
638
|
+
const isMain =
|
|
639
|
+
// @ts-expect-error import.meta.main is Bun-specific and may be undefined
|
|
640
|
+
(typeof import.meta.main === "boolean" && import.meta.main) ||
|
|
641
|
+
// Best-effort fallback: invoked as `bun run scripts/postinstall.ts`.
|
|
642
|
+
process.argv[1] !== undefined &&
|
|
643
|
+
resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
644
|
+
if (isMain) {
|
|
645
|
+
process.exit(main());
|
|
646
|
+
}
|