@mmerterden/multi-agent-pipeline 20.8.1 → 20.8.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 +36 -0
- package/docs/facts.json +3 -3
- package/index.js +1 -0
- package/install/_codex-agents.mjs +1 -1
- package/install/_common.mjs +486 -53
- package/install/_mcp-register.mjs +173 -117
- package/install/claude.mjs +281 -220
- package/install/codex.mjs +7 -7
- package/install/copilot.mjs +13 -11
- package/install/index.mjs +92 -27
- package/install/templates/claude-hooks.json +9 -9
- package/manifest.json +75 -78
- package/package.json +4 -1
- package/pipeline/commands/multi-agent/update/SKILL.md +28 -17
- package/pipeline/lib/confusables.json +79 -33
- package/pipeline/lib/extract-conventions.sh +3 -3
- package/pipeline/lib/json-file-lock.mjs +27 -7
- package/pipeline/lib/normalize-text.mjs +86 -17
- package/pipeline/lib/outbound-gate.mjs +13 -4
- package/pipeline/lib/redact.mjs +87 -14
- package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
- package/pipeline/multi-agent-refs/analysis/synthesis.md +1 -1
- package/pipeline/multi-agent-refs/component-dispatch.md +1 -1
- package/pipeline/multi-agent-refs/conventions-defaults.md +1 -1
- package/pipeline/multi-agent-refs/features/unattended-security.md +2 -2
- package/pipeline/scripts/agent-guard.py +150 -25
- package/pipeline/scripts/audit-log.sh +3 -4
- package/pipeline/scripts/autopilot-runner.mjs +14 -5
- package/pipeline/scripts/doctor.mjs +8 -2
- package/pipeline/scripts/log-metric.sh +9 -3
- package/pipeline/scripts/migrate-prefs.mjs +18 -4
- package/pipeline/scripts/pre-commit-check.sh +119 -31
- package/pipeline/scripts/scan-agent-config.sh +9 -9
- package/pipeline/scripts/unattended_policy.py +12 -3
- package/pipeline/scripts/uninstall.mjs +88 -1
- package/pipeline/scripts/usage-identity.mjs +1 -1
- package/pipeline/scripts/usage-register.mjs +1 -1
- package/pipeline/skills/.skill-manifest.json +11 -11
- package/pipeline/skills/shared/external/callkit-voip/SKILL.md +6 -3
- package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +43 -0
- package/pipeline/skills/shared/external/core-nfc/SKILL.md +31 -0
- package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
- package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +1 -1
- package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +3 -3
- package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +2 -1
- package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +14 -13
- package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +2 -2
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +7 -7
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +105 -4
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +20 -5
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +8 -7
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +7 -8
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +20 -7
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +4 -3
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +8 -7
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +15 -8
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +17 -9
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +39 -17
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +28 -7
- package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +5 -4
- package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +3 -2
- package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +2 -2
- package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +4 -4
- package/pipeline/skills/shared/external/permissionkit/SKILL.md +15 -6
- package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +2 -1
- package/pipeline/skills/shared/external/push-notifications/SKILL.md +8 -4
- package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +1 -1
- package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +25 -6
- package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/skill-creator/template.md +1 -1
- package/pipeline/skills/shared/external/vision-framework/SKILL.md +3 -1
- package/pipeline/scripts/gen-ref-toc.mjs +0 -279
- package/pipeline/scripts/make-manifest.mjs +0 -199
- package/pipeline/scripts/scorecard-snapshot.mjs +0 -178
package/install/_common.mjs
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared filesystem helpers for install/* modules.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* trees and fails on any drift.
|
|
4
|
+
* The install layout smoke (smoke-install-layout.sh) compares the installed
|
|
5
|
+
* tree against a committed fixture, so a helper that changes what lands on
|
|
6
|
+
* disk changes that fixture too.
|
|
8
7
|
*
|
|
9
8
|
* @module install/_common
|
|
10
9
|
*/
|
|
@@ -25,8 +24,9 @@ import {
|
|
|
25
24
|
unlinkSync,
|
|
26
25
|
writeFileSync,
|
|
27
26
|
} from "fs";
|
|
28
|
-
import {
|
|
29
|
-
import { join } from "path";
|
|
27
|
+
import { createHash } from "crypto";
|
|
28
|
+
import { dirname, join, relative, sep } from "path";
|
|
29
|
+
import { fileURLToPath } from "url";
|
|
30
30
|
|
|
31
31
|
let dryRun = false;
|
|
32
32
|
|
|
@@ -55,32 +55,476 @@ export function ensureDir(dir) {
|
|
|
55
55
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
59
|
+
const PACKAGE_NAME = (() => {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf-8")).name || "";
|
|
62
|
+
} catch {
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
})();
|
|
66
|
+
|
|
67
|
+
function isWithin(path, dir) {
|
|
68
|
+
const d = dir.endsWith(sep) ? dir : dir + sep;
|
|
69
|
+
return path === dir || path.startsWith(d);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function realOr(p) {
|
|
73
|
+
try {
|
|
74
|
+
return realpathSync(p);
|
|
75
|
+
} catch {
|
|
76
|
+
return p;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* True when `real` lies inside a pipeline source tree: the one this install
|
|
82
|
+
* runs from (`pipelineSrc`'s package root), or any checkout of this package,
|
|
83
|
+
* recognised by an ancestor package.json carrying the package name. A --link
|
|
84
|
+
* install points its trees at a checkout, and that checkout need not be the
|
|
85
|
+
* one the current install runs from.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} real resolved path
|
|
88
|
+
* @param {string} [pipelineSrc]
|
|
89
|
+
* @returns {boolean}
|
|
90
|
+
*/
|
|
91
|
+
export function isInsidePipelineSource(real, pipelineSrc) {
|
|
92
|
+
const roots = [PACKAGE_ROOT];
|
|
93
|
+
if (pipelineSrc) roots.push(dirname(realOr(pipelineSrc)), realOr(pipelineSrc));
|
|
94
|
+
if (roots.some((r) => isWithin(real, realOr(r)))) return true;
|
|
95
|
+
if (!PACKAGE_NAME) return false;
|
|
96
|
+
for (let dir = real; dir !== dirname(dir); dir = dirname(dir)) {
|
|
97
|
+
try {
|
|
98
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
|
|
99
|
+
if (pkg?.name === PACKAGE_NAME) return true;
|
|
100
|
+
} catch {
|
|
101
|
+
/* no package.json here */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
58
107
|
/**
|
|
59
|
-
*
|
|
60
|
-
* into it. After a `--link` install, an owned dir (or a shared parent like
|
|
61
|
-
* `~/.claude/commands`) can be a symlink into the developer's repo checkout;
|
|
62
|
-
* wiping or copying through it would land inside the repo and destroy the
|
|
63
|
-
* pipeline sources. Non-symlink paths are left untouched.
|
|
108
|
+
* What an install target currently is.
|
|
64
109
|
*
|
|
65
110
|
* @param {string} dir
|
|
66
|
-
* @
|
|
111
|
+
* @param {string} [pipelineSrc]
|
|
112
|
+
* @returns {"missing"|"real"|"pipeline-link"|"foreign-link"}
|
|
67
113
|
*/
|
|
68
|
-
export function
|
|
114
|
+
export function classifyInstallDir(dir, pipelineSrc) {
|
|
69
115
|
let st;
|
|
70
116
|
try {
|
|
71
117
|
st = lstatSync(dir);
|
|
72
118
|
} catch {
|
|
73
|
-
return
|
|
119
|
+
return "missing";
|
|
120
|
+
}
|
|
121
|
+
if (!st.isSymbolicLink()) return "real";
|
|
122
|
+
let real;
|
|
123
|
+
try {
|
|
124
|
+
real = realpathSync(dir);
|
|
125
|
+
} catch {
|
|
126
|
+
// A dangling link points at nothing, so replacing it loses nothing.
|
|
127
|
+
return "pipeline-link";
|
|
128
|
+
}
|
|
129
|
+
return isInsidePipelineSource(real, pipelineSrc) ? "pipeline-link" : "foreign-link";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolve the directory an install step should write into.
|
|
134
|
+
*
|
|
135
|
+
* - A real directory, or a missing one: returned as is.
|
|
136
|
+
* - A symlink into a pipeline source tree (left by `--link`): replaced with an
|
|
137
|
+
* empty real directory, because wiping or copying through it would land in
|
|
138
|
+
* the checkout and destroy the sources.
|
|
139
|
+
* - A symlink anywhere else belongs to the user (a dotfiles repo, typically).
|
|
140
|
+
* For a user-owned tree (`rules/`, `agents/`, `commands/`) the link target
|
|
141
|
+
* is returned and the install writes through it; for a pipeline-managed
|
|
142
|
+
* tree the link is left alone, a warning is printed and null is returned so
|
|
143
|
+
* the caller skips the tree.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} dir
|
|
146
|
+
* @param {{pipelineSrc?: string, userOwned?: boolean}} [opts]
|
|
147
|
+
* @returns {string|null} the path to write into, or null to skip
|
|
148
|
+
*/
|
|
149
|
+
export function ensureRealDir(dir, { pipelineSrc, userOwned = false } = {}) {
|
|
150
|
+
const kind = classifyInstallDir(dir, pipelineSrc);
|
|
151
|
+
if (kind === "missing" || kind === "real") return dir;
|
|
152
|
+
if (kind === "foreign-link") {
|
|
153
|
+
const target = realOr(dir);
|
|
154
|
+
if (userOwned) {
|
|
155
|
+
console.log(` -> ${dir} is a symlink to ${target}; installing through it`);
|
|
156
|
+
return target;
|
|
157
|
+
}
|
|
158
|
+
console.log(
|
|
159
|
+
` -> WARNING: skipped ${dir}: it is a symlink to ${target}, outside the pipeline source. ` +
|
|
160
|
+
`Remove the link (or point it at a pipeline checkout) and re-run the install to update it.`,
|
|
161
|
+
);
|
|
162
|
+
return null;
|
|
74
163
|
}
|
|
75
|
-
if (!st.isSymbolicLink()) return false;
|
|
76
164
|
if (dryRun) {
|
|
77
165
|
console.log(` [dry-run] would replace symlink ${dir} with a real directory`);
|
|
78
|
-
return
|
|
166
|
+
return dir;
|
|
79
167
|
}
|
|
80
168
|
unlinkSync(dir);
|
|
81
169
|
mkdirSync(dir, { recursive: true });
|
|
82
|
-
console.log(
|
|
83
|
-
|
|
170
|
+
console.log(
|
|
171
|
+
` -> replaced symlink ${dir} with a real directory (it pointed into a pipeline checkout)`,
|
|
172
|
+
);
|
|
173
|
+
return dir;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Raised when an install cannot finish. `index.mjs` turns it into one line and
|
|
178
|
+
* a non-zero exit; the stack trace prints only with MULTI_AGENT_INSTALL_DEBUG=1.
|
|
179
|
+
*/
|
|
180
|
+
export class InstallIncompleteError extends Error {
|
|
181
|
+
/**
|
|
182
|
+
* @param {string} message
|
|
183
|
+
* @param {{cause?: unknown, untouched?: boolean}} [opts]
|
|
184
|
+
*/
|
|
185
|
+
constructor(message, { cause, untouched = false } = {}) {
|
|
186
|
+
super(message);
|
|
187
|
+
this.name = "InstallIncompleteError";
|
|
188
|
+
this.code = "INSTALL_INCOMPLETE";
|
|
189
|
+
this.untouched = untouched;
|
|
190
|
+
if (cause) this.cause = cause;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function describeFsError(err) {
|
|
195
|
+
if (!err) return "unknown error";
|
|
196
|
+
if (err.code && err.path) return `${err.code} on ${err.path}`;
|
|
197
|
+
return String(err.message || err).split("\n")[0];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Stage every managed tree under `swapRoot` (a hidden directory of the host
|
|
202
|
+
* dir), then swap them all in by rename. Staging lives outside the managed
|
|
203
|
+
* trees so a half-written or undeletable copy never sits inside `commands/`,
|
|
204
|
+
* where the host would load it as a command namespace. Nothing at a
|
|
205
|
+
* destination changes until every tree staged cleanly; a failed swap puts
|
|
206
|
+
* back the trees already swapped. Previous copies wait under `swapRoot` until
|
|
207
|
+
* every swap succeeded.
|
|
208
|
+
*
|
|
209
|
+
* A leftover staged copy from an interrupted run is discarded, a leftover
|
|
210
|
+
* previous copy is restored when its destination is missing, and anything
|
|
211
|
+
* that cannot be deleted is moved aside inside `swapRoot`.
|
|
212
|
+
*
|
|
213
|
+
* @param {Array<{dest: string, label: string, build: (staged: string) => void}>} trees
|
|
214
|
+
* @param {{swapRoot: string}} opts
|
|
215
|
+
* @throws {InstallIncompleteError}
|
|
216
|
+
*/
|
|
217
|
+
export function stageAndSwapTrees(trees, { swapRoot }) {
|
|
218
|
+
if (dryRun) {
|
|
219
|
+
for (const t of trees) console.log(` [dry-run] would stage and swap in ${t.dest}`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const paths = new Map(trees.map(({ dest }) => [dest, swapPaths(swapRoot, dest)]));
|
|
223
|
+
mkdirSync(swapRoot, { recursive: true });
|
|
224
|
+
for (const { dest } of trees) recoverInterruptedSwap(dest, paths.get(dest));
|
|
225
|
+
|
|
226
|
+
const staged = [];
|
|
227
|
+
try {
|
|
228
|
+
for (const t of trees) {
|
|
229
|
+
const tmp = paths.get(t.dest).staged;
|
|
230
|
+
staged.push(tmp);
|
|
231
|
+
t.build(tmp);
|
|
232
|
+
}
|
|
233
|
+
} catch (err) {
|
|
234
|
+
for (const tmp of staged) rmQuiet(tmp);
|
|
235
|
+
throw new InstallIncompleteError(
|
|
236
|
+
`could not stage the new files: ${describeFsError(err)}. The previous install was left in place`,
|
|
237
|
+
{ cause: err, untouched: true },
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const swapped = [];
|
|
242
|
+
try {
|
|
243
|
+
for (const { dest } of trees) {
|
|
244
|
+
const { staged: tmp, old } = paths.get(dest);
|
|
245
|
+
const had = pathExists(dest);
|
|
246
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
247
|
+
if (had) renameSync(dest, old);
|
|
248
|
+
try {
|
|
249
|
+
renameSync(tmp, dest);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
if (had) renameSync(old, dest);
|
|
252
|
+
throw err;
|
|
253
|
+
}
|
|
254
|
+
swapped.push({ dest, had, old });
|
|
255
|
+
}
|
|
256
|
+
} catch (err) {
|
|
257
|
+
for (const { dest, had, old } of swapped.reverse()) {
|
|
258
|
+
try {
|
|
259
|
+
rmSync(dest, { recursive: true, force: true });
|
|
260
|
+
if (had) renameSync(old, dest);
|
|
261
|
+
} catch {
|
|
262
|
+
/* best effort: the next run's recovery restores a leftover previous copy */
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
for (const tmp of staged) rmQuiet(tmp);
|
|
266
|
+
throw new InstallIncompleteError(
|
|
267
|
+
`could not swap in the new files: ${describeFsError(err)}. The previous install was restored`,
|
|
268
|
+
{ cause: err, untouched: true },
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for (const { had, old } of swapped) {
|
|
273
|
+
if (!had) continue;
|
|
274
|
+
try {
|
|
275
|
+
rmSync(old, { recursive: true, force: true });
|
|
276
|
+
} catch (err) {
|
|
277
|
+
console.log(
|
|
278
|
+
` -> note: could not remove ${old} (${describeFsError(err)}); ` +
|
|
279
|
+
"the next install moves it aside",
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
if (readdirSync(swapRoot).length === 0) rmSync(swapRoot, { recursive: true, force: true });
|
|
285
|
+
} catch {
|
|
286
|
+
/* best effort */
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Staging and previous-copy paths for one destination: the destination's path
|
|
292
|
+
* relative to the host dir, flattened into one name under `swapRoot`.
|
|
293
|
+
*
|
|
294
|
+
* @param {string} swapRoot
|
|
295
|
+
* @param {string} dest
|
|
296
|
+
* @returns {{staged: string, old: string}}
|
|
297
|
+
*/
|
|
298
|
+
export function swapPaths(swapRoot, dest) {
|
|
299
|
+
const key = relative(dirname(swapRoot), dest).split(sep).join("__");
|
|
300
|
+
return { staged: join(swapRoot, `${key}.new`), old: join(swapRoot, `${key}.old`) };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function pathExists(p) {
|
|
304
|
+
try {
|
|
305
|
+
lstatSync(p);
|
|
306
|
+
return true;
|
|
307
|
+
} catch {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function rmQuiet(p) {
|
|
313
|
+
try {
|
|
314
|
+
rmSync(p, { recursive: true, force: true });
|
|
315
|
+
} catch {
|
|
316
|
+
/* best effort */
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function recoverInterruptedSwap(dest, { staged, old }) {
|
|
321
|
+
rmQuiet(staged);
|
|
322
|
+
rmQuiet(`${dest}.new`);
|
|
323
|
+
for (const prev of [old, `${dest}.old`]) {
|
|
324
|
+
if (!pathExists(prev)) continue;
|
|
325
|
+
if (!pathExists(dest)) {
|
|
326
|
+
try {
|
|
327
|
+
renameSync(prev, dest);
|
|
328
|
+
continue;
|
|
329
|
+
} catch {
|
|
330
|
+
/* fall through and clear it */
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
rmQuiet(prev);
|
|
334
|
+
if (!pathExists(prev)) continue;
|
|
335
|
+
// Something inside it cannot be deleted (a read-only subdirectory). Move it
|
|
336
|
+
// under the swap root so it is out of the managed trees, and say where.
|
|
337
|
+
const aside = `${old}-${Date.now()}`;
|
|
338
|
+
try {
|
|
339
|
+
renameSync(prev, aside);
|
|
340
|
+
console.log(` -> note: could not delete ${prev}; moved it to ${aside}, remove it by hand`);
|
|
341
|
+
} catch {
|
|
342
|
+
/* the swap below reports the failure */
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** File name of the install manifest, at the root of the host dir. */
|
|
348
|
+
export const INSTALL_MANIFEST = ".pipeline-manifest.json";
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* @param {string} path
|
|
352
|
+
* @returns {string} sha256 of the file's bytes
|
|
353
|
+
*/
|
|
354
|
+
export function hashFile(path) {
|
|
355
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The install manifest: every co-owned file the installer wrote, keyed by its
|
|
360
|
+
* path relative to the host dir, with the hash it had when written, plus the
|
|
361
|
+
* unknown files already reported so each is reported once.
|
|
362
|
+
*
|
|
363
|
+
* @param {string} hostDir e.g. `$HOME/.claude`
|
|
364
|
+
* @returns {{present: boolean, files: Record<string,string>, warned: string[]}}
|
|
365
|
+
*/
|
|
366
|
+
export function readInstallManifest(hostDir) {
|
|
367
|
+
try {
|
|
368
|
+
const doc = JSON.parse(readFileSync(join(hostDir, INSTALL_MANIFEST), "utf-8"));
|
|
369
|
+
return {
|
|
370
|
+
present: true,
|
|
371
|
+
files: doc && typeof doc.files === "object" && doc.files ? { ...doc.files } : {},
|
|
372
|
+
warned: Array.isArray(doc?.warned) ? doc.warned.filter((x) => typeof x === "string") : [],
|
|
373
|
+
};
|
|
374
|
+
} catch {
|
|
375
|
+
return { present: false, files: {}, warned: [] };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* @param {string} hostDir
|
|
381
|
+
* @param {{files: Record<string,string>, warned: string[]}} manifest
|
|
382
|
+
*/
|
|
383
|
+
export function writeInstallManifest(hostDir, manifest) {
|
|
384
|
+
const doc = {
|
|
385
|
+
schema: 1,
|
|
386
|
+
files: Object.fromEntries(
|
|
387
|
+
Object.entries(manifest.files).sort(([a], [b]) => a.localeCompare(b)),
|
|
388
|
+
),
|
|
389
|
+
warned: [...new Set(manifest.warned)].sort(),
|
|
390
|
+
};
|
|
391
|
+
writeFile(join(hostDir, INSTALL_MANIFEST), JSON.stringify(doc, null, 2) + "\n");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Relative paths of every regular file under `dir`, junk excluded.
|
|
396
|
+
* @param {string} dir
|
|
397
|
+
* @returns {string[]}
|
|
398
|
+
*/
|
|
399
|
+
export function listFiles(dir) {
|
|
400
|
+
const out = [];
|
|
401
|
+
const walk = (abs, rel) => {
|
|
402
|
+
let entries;
|
|
403
|
+
try {
|
|
404
|
+
entries = readdirSync(abs, { withFileTypes: true });
|
|
405
|
+
} catch {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
for (const e of entries) {
|
|
409
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
410
|
+
if (isCopyJunk(r)) continue;
|
|
411
|
+
if (e.isDirectory()) walk(join(abs, e.name), r);
|
|
412
|
+
else if (e.isFile()) out.push(r);
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
walk(dir, "");
|
|
416
|
+
return out.sort();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Bring the pipeline's files in a co-owned directory up to date without
|
|
421
|
+
* touching anything the user owns.
|
|
422
|
+
*
|
|
423
|
+
* A shipped file is written when it is absent, or when its current hash is the
|
|
424
|
+
* one the manifest recorded (unchanged since the last install). `adopt` covers
|
|
425
|
+
* the first run after an install that predates the manifest: that installer
|
|
426
|
+
* overwrote these files on every run, so one carrying a shipped name is its
|
|
427
|
+
* copy. Any other existing file is the user's: it is kept and reported. A file
|
|
428
|
+
* the manifest records but the source no longer ships is removed when it is
|
|
429
|
+
* still unchanged. Files neither shipped nor recorded are the user's; with
|
|
430
|
+
* `reportUnknown` they are reported once.
|
|
431
|
+
*
|
|
432
|
+
* @param {{
|
|
433
|
+
* srcDir: string,
|
|
434
|
+
* destDir: string,
|
|
435
|
+
* displayDir?: string,
|
|
436
|
+
* rels: string[],
|
|
437
|
+
* prefix: string,
|
|
438
|
+
* manifest: {files: Record<string,string>, warned: string[]},
|
|
439
|
+
* adopt: boolean,
|
|
440
|
+
* reportUnknown?: boolean,
|
|
441
|
+
* label: string,
|
|
442
|
+
* }} opts
|
|
443
|
+
* @returns {{written: number, kept: string[], removed: number}}
|
|
444
|
+
*/
|
|
445
|
+
export function syncTrackedFiles(opts) {
|
|
446
|
+
const { srcDir, destDir, rels, prefix, manifest, adopt, reportUnknown = false, label } = opts;
|
|
447
|
+
const shownDir = opts.displayDir || destDir;
|
|
448
|
+
const shipped = new Set(rels);
|
|
449
|
+
const kept = [];
|
|
450
|
+
let written = 0;
|
|
451
|
+
let removed = 0;
|
|
452
|
+
const warn = (key, text) => {
|
|
453
|
+
if (manifest.warned.includes(key)) return;
|
|
454
|
+
manifest.warned.push(key);
|
|
455
|
+
console.log(` -> WARNING: ${text}`);
|
|
456
|
+
};
|
|
457
|
+
for (const rel of rels) {
|
|
458
|
+
const key = `${prefix}${rel}`;
|
|
459
|
+
const from = join(srcDir, rel);
|
|
460
|
+
const to = join(destDir, rel);
|
|
461
|
+
const want = hashFile(from);
|
|
462
|
+
let have;
|
|
463
|
+
try {
|
|
464
|
+
have = lstatSync(to).isFile() ? hashFile(to) : "not-a-file";
|
|
465
|
+
} catch {
|
|
466
|
+
have = null;
|
|
467
|
+
}
|
|
468
|
+
const recorded = manifest.files[key];
|
|
469
|
+
if (have === want) {
|
|
470
|
+
manifest.files[key] = want;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (have === null || have === recorded || (adopt && recorded === undefined)) {
|
|
474
|
+
if (dryRun) {
|
|
475
|
+
console.log(` [dry-run] would write ${join(shownDir, rel)}`);
|
|
476
|
+
} else {
|
|
477
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
478
|
+
cpSync(from, to, { force: true });
|
|
479
|
+
}
|
|
480
|
+
manifest.files[key] = want;
|
|
481
|
+
manifest.warned = manifest.warned.filter((w) => w !== key);
|
|
482
|
+
written++;
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
delete manifest.files[key];
|
|
486
|
+
kept.push(rel);
|
|
487
|
+
warn(
|
|
488
|
+
key,
|
|
489
|
+
`kept your ${join(shownDir, rel)}: it differs from what the pipeline installed there. ` +
|
|
490
|
+
`Delete it and re-run the install to get the ${label} version.`,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
for (const [key, hash] of Object.entries(manifest.files)) {
|
|
494
|
+
if (!key.startsWith(prefix)) continue;
|
|
495
|
+
const rel = key.slice(prefix.length);
|
|
496
|
+
if (shipped.has(rel)) continue;
|
|
497
|
+
const to = join(destDir, rel);
|
|
498
|
+
let have;
|
|
499
|
+
try {
|
|
500
|
+
have = hashFile(to);
|
|
501
|
+
} catch {
|
|
502
|
+
have = null;
|
|
503
|
+
}
|
|
504
|
+
if (have === hash) {
|
|
505
|
+
if (dryRun) console.log(` [dry-run] would remove ${join(shownDir, rel)}`);
|
|
506
|
+
else rmSync(to, { force: true });
|
|
507
|
+
removed++;
|
|
508
|
+
}
|
|
509
|
+
delete manifest.files[key];
|
|
510
|
+
}
|
|
511
|
+
if (reportUnknown && existsSync(destDir)) {
|
|
512
|
+
const unknown = listFiles(destDir).filter(
|
|
513
|
+
(rel) => !shipped.has(rel) && manifest.files[`${prefix}${rel}`] === undefined,
|
|
514
|
+
);
|
|
515
|
+
const fresh = unknown.filter((rel) => !manifest.warned.includes(`${prefix}${rel}`));
|
|
516
|
+
if (fresh.length > 0) {
|
|
517
|
+
for (const rel of fresh) manifest.warned.push(`${prefix}${rel}`);
|
|
518
|
+
console.log(
|
|
519
|
+
` -> note: kept ${fresh.length} file(s) in ${shownDir} the pipeline did not install: ` +
|
|
520
|
+
fresh.join(", "),
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
manifest.warned = manifest.warned.filter(
|
|
524
|
+
(w) => !w.startsWith(prefix) || existsSync(join(destDir, w.slice(prefix.length))),
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
return { written, kept, removed };
|
|
84
528
|
}
|
|
85
529
|
|
|
86
530
|
/**
|
|
@@ -105,8 +549,8 @@ function pipelineAgentFileNames(agentsSrc) {
|
|
|
105
549
|
|
|
106
550
|
/**
|
|
107
551
|
* Remove ONLY pipeline-owned agent files from an installed agents dir.
|
|
108
|
-
* The dir also holds user-authored agent definitions, so it
|
|
109
|
-
*
|
|
552
|
+
* The dir also holds user-authored agent definitions, so it is never wiped
|
|
553
|
+
* wholesale.
|
|
110
554
|
*
|
|
111
555
|
* @param {string} destDir - installed agents dir (~/.claude/agents etc.)
|
|
112
556
|
* @param {string} agentsSrc - pipeline/agents source directory
|
|
@@ -134,25 +578,15 @@ export function removePipelineAgentFiles(destDir, agentsSrc) {
|
|
|
134
578
|
return removed;
|
|
135
579
|
}
|
|
136
580
|
|
|
137
|
-
/**
|
|
138
|
-
* Recursively copy a directory (or symlink it in `--link` mode).
|
|
139
|
-
*
|
|
140
|
-
* @param {string} src
|
|
141
|
-
* @param {string} dest
|
|
142
|
-
* @param {{ exclude?: Array<string|RegExp>, useSymlinks?: boolean }} [opts]
|
|
143
|
-
*/
|
|
144
581
|
/**
|
|
145
582
|
* Build and editor droppings that live inside a source tree and must never
|
|
146
583
|
* reach a user's install. Python writes `__pycache__/<mod>.cpython-NNN.pyc`
|
|
147
|
-
* next to any script it
|
|
584
|
+
* next to any script it imports, so a tree that has merely been used has them;
|
|
148
585
|
* the Finder writes .DS_Store into any directory it has displayed.
|
|
149
586
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* extra file was untracked and therefore invisible to every diff of the SOURCE.
|
|
154
|
-
* Filtering here fixes the user-visible half too: a .pyc compiled against one
|
|
155
|
-
* interpreter is worse than useless on another.
|
|
587
|
+
* Filtering them keeps the installed file count equal to the tracked source
|
|
588
|
+
* count (the layout smoke compares the two), and a .pyc compiled against one
|
|
589
|
+
* interpreter is of no use on another.
|
|
156
590
|
*
|
|
157
591
|
* @param {string} rel path relative to the copy root, posix-separated
|
|
158
592
|
* @returns {boolean}
|
|
@@ -169,6 +603,13 @@ function isCopyJunk(rel) {
|
|
|
169
603
|
);
|
|
170
604
|
}
|
|
171
605
|
|
|
606
|
+
/**
|
|
607
|
+
* Recursively copy a directory (or symlink it in `--link` mode).
|
|
608
|
+
*
|
|
609
|
+
* @param {string} src
|
|
610
|
+
* @param {string} dest
|
|
611
|
+
* @param {{ exclude?: Array<string|RegExp>, useSymlinks?: boolean, skipExisting?: boolean }} [opts]
|
|
612
|
+
*/
|
|
172
613
|
export function copyDir(src, dest, opts = {}) {
|
|
173
614
|
const { exclude = [], useSymlinks = false, skipExisting = false } = opts;
|
|
174
615
|
if (useSymlinks) {
|
|
@@ -269,25 +710,17 @@ export function pruneLegacyMultiAgentSkills(skillsDir) {
|
|
|
269
710
|
/**
|
|
270
711
|
* Trees an older installer created and no current one manages.
|
|
271
712
|
*
|
|
272
|
-
* A tree that stops being copied does
|
|
273
|
-
* pattern only runs for trees the installer still writes, so
|
|
274
|
-
* the installer
|
|
275
|
-
*
|
|
276
|
-
* `eval/`
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* Antigravity / VS Code Copilot Chat adapters needed, so their emitted agents
|
|
284
|
-
* could reach the gate scripts by absolute path. Those adapters were deleted in
|
|
285
|
-
* v10.7.0 along with `installSharedRuntime`, `_base.mjs`, `rewriteScriptRefs` and
|
|
286
|
-
* `smoke-shared-runtime.sh` - but not the tree they wrote. 221 files (174 scripts,
|
|
287
|
-
* 23 lib, 24 schemas) frozen at whatever the last adapter-era install produced,
|
|
288
|
-
* including a migrations directory missing `prefs-2.3.0-to-2.4.0.mjs`. Browsing it
|
|
289
|
-
* looks exactly like browsing a current install, which is how it comes to be
|
|
290
|
-
* mistaken for one.
|
|
713
|
+
* A tree that stops being copied does not stop existing: the wipe-before-copy
|
|
714
|
+
* pattern only runs for trees the installer still writes, so a tree dropped
|
|
715
|
+
* from the installer keeps whatever it last wrote until something removes it.
|
|
716
|
+
*
|
|
717
|
+
* `eval/` held eval corpora for harnesses that are maintainer CI tooling and do
|
|
718
|
+
* not ship, so nothing installed can read or remove it.
|
|
719
|
+
*
|
|
720
|
+
* `~/.multi-agent/` is the shared runtime the retired Cursor / Antigravity /
|
|
721
|
+
* VS Code Copilot Chat adapters used to reach the gate scripts by absolute
|
|
722
|
+
* path: scripts, lib and schemas that no current installer refreshes, and that
|
|
723
|
+
* look like a live install when browsed.
|
|
291
724
|
*
|
|
292
725
|
* `root: "home"` entries sit beside `~/.claude`, not inside it.
|
|
293
726
|
*
|