@amsterdamdatalabs/enact-extensions 0.1.47 → 0.1.49
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/bundled-extensions.json +1 -4
- package/dist/create/claude.d.ts.map +1 -1
- package/dist/create/claude.js +1 -1
- package/dist/create/claude.js.map +1 -1
- package/dist/create/codex.d.ts.map +1 -1
- package/dist/create/codex.js +2 -3
- package/dist/create/codex.js.map +1 -1
- package/dist/create/cursor.d.ts.map +1 -1
- package/dist/create/cursor.js +3 -7
- package/dist/create/cursor.js.map +1 -1
- package/dist/create/enact.d.ts.map +1 -1
- package/dist/create/enact.js.map +1 -1
- package/dist/create/index.d.ts +3 -3
- package/dist/create/index.d.ts.map +1 -1
- package/dist/create/index.js +4 -4
- package/dist/create/index.js.map +1 -1
- package/dist/create/kimi.d.ts.map +1 -1
- package/dist/create/kimi.js +2 -3
- package/dist/create/kimi.js.map +1 -1
- package/dist/create/opencode.d.ts.map +1 -1
- package/dist/create/opencode.js +5 -12
- package/dist/create/opencode.js.map +1 -1
- package/dist/index.d.ts +12 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -9
- package/dist/index.js.map +1 -1
- package/dist/internal/agents.d.ts.map +1 -1
- package/dist/internal/agents.js +1 -1
- package/dist/internal/agents.js.map +1 -1
- package/dist/internal/extensions-table.d.ts +27 -6
- package/dist/internal/extensions-table.d.ts.map +1 -1
- package/dist/internal/extensions-table.js +85 -12
- package/dist/internal/extensions-table.js.map +1 -1
- package/dist/internal/file-ops.js +1 -1
- package/dist/internal/file-ops.js.map +1 -1
- package/dist/internal/io.d.ts.map +1 -1
- package/dist/internal/io.js.map +1 -1
- package/dist/internal/json-merge.d.ts.map +1 -1
- package/dist/internal/json-merge.js +4 -2
- package/dist/internal/json-merge.js.map +1 -1
- package/dist/internal/lock-ownership.d.ts.map +1 -1
- package/dist/internal/lock-ownership.js.map +1 -1
- package/dist/internal/pick.d.ts.map +1 -1
- package/dist/internal/pick.js.map +1 -1
- package/dist/internal/platform.d.ts.map +1 -1
- package/dist/internal/platform.js +7 -53
- package/dist/internal/platform.js.map +1 -1
- package/dist/internal/schema.d.ts.map +1 -1
- package/dist/internal/schema.js.map +1 -1
- package/dist/internal/toml-block.d.ts.map +1 -1
- package/dist/internal/toml-block.js.map +1 -1
- package/dist/projection.d.ts.map +1 -1
- package/dist/projection.js +2 -2
- package/dist/projection.js.map +1 -1
- package/dist/validate/index.d.ts +2 -2
- package/dist/validate/index.d.ts.map +1 -1
- package/dist/validate/index.js +3 -3
- package/dist/validate/index.js.map +1 -1
- package/dist/validate/run.d.ts.map +1 -1
- package/dist/validate/run.js +2 -5
- package/dist/validate/run.js.map +1 -1
- package/extensions/enact-repo/.agents/plugin.json +2 -2
- package/extensions/enact-repo/skills/interaction-design/references/animation-libraries-gsap.md +0 -1
- package/extensions/enact-repo/skills/interaction-design/references/animation-libraries.md +0 -1
- package/extensions/enact-repo/skills/interaction-design/references/scroll-animations.md +0 -1
- package/package.json +9 -8
- package/scripts/enact-extensions.mjs +331 -303
- package/scripts/lib/build-index.mjs +1 -3
- package/scripts/lib/resolve-bundle.mjs +1 -3
- package/scripts/lib/run-repo-doctor.mjs +50 -17
- package/scripts/lib/run-repo-install.mjs +130 -58
- package/scripts/lib/run-sync.mjs +3 -9
- package/scripts/lib/run-validate.mjs +1 -3
- package/scripts/lib/skill-validator.mjs +319 -95
|
@@ -25,9 +25,7 @@ export function buildIndex(roots, opts = {}) {
|
|
|
25
25
|
const { now = null, packageRoot } = opts;
|
|
26
26
|
|
|
27
27
|
// Resolve package root: two levels up from this file (scripts/lib/ → repo root)
|
|
28
|
-
const pkgRoot =
|
|
29
|
-
packageRoot ??
|
|
30
|
-
new URL("../../", import.meta.url).pathname.replace(/\/$/, "");
|
|
28
|
+
const pkgRoot = packageRoot ?? new URL("../../", import.meta.url).pathname.replace(/\/$/, "");
|
|
31
29
|
|
|
32
30
|
// Source bundles via the canonical list-bundles helper (deduplicated, resilient).
|
|
33
31
|
const entries = listBundles(roots);
|
|
@@ -115,7 +115,5 @@ export function resolveBundlePath(arg, opts = {}) {
|
|
|
115
115
|
// --- Rule 3: nothing found ---
|
|
116
116
|
const uniqueCandidates = [...new Set(candidates)];
|
|
117
117
|
const candidateList = uniqueCandidates.map((c) => ` - ${c}`).join("\n");
|
|
118
|
-
throw new Error(
|
|
119
|
-
`Plugin "${arg}" not found. Looked in:\n${candidateList}\nPass an explicit path instead.`,
|
|
120
|
-
);
|
|
118
|
+
throw new Error(`Plugin "${arg}" not found. Looked in:\n${candidateList}\nPass an explicit path instead.`);
|
|
121
119
|
}
|
|
@@ -44,21 +44,22 @@
|
|
|
44
44
|
* version, host trust/approval notes, hookDoctor unavailability, a stale
|
|
45
45
|
* `.agents/enact-repo.toml`) is informational.
|
|
46
46
|
*/
|
|
47
|
+
|
|
48
|
+
import { spawnSync } from "node:child_process";
|
|
47
49
|
import { createHash } from "node:crypto";
|
|
48
50
|
import { existsSync, lstatSync, readFileSync, readlinkSync } from "node:fs";
|
|
49
|
-
import { spawnSync } from "node:child_process";
|
|
50
51
|
import { homedir } from "node:os";
|
|
51
52
|
import { basename, join } from "node:path";
|
|
52
53
|
import * as TOML from "@iarna/toml";
|
|
53
54
|
import { entryPresent, findBlock } from "../../dist/index.js";
|
|
54
55
|
import {
|
|
55
|
-
CONFIG_PATH,
|
|
56
|
-
HOOK_LOG_FILES,
|
|
57
|
-
REPO_HOSTS,
|
|
58
56
|
abs,
|
|
59
57
|
buildPlan,
|
|
58
|
+
CONFIG_PATH,
|
|
60
59
|
fileSha,
|
|
60
|
+
HOOK_LOG_FILES,
|
|
61
61
|
lockRel,
|
|
62
|
+
REPO_HOSTS,
|
|
62
63
|
readBundle,
|
|
63
64
|
readJsonStrict,
|
|
64
65
|
readLock,
|
|
@@ -76,7 +77,16 @@ const REQUIRED_BINARIES = ["enact-hook", "lean-ctx", "azure-devops-mcp"];
|
|
|
76
77
|
// global `enact-hook` runtime, never by this package; reported read-only.
|
|
77
78
|
const [DENY_LOG_REL, ACTIVITY_LOG_REL] = HOOK_LOG_FILES;
|
|
78
79
|
const DENY_LOG_COLUMNS = ["timestamp", "surface", "reason_id", "command", "agent_message"];
|
|
79
|
-
const ACTIVITY_LOG_COLUMNS = [
|
|
80
|
+
const ACTIVITY_LOG_COLUMNS = [
|
|
81
|
+
...DENY_LOG_COLUMNS,
|
|
82
|
+
"host",
|
|
83
|
+
"kind",
|
|
84
|
+
"event",
|
|
85
|
+
"tool",
|
|
86
|
+
"session",
|
|
87
|
+
"downstream",
|
|
88
|
+
"duration_ms",
|
|
89
|
+
];
|
|
80
90
|
const DENY_LOG_LAST_ROWS = 5;
|
|
81
91
|
|
|
82
92
|
// ---------------------------------------------------------------------------
|
|
@@ -118,10 +128,15 @@ function checkCodexTrust(repo, home) {
|
|
|
118
128
|
try {
|
|
119
129
|
doc = TOML.parse(readFileSync(configPath, "utf8"));
|
|
120
130
|
} catch (err) {
|
|
121
|
-
return {
|
|
131
|
+
return {
|
|
132
|
+
configFound: true,
|
|
133
|
+
trustLevel: null,
|
|
134
|
+
note: `cannot parse ${configPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
135
|
+
};
|
|
122
136
|
}
|
|
123
137
|
const entry = doc.projects && typeof doc.projects === "object" ? doc.projects[repo] : undefined;
|
|
124
|
-
const trustLevel =
|
|
138
|
+
const trustLevel =
|
|
139
|
+
entry && typeof entry === "object" && typeof entry.trust_level === "string" ? entry.trust_level : null;
|
|
125
140
|
return {
|
|
126
141
|
configFound: true,
|
|
127
142
|
trustLevel,
|
|
@@ -176,14 +191,18 @@ function checkKimiWorkspaceTrust(repo) {
|
|
|
176
191
|
return {
|
|
177
192
|
trusted: false,
|
|
178
193
|
recordPath,
|
|
179
|
-
note: untrustedNote(
|
|
194
|
+
note: untrustedNote(
|
|
195
|
+
`trust record at ${recordPath} is malformed JSON (${err instanceof Error ? err.message : String(err)})`,
|
|
196
|
+
),
|
|
180
197
|
};
|
|
181
198
|
}
|
|
182
199
|
if (!record || record.root !== repo) {
|
|
183
200
|
return {
|
|
184
201
|
trusted: false,
|
|
185
202
|
recordPath,
|
|
186
|
-
note: untrustedNote(
|
|
203
|
+
note: untrustedNote(
|
|
204
|
+
`trust record at ${recordPath} is for a different root (${record?.root ?? "(none)"}), not ${repo}`,
|
|
205
|
+
),
|
|
187
206
|
};
|
|
188
207
|
}
|
|
189
208
|
return { trusted: true, recordPath, trustedAt: record.trustedAt ?? null };
|
|
@@ -248,7 +267,9 @@ function readHookLogSummary(repo, rel, columns, lastN) {
|
|
|
248
267
|
if (!existsSync(path)) return { path: rel, exists: false, rowCount: 0, lastRows: [] };
|
|
249
268
|
const rows = parseCsvRows(readFileSync(path, "utf8"));
|
|
250
269
|
const lastRows =
|
|
251
|
-
lastN > 0
|
|
270
|
+
lastN > 0
|
|
271
|
+
? rows.slice(-lastN).map((cols) => Object.fromEntries(columns.map((name, i) => [name, cols[i] ?? ""])))
|
|
272
|
+
: [];
|
|
252
273
|
return { path: rel, exists: true, rowCount: rows.length, lastRows };
|
|
253
274
|
}
|
|
254
275
|
|
|
@@ -358,7 +379,9 @@ function checkHosts(repo, lock, home, contentOutdated) {
|
|
|
358
379
|
for (const host of REPO_HOSTS) {
|
|
359
380
|
const targeted = lock.hosts.includes(host);
|
|
360
381
|
if (host === "claude") {
|
|
361
|
-
const present =
|
|
382
|
+
const present =
|
|
383
|
+
!targeted ||
|
|
384
|
+
(existsSync(abs(repo, ".claude-plugin/marketplace.json")) && existsSync(abs(repo, ".claude/settings.json")));
|
|
362
385
|
hosts.claude = {
|
|
363
386
|
targeted,
|
|
364
387
|
present,
|
|
@@ -367,7 +390,8 @@ function checkHosts(repo, lock, home, contentOutdated) {
|
|
|
367
390
|
: "not targeted by this install",
|
|
368
391
|
};
|
|
369
392
|
} else if (host === "codex") {
|
|
370
|
-
const present =
|
|
393
|
+
const present =
|
|
394
|
+
!targeted || existsSync(abs(repo, ".codex/hooks.json")) || existsSync(abs(repo, ".codex/config.toml"));
|
|
371
395
|
hosts.codex = {
|
|
372
396
|
targeted,
|
|
373
397
|
present,
|
|
@@ -377,7 +401,8 @@ function checkHosts(repo, lock, home, contentOutdated) {
|
|
|
377
401
|
: "not targeted by this install",
|
|
378
402
|
};
|
|
379
403
|
} else if (host === "cursor") {
|
|
380
|
-
const present =
|
|
404
|
+
const present =
|
|
405
|
+
!targeted || existsSync(abs(repo, ".cursor/hooks.json")) || existsSync(abs(repo, ".cursor/mcp.json"));
|
|
381
406
|
hosts.cursor = {
|
|
382
407
|
targeted,
|
|
383
408
|
present,
|
|
@@ -407,7 +432,9 @@ function checkHosts(repo, lock, home, contentOutdated) {
|
|
|
407
432
|
targeted,
|
|
408
433
|
present,
|
|
409
434
|
generatedFromCurrentBundle,
|
|
410
|
-
note: targeted
|
|
435
|
+
note: targeted
|
|
436
|
+
? "one generated plugin file; re-run install after a bundle update to regenerate it"
|
|
437
|
+
: "not targeted by this install",
|
|
411
438
|
};
|
|
412
439
|
}
|
|
413
440
|
}
|
|
@@ -447,7 +474,9 @@ function checkMarker(repo) {
|
|
|
447
474
|
// enact-hook-managed, and its sub-tables are the declared extension set.
|
|
448
475
|
const extensionsPresent = doc.extensions !== undefined;
|
|
449
476
|
const declared = extensionsPresent
|
|
450
|
-
? Object.entries(doc.extensions)
|
|
477
|
+
? Object.entries(doc.extensions)
|
|
478
|
+
.filter(([, v]) => v && typeof v === "object" && !Array.isArray(v))
|
|
479
|
+
.map(([k]) => k)
|
|
451
480
|
: [];
|
|
452
481
|
return {
|
|
453
482
|
...base,
|
|
@@ -466,7 +495,9 @@ function checkStaleMarker(repo) {
|
|
|
466
495
|
return {
|
|
467
496
|
path: STALE_MARKER_PATH,
|
|
468
497
|
present,
|
|
469
|
-
note: present
|
|
498
|
+
note: present
|
|
499
|
+
? `stale leftover from the pre-${CONFIG_PATH} installer; nothing reads it -- safe to delete`
|
|
500
|
+
: undefined,
|
|
470
501
|
};
|
|
471
502
|
}
|
|
472
503
|
|
|
@@ -489,7 +520,9 @@ export function runRepoDoctor(pluginRoot, options = {}) {
|
|
|
489
520
|
name,
|
|
490
521
|
repo,
|
|
491
522
|
installed: false,
|
|
492
|
-
problems: [
|
|
523
|
+
problems: [
|
|
524
|
+
`${name} is not installed in ${repo} (no ${lockRel(name)}) -- install with: enact-extensions install ${name} --repo ${repo}`,
|
|
525
|
+
],
|
|
493
526
|
exitCode: 1,
|
|
494
527
|
};
|
|
495
528
|
}
|
|
@@ -71,55 +71,50 @@ import { createHash } from "node:crypto";
|
|
|
71
71
|
import {
|
|
72
72
|
existsSync,
|
|
73
73
|
lstatSync,
|
|
74
|
-
mkdirSync,
|
|
75
74
|
mkdtempSync,
|
|
76
|
-
readFileSync,
|
|
77
75
|
readdirSync,
|
|
76
|
+
readFileSync,
|
|
78
77
|
readlinkSync,
|
|
79
78
|
realpathSync,
|
|
80
79
|
rmSync,
|
|
81
|
-
rmdirSync,
|
|
82
80
|
statSync,
|
|
83
|
-
symlinkSync,
|
|
84
|
-
writeFileSync,
|
|
85
81
|
} from "node:fs";
|
|
86
82
|
import { homedir, tmpdir } from "node:os";
|
|
87
|
-
import {
|
|
83
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
88
84
|
import * as TOML from "@iarna/toml";
|
|
89
85
|
import {
|
|
90
86
|
applyHostFlagToHooksDoc,
|
|
91
87
|
assertSupportedHookEvents,
|
|
92
88
|
clampHookTimeoutsForHost,
|
|
93
|
-
|
|
94
|
-
materializeCodexSkillMetadata,
|
|
95
|
-
MANIFEST_BY_PLATFORM,
|
|
96
|
-
EXTENSIONS_BLOCK,
|
|
97
|
-
blockMarkers,
|
|
89
|
+
cutBlock,
|
|
98
90
|
cutExtensionsBlock,
|
|
91
|
+
cutLegacySkillsBlock,
|
|
99
92
|
declaredExtensionsFrom,
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
cutBlock,
|
|
93
|
+
declareExtension,
|
|
94
|
+
deriveOpenCodePlugin,
|
|
95
|
+
EXTENSIONS_BLOCK,
|
|
104
96
|
ensureContainer,
|
|
97
|
+
entryPresent,
|
|
105
98
|
extractTomlTable,
|
|
106
99
|
findBlock,
|
|
100
|
+
getAt,
|
|
101
|
+
MANIFEST_BY_PLATFORM,
|
|
107
102
|
makeFileOps,
|
|
103
|
+
materializeCodexSkillMetadata,
|
|
104
|
+
pathKey,
|
|
105
|
+
projectClaudePluginRoot,
|
|
106
|
+
projectCursorHooks,
|
|
107
|
+
projectPluginRoot,
|
|
108
108
|
reinsertTomlSpans,
|
|
109
|
+
removeExtensionBlock,
|
|
110
|
+
removeJsonEntries,
|
|
109
111
|
renderTomlBlock,
|
|
110
112
|
siblingOwnersOf,
|
|
111
113
|
siblingOwnsPrefix,
|
|
112
114
|
siblingRecordsJsonEntry,
|
|
113
|
-
entryPresent,
|
|
114
|
-
getAt,
|
|
115
|
-
pathKey,
|
|
116
|
-
removeJsonEntries,
|
|
117
|
-
valueHash,
|
|
118
|
-
projectClaudePluginRoot,
|
|
119
|
-
projectPluginRoot,
|
|
120
|
-
projectCursorHooks,
|
|
121
115
|
unmappedCursorHookEvents,
|
|
122
116
|
unmappedCursorHookMatchers,
|
|
117
|
+
valueHash,
|
|
123
118
|
} from "../../dist/index.js";
|
|
124
119
|
|
|
125
120
|
export const REPO_HOSTS = ["claude", "codex", "cursor", "kimi", "opencode"];
|
|
@@ -140,8 +135,8 @@ export function readDeclaredExtensions(configText) {
|
|
|
140
135
|
// Shared repo config at the repo root; its presence marks a repo as managed
|
|
141
136
|
// (enact-hook's contract). One owner per root table: enact-hook owns
|
|
142
137
|
// `version`/[hooks]/[rules]/[repo], enact-repo-controls owns [controls], this
|
|
143
|
-
// package owns [
|
|
144
|
-
// [
|
|
138
|
+
// package owns [extensions]. We never parse or rewrite anyone else's table:
|
|
139
|
+
// the [extensions] block is appended once at text level and cut at text level.
|
|
145
140
|
export const CONFIG_PATH = "enact-config.toml";
|
|
146
141
|
const GITIGNORE_PATH = ".agents/.gitignore";
|
|
147
142
|
// The global `enact-hook` runtime (not this installer) writes two per-repo
|
|
@@ -204,7 +199,13 @@ const GITIGNORE_LINES = [
|
|
|
204
199
|
// package-lock.json\nbun.lock\n.gitignore"). We never write these
|
|
205
200
|
// ourselves; `--purge` removes them ONLY when nothing else user-owned
|
|
206
201
|
// remains under `.opencode/` (besides our own now-removed `plugins/*.ts`).
|
|
207
|
-
const OPENCODE_AUTOGEN_ENTRIES = new Set([
|
|
202
|
+
const OPENCODE_AUTOGEN_ENTRIES = new Set([
|
|
203
|
+
"node_modules",
|
|
204
|
+
"package.json",
|
|
205
|
+
"package-lock.json",
|
|
206
|
+
"bun.lock",
|
|
207
|
+
".gitignore",
|
|
208
|
+
]);
|
|
208
209
|
// ONE marketplace per repo, listing every installed plugin -- not one per
|
|
209
210
|
// plugin. A marketplace.json carries exactly one `name` field, so per-plugin
|
|
210
211
|
// names cannot coexist in it; the previous `<plugin>-local` scheme meant the
|
|
@@ -225,7 +226,7 @@ const CONFIG_ROOT_CONTENT = "version = 1\n";
|
|
|
225
226
|
// ---------------------------------------------------------------------------
|
|
226
227
|
|
|
227
228
|
export const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
228
|
-
const has = (obj, key) => Object.
|
|
229
|
+
const has = (obj, key) => Object.hasOwn(obj, key);
|
|
229
230
|
|
|
230
231
|
export const abs = (repo, rel) => join(repo, ...rel.split("/"));
|
|
231
232
|
|
|
@@ -258,7 +259,11 @@ function serializeJson(doc, format) {
|
|
|
258
259
|
}
|
|
259
260
|
|
|
260
261
|
function sortObject(obj) {
|
|
261
|
-
return Object.fromEntries(
|
|
262
|
+
return Object.fromEntries(
|
|
263
|
+
Object.keys(obj)
|
|
264
|
+
.sort()
|
|
265
|
+
.map((k) => [k, obj[k]]),
|
|
266
|
+
);
|
|
262
267
|
}
|
|
263
268
|
|
|
264
269
|
// Drop empty per-rel entries before persisting `displaced` to the lock.
|
|
@@ -274,6 +279,35 @@ function pruneDisplaced(displaced) {
|
|
|
274
279
|
// repo + bundle resolution
|
|
275
280
|
// ---------------------------------------------------------------------------
|
|
276
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Every real path this process could reasonably mean by "the home directory".
|
|
284
|
+
*
|
|
285
|
+
* `os.homedir()` alone is not enough, because runtimes disagree about it.
|
|
286
|
+
* Measured: node 26 resolves it from `$HOME` on POSIX, bun 1.4.2 does NOT --
|
|
287
|
+
* after `process.env.HOME = "/tmp/pretend-home"`, node reports the override and
|
|
288
|
+
* bun still reports the real account home. So a guard reading only `homedir()`
|
|
289
|
+
* quietly stops matching wherever HOME is overridden: CI images, containers,
|
|
290
|
+
* `sudo -E`, and this repo's own test suite.
|
|
291
|
+
*
|
|
292
|
+
* Refusing all of the candidates is strictly safer than betting on one of them,
|
|
293
|
+
* and makes the guard mean the same thing on every runtime.
|
|
294
|
+
*/
|
|
295
|
+
function homeDirectories() {
|
|
296
|
+
const found = new Set();
|
|
297
|
+
for (const candidate of [homedir(), process.env.HOME, process.env.USERPROFILE]) {
|
|
298
|
+
if (typeof candidate !== "string" || candidate === "") continue;
|
|
299
|
+
found.add(resolve(candidate));
|
|
300
|
+
try {
|
|
301
|
+
found.add(realpathSync(candidate));
|
|
302
|
+
} catch {
|
|
303
|
+
// A HOME that does not resolve cannot be the directory being installed
|
|
304
|
+
// into -- `repo` is realpath'd above, so it always exists. Nothing is
|
|
305
|
+
// being suppressed here: the candidate simply cannot match.
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return found;
|
|
309
|
+
}
|
|
310
|
+
|
|
277
311
|
export function resolveRepoRoot(repoArg) {
|
|
278
312
|
if (!repoArg || typeof repoArg !== "string") {
|
|
279
313
|
throw new Error("--repo <path> is required for a repo-local install");
|
|
@@ -282,7 +316,7 @@ export function resolveRepoRoot(repoArg) {
|
|
|
282
316
|
throw new Error(`--repo ${repoArg} is not a directory`);
|
|
283
317
|
}
|
|
284
318
|
const repo = realpathSync(resolve(repoArg));
|
|
285
|
-
if (
|
|
319
|
+
if (homeDirectories().has(repo)) {
|
|
286
320
|
throw new Error(`--repo ${repoArg} is the home directory; repo-local installs must target a git repository root`);
|
|
287
321
|
}
|
|
288
322
|
// Filesystem check, deliberately NOT `git rev-parse --show-toplevel`. A
|
|
@@ -302,7 +336,14 @@ export function resolveRepoRoot(repoArg) {
|
|
|
302
336
|
export function parseRepoHosts(host, manifest) {
|
|
303
337
|
const targets = Array.isArray(manifest.targets) ? manifest.targets : REPO_HOSTS;
|
|
304
338
|
if (!host) return REPO_HOSTS.filter((h) => targets.includes(h));
|
|
305
|
-
const parts = [
|
|
339
|
+
const parts = [
|
|
340
|
+
...new Set(
|
|
341
|
+
String(host)
|
|
342
|
+
.split(",")
|
|
343
|
+
.map((p) => p.trim())
|
|
344
|
+
.filter(Boolean),
|
|
345
|
+
),
|
|
346
|
+
];
|
|
306
347
|
for (const part of parts) {
|
|
307
348
|
if (!REPO_HOSTS.includes(part)) {
|
|
308
349
|
throw new Error(`Unknown repo host: "${part}". Valid: ${REPO_HOSTS.join(", ")}`);
|
|
@@ -415,7 +456,6 @@ function addSharedSkills(plan, bundle, codexPolicy) {
|
|
|
415
456
|
});
|
|
416
457
|
}
|
|
417
458
|
|
|
418
|
-
|
|
419
459
|
// Project a self-contained plugin root for a host that has a plugin format,
|
|
420
460
|
// and stage it into the plan. Every such host namespaces a plugin's skills by
|
|
421
461
|
// the plugin name, which is what lets two extensions ship the same skill name
|
|
@@ -605,7 +645,7 @@ export function buildPlan(pluginRoot, bundle, hosts, repo) {
|
|
|
605
645
|
plan.gitignore.set(GITIGNORE_PATH, GITIGNORE_LINES);
|
|
606
646
|
const leanCtxConfig = addLeanCtxConfig(plan, pluginRoot);
|
|
607
647
|
|
|
608
|
-
// The repo's DECLARED extension set, from `[
|
|
648
|
+
// The repo's DECLARED extension set, from the `[extensions.<name>]` tables. Installing a
|
|
609
649
|
// plugin declares it: the file is the user-editable statement of what this
|
|
610
650
|
// repo should have, and install reconciles the repo to it. Order is stable
|
|
611
651
|
// (declaration order, ours appended last) so regenerating the marketplace
|
|
@@ -764,9 +804,9 @@ function removeManagedGitignoreLines(repo, rel, ginfo, ops) {
|
|
|
764
804
|
}
|
|
765
805
|
|
|
766
806
|
// ---------------------------------------------------------------------------
|
|
767
|
-
// enact-config.toml [
|
|
768
|
-
// to refuse invalid TOML and to learn
|
|
769
|
-
// table is read, and the document is never re-serialized.
|
|
807
|
+
// enact-config.toml [extensions] — text-level only. The document is parsed
|
|
808
|
+
// solely to refuse invalid TOML and to learn which extensions are declared; no
|
|
809
|
+
// other table is read, and the document is never re-serialized.
|
|
770
810
|
// ---------------------------------------------------------------------------
|
|
771
811
|
|
|
772
812
|
function parseConfigText(text, action) {
|
|
@@ -815,7 +855,9 @@ function purgeOpenCodeAutogenIfClean(repo, ops) {
|
|
|
815
855
|
}
|
|
816
856
|
for (const e of entries) ops.removeDir(`.opencode/${e}`, "OpenCode auto-generated file (--purge)");
|
|
817
857
|
ops.rmdirIfEmpty(".opencode");
|
|
818
|
-
console.log(
|
|
858
|
+
console.log(
|
|
859
|
+
"purged .opencode/ auto-generated files (node_modules/package.json/package-lock.json/bun.lock/.gitignore -- matched OpenCode's own auto-generated shape, nothing else present)",
|
|
860
|
+
);
|
|
819
861
|
}
|
|
820
862
|
|
|
821
863
|
// ---------------------------------------------------------------------------
|
|
@@ -861,7 +903,13 @@ export function runRepoInstall(pluginRoot, options = {}) {
|
|
|
861
903
|
// -- preflight: enact-config.toml must be valid TOML before we append to it
|
|
862
904
|
const configExists = existsSync(abs(repo, CONFIG_PATH));
|
|
863
905
|
const configText = configExists ? readFileSync(abs(repo, CONFIG_PATH), "utf8") : null;
|
|
864
|
-
const configHasExtensions =
|
|
906
|
+
const configHasExtensions =
|
|
907
|
+
configExists && parseConfigText(configText, "refusing to append [extensions]").extensions !== undefined;
|
|
908
|
+
// -- preflight: the legacy `[skills]` marker is cut here, not carried. It
|
|
909
|
+
// refuses rather than guesses when the table is not the one install wrote,
|
|
910
|
+
// and it runs BEFORE any file is touched so a refusal leaves the repo
|
|
911
|
+
// untouched instead of half-installed.
|
|
912
|
+
const configLegacyCut = configExists ? cutLegacySkillsBlock(configText) : null;
|
|
865
913
|
|
|
866
914
|
// -- preflight: skills never overwrite a USER-AUTHORED skill dir (not even
|
|
867
915
|
// with --force). A dir owned by THIS plugin, or by any SIBLING plugin
|
|
@@ -900,6 +948,13 @@ export function runRepoInstall(pluginRoot, options = {}) {
|
|
|
900
948
|
// it here would report a real collision with useless advice ("rename one").
|
|
901
949
|
const SHARED_CONTENT_EXEMPT = new Set([".claude-plugin/marketplace.json"]);
|
|
902
950
|
const divergent = [];
|
|
951
|
+
// Every repo-relative path this run changed, in write order. Declared HERE
|
|
952
|
+
// rather than beside the other `-- apply` locals because the stale-symlink
|
|
953
|
+
// sweep below already pushes to it, and that sweep has to run before any file
|
|
954
|
+
// is written. `const` further down put this in its temporal dead zone, so the
|
|
955
|
+
// sweep threw `Cannot access 'written' before initialization` on exactly the
|
|
956
|
+
// runs where it had a symlink to drop -- see install-stale-symlink-crash.test.mjs.
|
|
957
|
+
const written = [];
|
|
903
958
|
// BEFORE any file is written: drop a recorded symlink that is no longer in
|
|
904
959
|
// the plan. A managed directory that is still a symlink from an earlier
|
|
905
960
|
// layout would otherwise be FOLLOWED by the writes below -- the skills a
|
|
@@ -1020,16 +1075,19 @@ export function runRepoInstall(pluginRoot, options = {}) {
|
|
|
1020
1075
|
|
|
1021
1076
|
const problems = [];
|
|
1022
1077
|
if (edited.length > 0) {
|
|
1023
|
-
problems.push(
|
|
1078
|
+
problems.push(
|
|
1079
|
+
`Managed content was edited since the last install (re-run with --force to overwrite):\n ${edited.join("\n ")}`,
|
|
1080
|
+
);
|
|
1024
1081
|
}
|
|
1025
1082
|
if (conflicts.length > 0) {
|
|
1026
|
-
problems.push(
|
|
1083
|
+
problems.push(
|
|
1084
|
+
`Conflict with existing entries not owned by ${bundle.name} (re-run with --force to take them over):\n ${conflicts.join("\n ")}`,
|
|
1085
|
+
);
|
|
1027
1086
|
}
|
|
1028
1087
|
if (problems.length > 0 && !force) throw new Error(problems.join("\n"));
|
|
1029
1088
|
|
|
1030
1089
|
// -- apply
|
|
1031
1090
|
const createdDirs = prev.createdDirs.filter((d) => existsSync(abs(repo, d)));
|
|
1032
|
-
const written = [];
|
|
1033
1091
|
const writeRepoFile = (rel, content, reason) => {
|
|
1034
1092
|
replaceSymlinkAncestors(repo, rel, ops);
|
|
1035
1093
|
recordMissingDirs(repo, rel, createdDirs);
|
|
@@ -1255,19 +1313,25 @@ export function runRepoInstall(pluginRoot, options = {}) {
|
|
|
1255
1313
|
let config;
|
|
1256
1314
|
{
|
|
1257
1315
|
let text;
|
|
1316
|
+
// The legacy `[skills]` table is already gone from `base` when one was
|
|
1317
|
+
// present; `configText` stays the ORIGINAL so the "did anything change"
|
|
1318
|
+
// comparison below still sees the cut.
|
|
1319
|
+
const base = configLegacyCut ?? configText;
|
|
1258
1320
|
if (!configExists) {
|
|
1259
1321
|
const appended = `\n${EXTENSIONS_BLOCK}`;
|
|
1260
1322
|
text = `${CONFIG_ROOT_CONTENT}${appended}`;
|
|
1261
1323
|
config = { created: true, appended };
|
|
1262
1324
|
} else if (!configHasExtensions) {
|
|
1263
|
-
const appended = `${
|
|
1264
|
-
text = `${
|
|
1325
|
+
const appended = `${base.length === 0 ? "" : base.endsWith("\n") ? "\n" : "\n\n"}${EXTENSIONS_BLOCK}`;
|
|
1326
|
+
text = `${base}${appended}`;
|
|
1265
1327
|
config = { created: prev.config?.created ?? false, appended };
|
|
1266
1328
|
} else {
|
|
1267
|
-
text =
|
|
1329
|
+
text = base;
|
|
1268
1330
|
config = prev.config ?? { created: false, appended: null };
|
|
1269
1331
|
}
|
|
1270
|
-
|
|
1332
|
+
// Insert-once: null means already declared (or no [extensions] to write
|
|
1333
|
+
// into), and an existing declaration is never rewritten -- see declareExtension.
|
|
1334
|
+
const declared = declareExtension(text, bundle.name, bundle.version);
|
|
1271
1335
|
if (declared !== null) text = declared;
|
|
1272
1336
|
if (text !== configText) {
|
|
1273
1337
|
parseConfigText(text, "refusing to write invalid TOML");
|
|
@@ -1296,9 +1360,7 @@ export function runRepoInstall(pluginRoot, options = {}) {
|
|
|
1296
1360
|
const tag = dryRun ? "[dry-run] " : "";
|
|
1297
1361
|
console.log(`${tag}repo install ${bundle.name}@${bundle.version} -> ${repo} (hosts: ${hosts.join(", ")})`);
|
|
1298
1362
|
console.log(
|
|
1299
|
-
written.length > 0
|
|
1300
|
-
? `${dryRun ? "would change" : "changed"} ${written.length} file(s)`
|
|
1301
|
-
: "up to date (no changes)",
|
|
1363
|
+
written.length > 0 ? `${dryRun ? "would change" : "changed"} ${written.length} file(s)` : "up to date (no changes)",
|
|
1302
1364
|
);
|
|
1303
1365
|
// Live-testing finding (2026-09-14): even after trusting the folder, hooks
|
|
1304
1366
|
// and skills were observed NOT active in the very FIRST Claude Code
|
|
@@ -1314,8 +1376,10 @@ export function runRepoInstall(pluginRoot, options = {}) {
|
|
|
1314
1376
|
"claude: the plugin loads after you trust this folder in Claude Code -- if hooks/skills are missing in that FIRST session, start a NEW Claude Code session in this folder (the marketplace registers on first launch; the second session has it active)",
|
|
1315
1377
|
);
|
|
1316
1378
|
}
|
|
1317
|
-
if (hosts.includes("codex"))
|
|
1318
|
-
|
|
1379
|
+
if (hosts.includes("codex"))
|
|
1380
|
+
console.log("codex: config and hooks load after you trust this project and review hooks (/hooks)");
|
|
1381
|
+
if (hosts.includes("kimi"))
|
|
1382
|
+
console.log("kimi: hooks are global-only (installed by enact-vps); no repo hook file written");
|
|
1319
1383
|
return { repo, name: bundle.name, version: bundle.version, hosts, changed: written, actions, dryRun };
|
|
1320
1384
|
}
|
|
1321
1385
|
|
|
@@ -1339,7 +1403,6 @@ export function runRepoUninstall(name, options = {}) {
|
|
|
1339
1403
|
// surviving entries have to be read while they still exist.
|
|
1340
1404
|
const uninstallConfigPath = abs(repo, CONFIG_PATH);
|
|
1341
1405
|
const uninstallConfigText = existsSync(uninstallConfigPath) ? readFileSync(uninstallConfigPath, "utf8") : "";
|
|
1342
|
-
const remainingExtensions = readDeclaredExtensions(uninstallConfigText).filter((n) => n !== name);
|
|
1343
1406
|
const survivingMarketplace = (() => {
|
|
1344
1407
|
const p = abs(repo, MARKETPLACE_PATH);
|
|
1345
1408
|
if (!existsSync(p)) return null;
|
|
@@ -1391,7 +1454,7 @@ export function runRepoUninstall(name, options = {}) {
|
|
|
1391
1454
|
const found = existsSync(path) ? findBlock(readFileSync(path, "utf8"), name) : null;
|
|
1392
1455
|
if (!found || sha256(found.block) !== record.sha256) edited.push(rel);
|
|
1393
1456
|
}
|
|
1394
|
-
// --purge cuts our [
|
|
1457
|
+
// --purge cuts our [extensions] block from enact-config.toml; plan it up front so
|
|
1395
1458
|
// an edited block is refused like any other edited managed content.
|
|
1396
1459
|
let configCut = null;
|
|
1397
1460
|
if (options.purge && typeof lock.config?.appended === "string" && existsSync(abs(repo, CONFIG_PATH))) {
|
|
@@ -1400,7 +1463,9 @@ export function runRepoUninstall(name, options = {}) {
|
|
|
1400
1463
|
if (configCut === null) edited.push(`${CONFIG_PATH} ([extensions] block edited since install)`);
|
|
1401
1464
|
}
|
|
1402
1465
|
if (edited.length > 0 && !force) {
|
|
1403
|
-
throw new Error(
|
|
1466
|
+
throw new Error(
|
|
1467
|
+
`Managed content was edited since install (re-run with --force to remove anyway):\n ${edited.join("\n ")}`,
|
|
1468
|
+
);
|
|
1404
1469
|
}
|
|
1405
1470
|
|
|
1406
1471
|
// Originals displaced by a --force takeover during install; restored below
|
|
@@ -1512,7 +1577,10 @@ export function runRepoUninstall(name, options = {}) {
|
|
|
1512
1577
|
const presentRuntimePaths = [...presentRuntimeDirs, ...presentHookLogFiles];
|
|
1513
1578
|
retainedGitignore = !options.purge && presentRuntimePaths.length > 0;
|
|
1514
1579
|
if (retainedGitignore) {
|
|
1515
|
-
const kept = [
|
|
1580
|
+
const kept = [
|
|
1581
|
+
GITIGNORE_PATH,
|
|
1582
|
+
...(typeof lock.config?.appended === "string" ? [`${CONFIG_PATH} [extensions]`] : []),
|
|
1583
|
+
];
|
|
1516
1584
|
console.log(
|
|
1517
1585
|
`kept ${kept.join(" and ")} -- lean-ctx runtime dirs / enact-hook log files still present (${presentRuntimePaths.join(", ")}); re-run uninstall --purge to remove them too`,
|
|
1518
1586
|
);
|
|
@@ -1556,20 +1624,24 @@ export function runRepoUninstall(name, options = {}) {
|
|
|
1556
1624
|
createdDirs: lock.createdDirs,
|
|
1557
1625
|
displaced: { files: {}, json: {}, toml: {} },
|
|
1558
1626
|
};
|
|
1559
|
-
ops.write(
|
|
1627
|
+
ops.write(
|
|
1628
|
+
lockRel(name),
|
|
1629
|
+
`${JSON.stringify(residual, null, 2)}\n`,
|
|
1630
|
+
"residual lock (gitignore/config retained pending --purge)",
|
|
1631
|
+
);
|
|
1560
1632
|
}
|
|
1561
1633
|
|
|
1562
1634
|
if (options.purge) {
|
|
1563
1635
|
if (configCut !== null) {
|
|
1564
1636
|
if (lock.config.created && configCut.trim() === CONFIG_ROOT_CONTENT.trim()) {
|
|
1565
|
-
ops.remove(CONFIG_PATH, "managed-repo marker created by install ([
|
|
1637
|
+
ops.remove(CONFIG_PATH, "managed-repo marker created by install ([extensions] removed, only version = 1 left)");
|
|
1566
1638
|
} else {
|
|
1567
|
-
ops.write(CONFIG_PATH, configCut, "[
|
|
1639
|
+
ops.write(CONFIG_PATH, configCut, "[extensions] block removed (--purge)");
|
|
1568
1640
|
}
|
|
1569
1641
|
} else if (typeof lock.config?.appended === "string" && existsSync(abs(repo, CONFIG_PATH))) {
|
|
1570
|
-
console.log(`kept ${CONFIG_PATH} [
|
|
1642
|
+
console.log(`kept ${CONFIG_PATH} [extensions] block (edited since install; --force never cuts an edited block)`);
|
|
1571
1643
|
} else if (existsSync(abs(repo, CONFIG_PATH))) {
|
|
1572
|
-
console.log(`kept ${CONFIG_PATH} (install did not write its [
|
|
1644
|
+
console.log(`kept ${CONFIG_PATH} (install did not write its [extensions] block)`);
|
|
1573
1645
|
}
|
|
1574
1646
|
purgeOpenCodeAutogenIfClean(repo, ops);
|
|
1575
1647
|
}
|
package/scripts/lib/run-sync.mjs
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
syncPlatformManifests,
|
|
5
|
-
createEnactManifest,
|
|
6
|
-
assertSupportedHookEvents,
|
|
7
|
-
} from "../../dist/index.js";
|
|
3
|
+
import { assertSupportedHookEvents, createEnactManifest, syncPlatformManifests } from "../../dist/index.js";
|
|
8
4
|
|
|
9
5
|
export function runSync(pluginRoot, { name } = {}) {
|
|
10
6
|
const enactPath = join(pluginRoot, ".agents/plugin.json");
|
|
@@ -13,9 +9,7 @@ export function runSync(pluginRoot, { name } = {}) {
|
|
|
13
9
|
if (existsSync(enactPath)) {
|
|
14
10
|
enact = JSON.parse(readFileSync(enactPath, "utf8"));
|
|
15
11
|
} else if (!name) {
|
|
16
|
-
throw new Error(
|
|
17
|
-
`No .agents/plugin.json in ${pluginRoot}. Pass --name <kebab-name> to create one.`,
|
|
18
|
-
);
|
|
12
|
+
throw new Error(`No .agents/plugin.json in ${pluginRoot}. Pass --name <kebab-name> to create one.`);
|
|
19
13
|
} else {
|
|
20
14
|
enact = createEnactManifest({ name });
|
|
21
15
|
}
|