@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.
Files changed (74) hide show
  1. package/bundled-extensions.json +1 -4
  2. package/dist/create/claude.d.ts.map +1 -1
  3. package/dist/create/claude.js +1 -1
  4. package/dist/create/claude.js.map +1 -1
  5. package/dist/create/codex.d.ts.map +1 -1
  6. package/dist/create/codex.js +2 -3
  7. package/dist/create/codex.js.map +1 -1
  8. package/dist/create/cursor.d.ts.map +1 -1
  9. package/dist/create/cursor.js +3 -7
  10. package/dist/create/cursor.js.map +1 -1
  11. package/dist/create/enact.d.ts.map +1 -1
  12. package/dist/create/enact.js.map +1 -1
  13. package/dist/create/index.d.ts +3 -3
  14. package/dist/create/index.d.ts.map +1 -1
  15. package/dist/create/index.js +4 -4
  16. package/dist/create/index.js.map +1 -1
  17. package/dist/create/kimi.d.ts.map +1 -1
  18. package/dist/create/kimi.js +2 -3
  19. package/dist/create/kimi.js.map +1 -1
  20. package/dist/create/opencode.d.ts.map +1 -1
  21. package/dist/create/opencode.js +5 -12
  22. package/dist/create/opencode.js.map +1 -1
  23. package/dist/index.d.ts +12 -13
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +9 -9
  26. package/dist/index.js.map +1 -1
  27. package/dist/internal/agents.d.ts.map +1 -1
  28. package/dist/internal/agents.js +1 -1
  29. package/dist/internal/agents.js.map +1 -1
  30. package/dist/internal/extensions-table.d.ts +27 -6
  31. package/dist/internal/extensions-table.d.ts.map +1 -1
  32. package/dist/internal/extensions-table.js +85 -12
  33. package/dist/internal/extensions-table.js.map +1 -1
  34. package/dist/internal/file-ops.js +1 -1
  35. package/dist/internal/file-ops.js.map +1 -1
  36. package/dist/internal/io.d.ts.map +1 -1
  37. package/dist/internal/io.js.map +1 -1
  38. package/dist/internal/json-merge.d.ts.map +1 -1
  39. package/dist/internal/json-merge.js +4 -2
  40. package/dist/internal/json-merge.js.map +1 -1
  41. package/dist/internal/lock-ownership.d.ts.map +1 -1
  42. package/dist/internal/lock-ownership.js.map +1 -1
  43. package/dist/internal/pick.d.ts.map +1 -1
  44. package/dist/internal/pick.js.map +1 -1
  45. package/dist/internal/platform.d.ts.map +1 -1
  46. package/dist/internal/platform.js +7 -53
  47. package/dist/internal/platform.js.map +1 -1
  48. package/dist/internal/schema.d.ts.map +1 -1
  49. package/dist/internal/schema.js.map +1 -1
  50. package/dist/internal/toml-block.d.ts.map +1 -1
  51. package/dist/internal/toml-block.js.map +1 -1
  52. package/dist/projection.d.ts.map +1 -1
  53. package/dist/projection.js +2 -2
  54. package/dist/projection.js.map +1 -1
  55. package/dist/validate/index.d.ts +2 -2
  56. package/dist/validate/index.d.ts.map +1 -1
  57. package/dist/validate/index.js +3 -3
  58. package/dist/validate/index.js.map +1 -1
  59. package/dist/validate/run.d.ts.map +1 -1
  60. package/dist/validate/run.js +2 -5
  61. package/dist/validate/run.js.map +1 -1
  62. package/extensions/enact-repo/.agents/plugin.json +2 -2
  63. package/extensions/enact-repo/skills/interaction-design/references/animation-libraries-gsap.md +0 -1
  64. package/extensions/enact-repo/skills/interaction-design/references/animation-libraries.md +0 -1
  65. package/extensions/enact-repo/skills/interaction-design/references/scroll-animations.md +0 -1
  66. package/package.json +9 -8
  67. package/scripts/enact-extensions.mjs +331 -303
  68. package/scripts/lib/build-index.mjs +1 -3
  69. package/scripts/lib/resolve-bundle.mjs +1 -3
  70. package/scripts/lib/run-repo-doctor.mjs +50 -17
  71. package/scripts/lib/run-repo-install.mjs +130 -58
  72. package/scripts/lib/run-sync.mjs +3 -9
  73. package/scripts/lib/run-validate.mjs +1 -3
  74. 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 = [...DENY_LOG_COLUMNS, "host", "kind", "event", "tool", "session", "downstream", "duration_ms"];
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 { configFound: true, trustLevel: null, note: `cannot parse ${configPath}: ${err instanceof Error ? err.message : String(err)}` };
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 = entry && typeof entry === "object" && typeof entry.trust_level === "string" ? entry.trust_level : null;
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(`trust record at ${recordPath} is malformed JSON (${err instanceof Error ? err.message : String(err)})`),
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(`trust record at ${recordPath} is for a different root (${record?.root ?? "(none)"}), not ${repo}`),
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 ? rows.slice(-lastN).map((cols) => Object.fromEntries(columns.map((name, i) => [name, cols[i] ?? ""]))) : [];
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 = !targeted || (existsSync(abs(repo, ".claude-plugin/marketplace.json")) && existsSync(abs(repo, ".claude/settings.json")));
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 = !targeted || existsSync(abs(repo, ".codex/hooks.json")) || existsSync(abs(repo, ".codex/config.toml"));
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 = !targeted || existsSync(abs(repo, ".cursor/hooks.json")) || existsSync(abs(repo, ".cursor/mcp.json"));
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 ? "one generated plugin file; re-run install after a bundle update to regenerate it" : "not targeted by this install",
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).filter(([, v]) => v && typeof v === "object" && !Array.isArray(v)).map(([k]) => k)
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 ? `stale leftover from the pre-${CONFIG_PATH} installer; nothing reads it -- safe to delete` : undefined,
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: [`${name} is not installed in ${repo} (no ${lockRel(name)}) -- install with: enact-extensions install ${name} --repo ${repo}`],
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 { dirname, isAbsolute, join, resolve } from "node:path";
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
- deriveOpenCodePlugin,
94
- materializeCodexSkillMetadata,
95
- MANIFEST_BY_PLATFORM,
96
- EXTENSIONS_BLOCK,
97
- blockMarkers,
89
+ cutBlock,
98
90
  cutExtensionsBlock,
91
+ cutLegacySkillsBlock,
99
92
  declaredExtensionsFrom,
100
- extensionBlock,
101
- removeExtensionBlock,
102
- upsertExtensionBlock,
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 [skills]. We never parse or rewrite anyone else's table: the
144
- // [skills] block is appended once at text level and cut at text level.
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(["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"]);
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.prototype.hasOwnProperty.call(obj, key);
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(Object.keys(obj).sort().map((k) => [k, obj[k]]));
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 (repo === realpathSync(homedir())) {
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 = [...new Set(String(host).split(",").map((p) => p.trim()).filter(Boolean))];
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 `[skills].extensions`. Installing a
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 [skills] — text-level only. The document is parsed solely
768
- // to refuse invalid TOML and to learn whether OUR `skills` key exists; no other
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("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)");
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 = configExists && parseConfigText(configText, "refusing to append [extensions]").extensions !== undefined;
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(`Managed content was edited since the last install (re-run with --force to overwrite):\n ${edited.join("\n ")}`);
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(`Conflict with existing entries not owned by ${bundle.name} (re-run with --force to take them over):\n ${conflicts.join("\n ")}`);
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 = `${configText.length === 0 ? "" : configText.endsWith("\n") ? "\n" : "\n\n"}${EXTENSIONS_BLOCK}`;
1264
- text = `${configText}${appended}`;
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 = configText;
1329
+ text = base;
1268
1330
  config = prev.config ?? { created: false, appended: null };
1269
1331
  }
1270
- const declared = upsertExtensionBlock(text, bundle.name, bundle.version);
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")) console.log("codex: config and hooks load after you trust this project and review hooks (/hooks)");
1318
- if (hosts.includes("kimi")) console.log("kimi: hooks are global-only (installed by enact-vps); no repo hook file written");
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 [skills] block from enact-config.toml; plan it up front so
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(`Managed content was edited since install (re-run with --force to remove anyway):\n ${edited.join("\n ")}`);
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 = [GITIGNORE_PATH, ...(typeof lock.config?.appended === "string" ? [`${CONFIG_PATH} [skills]`] : [])];
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(lockRel(name), `${JSON.stringify(residual, null, 2)}\n`, "residual lock (gitignore/config retained pending --purge)");
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 ([skills] removed, only version = 1 left)");
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, "[skills] block removed (--purge)");
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} [skills] block (edited since install; --force never cuts an edited block)`);
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 [skills] block)`);
1644
+ console.log(`kept ${CONFIG_PATH} (install did not write its [extensions] block)`);
1573
1645
  }
1574
1646
  purgeOpenCodeAutogenIfClean(repo, ops);
1575
1647
  }
@@ -1,10 +1,6 @@
1
- import { readFileSync, existsSync } from "node:fs";
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
  }
@@ -1,6 +1,4 @@
1
- import {
2
- runBundleValidation,
3
- } from "../../dist/index.js";
1
+ import { runBundleValidation } from "../../dist/index.js";
4
2
  import { formatDiagnostics, validatePlugin } from "./skill-validator.mjs";
5
3
 
6
4
  /**