@amsterdamdatalabs/enact-extensions 0.1.54 → 0.1.55

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.
@@ -7,47 +7,32 @@
7
7
  * exception is the gitignored `.claude/settings.local.json` env block (see
8
8
  * the claude adapter), which cannot work any other way.
9
9
  *
10
- * claude — plugin root copied to `.claude-plugin/<name>/` (its `skills/` is
11
- * a RELATIVE SYMLINK to the shared `.agents/skills/` tree, not a
12
- * copy — see "Shared skills" below); repo marketplace
13
- * `.claude-plugin/marketplace.json` (marketplace root = repo root,
14
- * the dir CONTAINING .claude-plugin/, so the plugin entry is
15
- * `source: "./.claude-plugin/<name>"`); `.claude/settings.json`
16
- * merged with `extraKnownMarketplaces` (directory source, path "./")
17
- * and `enabledPlugins`; `.claude/settings.local.json` merged with
18
- * the absolute LEAN_CTX_*_DIR env block (gitignored via
19
- * `.claude/.gitignore`). Loads only after the user trusts the folder.
20
- * codex — `.codex/config.toml` `[mcp_servers.<name>]` tables in a
21
- * marker-delimited block (user TOML and comments untouched) +
22
- * `.codex/hooks.json` (canonical nested schema, `--host=codex`).
23
- * Loads only after the user trusts the project and reviews hooks.
24
- * cursor — `.cursor/hooks.json` (version 1, camelCase, `--host=cursor`) +
25
- * `.cursor/mcp.json` (`type: "stdio"`).
26
- * kimi — `.kimi-code/mcp.json`. Kimi hooks are global-only (enact-vps),
27
- * so NO hook file is written here.
28
- * opencode — one generated `.opencode/plugins/<name>.ts` (hooks + MCP via the
29
- * `config` hook, NO skills path: OpenCode reads `.agents/skills/`).
10
+ * Every host receives only its native project files:
11
+ * claude — `.claude/agents`, `.claude/skills`, `.mcp.json`, and hooks in
12
+ * `.claude/settings.json`.
13
+ * codex — `.codex/agents`, `.agents/skills`, `.codex/config.toml`, and
14
+ * `.codex/hooks.json`.
15
+ * cursor — `.cursor/agents`, `.cursor/skills`, `.cursor/hooks.json`, and
16
+ * `.cursor/mcp.json`.
17
+ * kimi — `.kimi-code/agents`, `.kimi-code/skills`, and `.kimi-code/mcp.json`.
18
+ * Kimi hooks remain global-only and are not owned here.
19
+ * opencode — `.opencode/agents`, `.opencode/skills`, and `opencode.json` MCP.
20
+ * OpenCode has no native project hook surface, so a hook-bearing
21
+ * bundle fails before writes and names enact-vps' global plugin path.
30
22
  *
31
- * Shared skills: ONE real copy, in `.agents/skills/<skill>/`, written
32
- * UNCONDITIONALLY for every host (`.agents` is universal — every host,
33
- * including a claude-only install, needs it present so nothing dangles).
34
- * Claude's plugin-bundled `skills/` is a relative symlink to that same tree
35
- * (`../../.agents/skills`), not a second copy: Claude Code dereferences a
36
- * within-marketplace symlink (see `plan.symlinks` / the claude adapter below).
23
+ * Repo installation never writes a plugin root, marketplace, or plugin
24
+ * registration. Those belong exclusively to explicit global installation.
37
25
  *
38
26
  * State: `.agents/<name>.lock.json` records every managed file (sha256) and
39
27
  * every entry merged into a user file. Re-runs are idempotent.
40
28
  *
41
- * Owner decision (Tarun): hooks, mcp and skills content is ALWAYS force-
42
- * installed -- our own generated content and our own entries in a shared
43
- * file are repaired to match the bundle on every install, no `--force` flag
44
- * needed and no "not managed"/"edited" refusal. Two kinds:
45
- * - Whole files we exclusively generate (`.claude-plugin/<name>/**`,
46
- * `.agents/skills/**`, `.opencode/plugins/<name>.ts`, the claude skills
47
- * symlink): nothing user-authored can live there, so drift or a stale
48
- * pre-existing entry is just silently overwritten -- no displace/restore,
49
- * nothing worth preserving. `plan.forcedFiles` marks exactly this subset
50
- * of `plan.files`.
29
+ * Owner decision (Tarun): merged hooks and MCP entries are always repaired.
30
+ * Direct native agent and skill files are lock-owned files: user collisions
31
+ * fail loudly and the lock makes uninstall remove only this bundle's files.
32
+ *
33
+ * Two kinds:
34
+ * - Whole files in host-native agent/skill locations, which are conflict
35
+ * checked and recorded as files.
51
36
  * - Merged surfaces shared with the user (`.cursor/hooks.json`,
52
37
  * `.cursor/mcp.json`, `.kimi-code/mcp.json`, `.codex/hooks.json`,
53
38
  * `.claude/settings.json`, the marker block in `.codex/config.toml`):
@@ -56,12 +41,9 @@
56
41
  * share OUR key/server name is taken over and its original captured into
57
42
  * `displaced` for a byte-exact restore on uninstall, exactly like a
58
43
  * `--force` takeover always has -- it just no longer needs the flag.
59
- * `--force` still gates the two surfaces OUTSIDE that scope: the repo-root
60
- * `.claude-plugin/marketplace.json` and the lean-ctx baseline config files
61
- * (`.agents/lean-ctx/config/lean-ctx/*.toml`) -- both can plausibly hold
62
- * independent user content, so a pre-existing foreign file there is still a
63
- * refused conflict until `--force` takes it over (with restore-on-uninstall,
64
- * unchanged).
44
+ * `--force` gates native whole-file drift and baseline lean-ctx config files
45
+ * (`.agents/lean-ctx/config/lean-ctx/*.toml`), preserving displaced content
46
+ * for byte-exact restoration on uninstall.
65
47
  * Uninstall removes exactly what the lock records; uninstall's OWN drift
66
48
  * check (refusing to remove content edited since install without --force)
67
49
  * is unaffected by any of the above -- that gate is about safe removal, not
@@ -91,20 +73,18 @@ import {
91
73
  cutLegacySkillsBlock,
92
74
  declaredExtensionsFrom,
93
75
  declareExtension,
94
- deriveOpenCodePlugin,
76
+ deriveOpenCodeMcp,
95
77
  EXTENSIONS_BLOCK,
96
78
  ensureContainer,
97
79
  entryPresent,
98
80
  extractTomlTable,
99
81
  findBlock,
100
82
  getAt,
101
- MANIFEST_BY_PLATFORM,
102
83
  makeFileOps,
103
84
  materializeCodexSkillMetadata,
104
85
  pathKey,
105
- projectClaudePluginRoot,
106
86
  projectCursorHooks,
107
- projectPluginRoot,
87
+ projectNativeAgentsDir,
108
88
  reinsertTomlSpans,
109
89
  removeExtensionBlock,
110
90
  removeJsonEntries,
@@ -118,15 +98,20 @@ import {
118
98
  } from "../../dist/index.js";
119
99
 
120
100
  export const REPO_HOSTS = ["claude", "codex", "cursor", "kimi", "opencode"];
121
- // OpenCode is the ONLY host left needing a flat skills directory: per
122
- // spec/opencode.json its "documented plugin surface has no skills/agents/
123
- // commands concept", so a plugin root cannot carry them. Every other host
124
- // gets skills INSIDE its plugin root, namespaced by plugin name.
125
- //
126
- // `.opencode/skills/` is OpenCode's own documented project location, not a
127
- // shared cross-host pool -- so a collision here is scoped to one host instead
128
- // of every host, and nothing else reads it.
129
- const SHARED_SKILLS_DIR = ".opencode/skills";
101
+ const HOST_SKILLS_DIR = {
102
+ codex: ".agents/skills",
103
+ claude: ".claude/skills",
104
+ cursor: ".cursor/skills",
105
+ kimi: ".kimi-code/skills",
106
+ opencode: ".opencode/skills",
107
+ };
108
+ const HOST_AGENTS_DIR = {
109
+ codex: ".codex/agents",
110
+ claude: ".claude/agents",
111
+ cursor: ".cursor/agents",
112
+ kimi: ".kimi-code/agents",
113
+ opencode: ".opencode/agents",
114
+ };
130
115
  /** Declared extension names from `[extensions.<name>]` sub-tables, or []. */
131
116
  export function readDeclaredExtensions(configText) {
132
117
  if (typeof configText !== "string" || configText.length === 0) return [];
@@ -192,13 +177,9 @@ const GITIGNORE_LINES = [
192
177
  // `.claude/.gitignore` line to hide it. Both were removed with the carrier:
193
178
  // absolute paths in an installed file are machine-specific and go stale
194
179
  // silently on a rename or a fresh clone at a different path.
195
- // OpenCode's own auto-generated project-local files (verified 2026-09-14,
196
- // this session, against a live `.opencode/` created by a real OpenCode run
197
- // -- .scratch/opencode-plugin-example/.opencode/.gitignore lists exactly
198
- // these as what OpenCode itself manages: "node_modules\npackage.json\n
199
- // package-lock.json\nbun.lock\n.gitignore"). We never write these
200
- // ourselves; `--purge` removes them ONLY when nothing else user-owned
201
- // remains under `.opencode/` (besides our own now-removed `plugins/*.ts`).
180
+ // OpenCode's own auto-generated project-local files. We never write these;
181
+ // `--purge` removes them only when no user-owned content remains under
182
+ // `.opencode/` after the lock-owned native files are removed.
202
183
  const OPENCODE_AUTOGEN_ENTRIES = new Set([
203
184
  "node_modules",
204
185
  "package.json",
@@ -206,17 +187,6 @@ const OPENCODE_AUTOGEN_ENTRIES = new Set([
206
187
  "bun.lock",
207
188
  ".gitignore",
208
189
  ]);
209
- // ONE marketplace per repo, listing every installed plugin -- not one per
210
- // plugin. A marketplace.json carries exactly one `name` field, so per-plugin
211
- // names cannot coexist in it; the previous `<plugin>-local` scheme meant the
212
- // file's identity was "whoever installed last", and the second plugin silently
213
- // erased the first's entry (measured: installing enact-repo then enact-wiki
214
- // with --force left `name: enact-wiki-local`, `plugins: [enact-wiki]`).
215
- // Claude: "To publish multiple plugins under one marketplace name, list them
216
- // all in a single marketplace.json." Codex reads this file too, as a
217
- // legacy-compatible repo marketplace.
218
- const MARKETPLACE_NAME = "enact-local";
219
- const MARKETPLACE_PATH = ".claude-plugin/marketplace.json";
220
190
  const LOCK_VERSION = 1;
221
191
 
222
192
  const CONFIG_ROOT_CONTENT = "version = 1\n";
@@ -432,128 +402,59 @@ function addLeanCtxConfig(plan, pluginRoot) {
432
402
  return added;
433
403
  }
434
404
 
435
- function addSharedSkills(plan, bundle, codexPolicy) {
405
+ function addNativeSkills(plan, bundle, host) {
436
406
  if (!bundle.skillsDir) return;
407
+ const skillsDir = HOST_SKILLS_DIR[host];
437
408
  for (const entry of readdirSync(bundle.skillsDir, { withFileTypes: true })) {
438
409
  if (!entry.isDirectory()) continue;
439
- plan.skills.add(entry.name);
410
+ plan.skills.add(`${skillsDir}/${entry.name}`);
440
411
  for (const rel of listFilesRecursive(join(bundle.skillsDir, entry.name))) {
441
- // Skill content is force-installed (owner decision): always ours, see
442
- // file-header comment.
443
- const fileRel = `${SHARED_SKILLS_DIR}/${entry.name}/${rel}`;
412
+ const fileRel = `${skillsDir}/${entry.name}/${rel}`;
444
413
  plan.files.set(fileRel, readFileSync(join(bundle.skillsDir, entry.name, rel)));
445
- plan.forcedFiles.add(fileRel);
446
414
  }
447
415
  }
448
- if (!codexPolicy) return;
416
+ if (host !== "codex") return;
449
417
  stageDir("enact-repo-codex-skills-", (staging) => {
450
418
  materializeCodexSkillMetadata(bundle.skillsDir, staging);
451
419
  for (const rel of listFilesRecursive(staging)) {
452
- const fileRel = `${SHARED_SKILLS_DIR}/${rel}`;
420
+ const fileRel = `${skillsDir}/${rel}`;
453
421
  plan.files.set(fileRel, readFileSync(join(staging, rel)));
454
- plan.forcedFiles.add(fileRel);
455
422
  }
456
423
  });
457
424
  }
458
425
 
459
- // Project a self-contained plugin root for a host that has a plugin format,
460
- // and stage it into the plan. Every such host namespaces a plugin's skills by
461
- // the plugin name, which is what lets two extensions ship the same skill name
462
- // safely -- the reason loose per-host files needed a flat shared skills pool.
463
- function addPluginRoot(plan, pluginRoot, bundle, platform) {
464
- const rootRel = `${MANIFEST_BY_PLATFORM[platform].directory}/${bundle.name}`;
465
- stageDir(`enact-repo-${platform}-`, (staging) => {
466
- const target = join(staging, "plugin");
467
- projectPluginRoot(pluginRoot, target, platform);
468
- // Codex reads per-skill invocation policy from `agents/openai.yaml` beside
469
- // each SKILL.md. It used to be written into the shared pool; it belongs
470
- // inside the plugin root now, next to the skills it governs.
471
- if (platform === "codex" && bundle.skillsDir) {
472
- materializeCodexSkillMetadata(bundle.skillsDir, join(target, "skills"));
473
- }
426
+ function addNativeAgents(plan, pluginRoot, host) {
427
+ const source = join(pluginRoot, "agents");
428
+ if (!existsSync(source)) return;
429
+ const targetRel = HOST_AGENTS_DIR[host];
430
+ stageDir(`enact-repo-${host}-agents-`, (staging) => {
431
+ const target = join(staging, "agents");
432
+ projectNativeAgentsDir(source, target, host);
474
433
  for (const rel of listFilesRecursive(target)) {
475
- const fileRel = `${rootRel}/${rel}`;
476
- plan.files.set(fileRel, readFileSync(join(target, rel)));
477
- plan.forcedFiles.add(fileRel);
434
+ plan.files.set(`${targetRel}/${rel}`, readFileSync(join(target, rel)));
478
435
  }
479
436
  });
480
- return rootRel;
481
437
  }
482
438
 
483
- // Host adapters. Codex is a direct-file adapter like Cursor: a committed repo
484
- // marketplace is not auto-discovered by Codex, so none is written.
439
+ // Host adapters write only documented native project files.
485
440
  const HOST_ADAPTERS = {
486
- claude(plan, bundle, pluginRoot, ctx) {
487
- const pluginRel = `.claude-plugin/${bundle.name}`;
488
- stageDir("enact-repo-claude-", (staging) => {
489
- const target = join(staging, "plugin");
490
- // Skills are carried INSIDE the plugin root, like every other host with
491
- // a plugin format -- that is what namespaces them by plugin name. The
492
- // earlier symlink into a shared `.agents/skills/` pool existed only
493
- // because that pool existed; it is retired with it.
494
- projectClaudePluginRoot(pluginRoot, target);
495
- for (const rel of listFilesRecursive(target)) {
496
- // The whole projected plugin root is exclusively our own generated
497
- // content -- force-installed, never gated (see file-header comment).
498
- const fileRel = `${pluginRel}/${rel}`;
499
- plan.files.set(fileRel, readFileSync(join(target, rel)));
500
- plan.forcedFiles.add(fileRel);
441
+ claude(plan, bundle, pluginRoot) {
442
+ addNativeAgents(plan, pluginRoot, "claude");
443
+ addNativeSkills(plan, bundle, "claude");
444
+ const mcp = jsonMerge(plan, ".mcp.json");
445
+ for (const [name, def] of Object.entries(bundle.mcpServers))
446
+ mcp.keys.push({ path: ["mcpServers", name], value: def });
447
+ if (bundle.hooks) {
448
+ const merge = jsonMerge(plan, ".claude/settings.json");
449
+ for (const [event, entries] of Object.entries(applyHostFlagToHooksDoc(bundle.hooks, "claude").hooks ?? {})) {
450
+ for (const entry of entries) merge.items.push({ path: ["hooks", event], value: entry });
501
451
  }
502
- });
503
- const author = bundle.manifest.author;
504
- plan.files.set(
505
- MARKETPLACE_PATH,
506
- Buffer.from(
507
- `${JSON.stringify(
508
- {
509
- name: MARKETPLACE_NAME,
510
- owner: { name: typeof author?.name === "string" ? author.name : bundle.name },
511
- // Seeded from the entry already on disk so a sibling's description
512
- // and version survive OUR install; only our own entry is rebuilt.
513
- // Regeneration must not quietly strip fields it did not author.
514
- plugins: ctx.declaredExtensions.map((name) =>
515
- name === bundle.name
516
- ? {
517
- name,
518
- source: `./.claude-plugin/${name}`,
519
- ...(typeof bundle.manifest.description === "string"
520
- ? { description: bundle.manifest.description }
521
- : {}),
522
- version: bundle.version,
523
- }
524
- : (ctx.existingMarketplaceEntries.get(name) ?? { name, source: `./.claude-plugin/${name}` }),
525
- ),
526
- },
527
- null,
528
- 2,
529
- )}\n`,
530
- ),
531
- );
532
- // NOT forced: a pre-existing FOREIGN marketplace must still be refused
533
- // until --force, with restore-on-uninstall, because it can plausibly be
534
- // the user's own. But it IS derived -- we regenerate it whenever the
535
- // declared set changes, including from a sibling's uninstall -- so it is
536
- // exempt from the uninstall drift check, which would otherwise read our
537
- // own regeneration as damage.
538
- plan.derivedFiles.add(MARKETPLACE_PATH);
539
- const settings = jsonMerge(plan, ".claude/settings.json");
540
- settings.keys.push({
541
- path: ["extraKnownMarketplaces", MARKETPLACE_NAME],
542
- value: { source: { source: "directory", path: "./" } },
543
- });
544
- settings.keys.push({ path: ["enabledPlugins", `${bundle.name}@${MARKETPLACE_NAME}`], value: true });
545
- // NOTE: the installer deliberately writes NO lean-ctx env carrier here.
546
- // Scoping lean-ctx to the repo config is done by `enact-hook`, which
547
- // prefixes LEAN_CTX_CONFIG_DIR/DATA_DIR/STATE_DIR/CACHE_DIR onto the
548
- // rewritten command, derived from `inv.root` at invocation time. Nothing
549
- // is stored, so nothing can go stale, and it is not Claude-specific.
550
- // A settings.local.json `env` block was tried and rejected: it is
551
- // Claude-only, machine-local, gitignored, and does not travel with the
552
- // repo. Do not reintroduce it.
452
+ }
553
453
  },
554
454
 
555
455
  codex(plan, bundle, pluginRoot) {
556
- addPluginRoot(plan, pluginRoot, bundle, "codex");
456
+ addNativeAgents(plan, pluginRoot, "codex");
457
+ addNativeSkills(plan, bundle, "codex");
557
458
  if (Object.keys(bundle.mcpServers).length > 0) plan.toml.set(".codex/config.toml", { tables: bundle.mcpServers });
558
459
  if (bundle.hooks) {
559
460
  const merge = jsonMerge(plan, ".codex/hooks.json");
@@ -571,7 +472,8 @@ const HOST_ADAPTERS = {
571
472
  },
572
473
 
573
474
  cursor(plan, bundle, pluginRoot) {
574
- addPluginRoot(plan, pluginRoot, bundle, "cursor");
475
+ addNativeAgents(plan, pluginRoot, "cursor");
476
+ addNativeSkills(plan, bundle, "cursor");
575
477
  if (bundle.hooks) {
576
478
  const dropped = unmappedCursorHookEvents(bundle.hooks);
577
479
  if (dropped.length > 0) {
@@ -596,24 +498,26 @@ const HOST_ADAPTERS = {
596
498
  },
597
499
 
598
500
  kimi(plan, bundle, pluginRoot) {
599
- addPluginRoot(plan, pluginRoot, bundle, "kimi");
501
+ addNativeAgents(plan, pluginRoot, "kimi");
502
+ addNativeSkills(plan, bundle, "kimi");
600
503
  const mcp = jsonMerge(plan, ".kimi-code/mcp.json");
601
504
  for (const [name, def] of Object.entries(bundle.mcpServers)) {
602
505
  mcp.keys.push({ path: ["mcpServers", name], value: def });
603
506
  }
604
507
  },
605
508
 
606
- opencode(plan, bundle) {
607
- // No skillsDir on purpose: OpenCode natively reads the shared .agents/skills/
608
- // copy; a skills.paths entry would list every skill twice.
609
- const source = deriveOpenCodePlugin(
610
- { hooks: bundle.hooks ?? undefined, mcpServers: { mcpServers: bundle.mcpServers } },
611
- bundle.name,
612
- );
613
- // Generated whole file (hooks + mcp): force-installed, see file-header comment.
614
- const fileRel = `.opencode/plugins/${bundle.name}.ts`;
615
- plan.files.set(fileRel, Buffer.from(source));
616
- plan.forcedFiles.add(fileRel);
509
+ opencode(plan, bundle, pluginRoot) {
510
+ if (bundle.hooks) {
511
+ throw new Error(
512
+ "OpenCode repo-local installation cannot install hooks: install the explicit global OpenCode plugin through enact-vps first",
513
+ );
514
+ }
515
+ addNativeAgents(plan, pluginRoot, "opencode");
516
+ addNativeSkills(plan, bundle, "opencode");
517
+ const mcp = jsonMerge(plan, "opencode.json");
518
+ for (const [name, def] of Object.entries(deriveOpenCodeMcp({ mcpServers: bundle.mcpServers }).mcp)) {
519
+ mcp.keys.push({ path: ["mcp", name], value: def });
520
+ }
617
521
  },
618
522
  };
619
523
 
@@ -631,10 +535,8 @@ export function buildPlan(pluginRoot, bundle, hosts, repo) {
631
535
  // `--force`, never captured for a displaced-content restore. See the
632
536
  // file-header doc comment.
633
537
  forcedFiles: new Set(),
634
- // Files we REGENERATE from a source of truth (today: the marketplace, built
635
- // from the declared extension set). Still conflict-gated on install, but
636
- // exempt from the uninstall drift check -- a sibling's install or uninstall
637
- // legitimately rewrites them.
538
+ // Files regenerated from a source of truth. They are exempt from uninstall
539
+ // drift checks when a sibling can legitimately rewrite them.
638
540
  derivedFiles: new Set(),
639
541
  json: new Map(),
640
542
  toml: new Map(),
@@ -645,40 +547,15 @@ export function buildPlan(pluginRoot, bundle, hosts, repo) {
645
547
  plan.gitignore.set(GITIGNORE_PATH, GITIGNORE_LINES);
646
548
  const leanCtxConfig = addLeanCtxConfig(plan, pluginRoot);
647
549
 
648
- // The repo's DECLARED extension set, from the `[extensions.<name>]` tables. Installing a
649
- // plugin declares it: the file is the user-editable statement of what this
650
- // repo should have, and install reconciles the repo to it. Order is stable
651
- // (declaration order, ours appended last) so regenerating the marketplace
652
- // does not churn the diff.
550
+ // The repo's declared extension set is the user-editable membership roster.
653
551
  const configPath = abs(repo, CONFIG_PATH);
654
552
  const configText = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
655
553
  const declared = readDeclaredExtensions(configText);
656
554
  const declaredExtensions = declared.includes(bundle.name) ? declared : [...declared, bundle.name];
657
555
 
658
- // Existing marketplace entries, so regeneration preserves fields belonging to
659
- // plugins other than the one being installed.
660
- const existingMarketplaceEntries = new Map();
661
- const mktPath = abs(repo, MARKETPLACE_PATH);
662
- if (existsSync(mktPath)) {
663
- try {
664
- for (const entry of JSON.parse(readFileSync(mktPath, "utf8")).plugins ?? []) {
665
- if (entry && typeof entry.name === "string") existingMarketplaceEntries.set(entry.name, entry);
666
- }
667
- } catch {
668
- // Unparsable or foreign: the ordinary conflict gate handles it below.
669
- }
670
- }
671
-
672
- const ctx = { repo, leanCtxConfig, declaredExtensions, existingMarketplaceEntries };
556
+ const ctx = { repo, leanCtxConfig, declaredExtensions };
673
557
  for (const host of hosts) HOST_ADAPTERS[host](plan, bundle, pluginRoot, ctx);
674
558
  plan.declaredExtensions = declaredExtensions;
675
- // `.agents/skills` is written UNCONDITIONALLY -- .agents is universal, and a
676
- // claude-only install still needs the real tree its plugin skills/ symlink
677
- // points at, or the link dangles and Claude Code drops the skills silently.
678
- // Only for OpenCode. `.agents/skills/` is retired: it was a flat pool shared
679
- // by every host precisely because we were not building plugin roots, and a
680
- // flat pool is what made two extensions shipping one skill name unsafe.
681
- if (hosts.includes("opencode")) addSharedSkills(plan, bundle, false);
682
559
  for (const [rel, merge] of [...plan.json]) {
683
560
  if (merge.keys.length + merge.items.length === 0) plan.json.delete(rel);
684
561
  }
@@ -919,14 +796,12 @@ export function runRepoInstall(pluginRoot, options = {}) {
919
796
  // made every sibling-owned dir look foreign, which refused the install with
920
797
  // a false "non-managed" claim and advised deleting files another plugin
921
798
  // depends on.
922
- const skillConflicts = [...plan.skills]
923
- .map((skill) => `${SHARED_SKILLS_DIR}/${skill}`)
924
- .filter(
925
- (dirRel) =>
926
- existsSync(abs(repo, dirRel)) &&
927
- !Object.keys(prev.files).some((f) => f.startsWith(`${dirRel}/`)) &&
928
- !siblingOwnsPrefix(siblings, dirRel),
929
- );
799
+ const skillConflicts = [...plan.skills].filter(
800
+ (dirRel) =>
801
+ existsSync(abs(repo, dirRel)) &&
802
+ !Object.keys(prev.files).some((f) => f.startsWith(`${dirRel}/`)) &&
803
+ !siblingOwnsPrefix(siblings, dirRel),
804
+ );
930
805
  if (skillConflicts.length > 0) {
931
806
  throw new Error(
932
807
  `Refusing to overwrite existing user-authored skill dir(s); rename or remove them first:\n ${skillConflicts.join("\n ")}`,
@@ -940,13 +815,7 @@ export function runRepoInstall(pluginRoot, options = {}) {
940
815
  // that reads it, so two plugins cannot ship different skills under one name.
941
816
  // Refuse, and do NOT let --force through -- forcing would leave the other
942
817
  // plugin's lock pointing at bytes it did not write.
943
- // `.claude-plugin/marketplace.json` is excluded: it is a multi-plugin
944
- // REGISTRY, so differing content there is expected and correct -- every
945
- // plugin adds its own entry. It is still written as a whole file today, so
946
- // the second install is refused by the ordinary foreign-content gate below;
947
- // Phase 2 turns it into a merged surface keyed by `plugins[].name`. Listing
948
- // it here would report a real collision with useless advice ("rename one").
949
- const SHARED_CONTENT_EXEMPT = new Set([".claude-plugin/marketplace.json"]);
818
+ const SHARED_CONTENT_EXEMPT = new Set();
950
819
  const divergent = [];
951
820
  // Every repo-relative path this run changed, in write order. Declared HERE
952
821
  // rather than beside the other `-- apply` locals because the stale-symlink
@@ -1033,8 +902,8 @@ export function runRepoInstall(pluginRoot, options = {}) {
1033
902
  // Owned by US or by any SIBLING enact plugin. A key another installed
1034
903
  // extension wrote is not foreign: capturing it as a "displaced original"
1035
904
  // made uninstall RESTORE it instead of deleting it, so a shared
1036
- // registration key (`extraKnownMarketplaces.<marketplace>`) survived the
1037
- // last uninstall forever.
905
+ // legacy registration key survives migration only while another installed
906
+ // extension still records it; otherwise the final uninstall removes it.
1038
907
  const ownedKeys = new Set([
1039
908
  ...(record?.entries ?? []).filter((e) => e.type === "key").map((e) => pathKey(e.path)),
1040
909
  ...siblings.flatMap(({ lock: l }) =>
@@ -1166,13 +1035,10 @@ export function runRepoInstall(pluginRoot, options = {}) {
1166
1035
  const created = [];
1167
1036
  for (const s of want.scaffold) {
1168
1037
  if (getAt(doc, s.path) === undefined) doc[s.path[0]] = s.value;
1169
- // Recorded whether or not WE created it. Scaffold is shared repo-level
1170
- // framing (a marketplace's `name`/`owner`), so recording it only in the
1171
- // creator's lock left the LAST plugin out with nothing to prune: it
1172
- // removed its own entry, `plugins` emptied, and `{name, owner}` stayed
1173
- // behind forever in a marketplace listing no plugins. The prune is
1174
- // already conditional on nothing else remaining, so recording it in
1175
- // every owner's lock is safe.
1038
+ // Record shared framing whether or not we created it. This is retained
1039
+ // for lock migration: all current install projections are direct native
1040
+ // files, but an old lock can still name shared metadata that must be
1041
+ // removed only after its final owner uninstalls.
1176
1042
  if (!scaffold.some((x) => pathKey(x.path) === pathKey(s.path))) scaffold.push({ path: s.path });
1177
1043
  }
1178
1044
  const entries = [];
@@ -1362,19 +1228,8 @@ export function runRepoInstall(pluginRoot, options = {}) {
1362
1228
  console.log(
1363
1229
  written.length > 0 ? `${dryRun ? "would change" : "changed"} ${written.length} file(s)` : "up to date (no changes)",
1364
1230
  );
1365
- // Live-testing finding (2026-09-14): even after trusting the folder, hooks
1366
- // and skills were observed NOT active in the very FIRST Claude Code
1367
- // session in this trusted folder (the newly-added extraKnownMarketplaces
1368
- // entry + enabledPlugins registers on that first launch, but does not
1369
- // finish activating in time for that same session) -- the SECOND session
1370
- // works. This matches Claude Code's own documented plugin-activation
1371
- // caveats (code.claude.com/docs/en/discover-plugins, "Apply plugin
1372
- // changes without restarting"). Told explicitly here so a user isn't left
1373
- // thinking the install failed.
1374
1231
  if (hosts.includes("claude")) {
1375
- console.log(
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)",
1377
- );
1232
+ console.log("claude: trust this folder and review the project hooks before using the installed agents and skills");
1378
1233
  }
1379
1234
  if (hosts.includes("codex"))
1380
1235
  console.log("codex: config and hooks load after you trust this project and review hooks (/hooks)");
@@ -1398,22 +1253,8 @@ export function runRepoUninstall(name, options = {}) {
1398
1253
  // Other plugins still installed here; their claims outlive this uninstall.
1399
1254
  const siblings = readSiblingLocks(repo, name);
1400
1255
 
1401
- // Captured BEFORE the removal loop deletes the file: uninstalling one
1402
- // extension must leave a marketplace listing the ones that remain, so the
1403
- // surviving entries have to be read while they still exist.
1404
1256
  const uninstallConfigPath = abs(repo, CONFIG_PATH);
1405
1257
  const uninstallConfigText = existsSync(uninstallConfigPath) ? readFileSync(uninstallConfigPath, "utf8") : "";
1406
- const survivingMarketplace = (() => {
1407
- const p = abs(repo, MARKETPLACE_PATH);
1408
- if (!existsSync(p)) return null;
1409
- try {
1410
- const doc = JSON.parse(readFileSync(p, "utf8"));
1411
- const kept = (doc.plugins ?? []).filter((e) => e && e.name !== name);
1412
- return kept.length > 0 ? { ...doc, plugins: kept } : null;
1413
- } catch {
1414
- return null;
1415
- }
1416
- })();
1417
1258
 
1418
1259
  const edited = [];
1419
1260
  for (const [rel, record] of Object.entries(lock.files)) {
@@ -1439,11 +1280,9 @@ export function runRepoUninstall(name, options = {}) {
1439
1280
  const text = readFileSync(path, "utf8");
1440
1281
  const doc = readJsonStrict(path, rel);
1441
1282
  for (const entry of record.entries) {
1442
- // An entry a SIBLING also records is shared registration, not drift:
1443
- // both plugins legitimately claim `extraKnownMarketplaces.<marketplace>`
1444
- // and the marketplace's own `name`/`owner`. Whichever uninstalls first
1445
- // removes it, and the second must not then report it as edited -- that
1446
- // read the shared state as damage and refused the uninstall outright.
1283
+ // An entry a SIBLING also records is shared lock-owned metadata, not
1284
+ // drift. This only matters while uninstalling historical lock content;
1285
+ // current installs create no plugin registration or marketplace data.
1447
1286
  if (siblingRecordsJsonEntry(siblings, rel, entry)) continue;
1448
1287
  if (!entryPresent(doc, entry)) edited.push(`${rel} (${entry.path.join(".")})`);
1449
1288
  }
@@ -1497,16 +1336,6 @@ export function runRepoUninstall(name, options = {}) {
1497
1336
  for (const rel of Object.keys(lock.symlinks ?? {})) {
1498
1337
  ops.removeSymlink(rel, "managed symlink");
1499
1338
  }
1500
- // Put the marketplace back if other extensions are still declared. The
1501
- // removal loop above deleted it as one of our managed files; regenerating it
1502
- // from the survivors is what keeps the remaining plugins loadable.
1503
- if (survivingMarketplace) {
1504
- ops.write(
1505
- MARKETPLACE_PATH,
1506
- `${JSON.stringify(survivingMarketplace, null, 2)}\n`,
1507
- `marketplace regenerated without ${name}`,
1508
- );
1509
- }
1510
1339
  // Undeclare this extension: remove its own `[extensions.<name>]` table. The
1511
1340
  // `[extensions]` root table is cut only by --purge, and only once no other
1512
1341
  // extension is declared.
@@ -1520,12 +1349,9 @@ export function runRepoUninstall(name, options = {}) {
1520
1349
  for (const [rel, record] of Object.entries(lock.merged)) {
1521
1350
  if (!jsonDocs.has(rel)) continue;
1522
1351
  const { text, doc } = jsonDocs.get(rel);
1523
- // Reference counting, same law as managed files: an entry a sibling still
1524
- // records is shared repo-level registration, not ours to delete. Without
1525
- // this the FIRST uninstall removed `extraKnownMarketplaces.<marketplace>`
1526
- // and the marketplace's `name`/`owner`, leaving the surviving plugin
1527
- // unregistered -- and its own uninstall then refused, reporting the
1528
- // missing shared entry as drift.
1352
+ // Reference-count historical shared metadata just as managed files. A
1353
+ // sibling lock can still own an old registration during migration, so the
1354
+ // first uninstall must leave it for the final owner to remove.
1529
1355
  const retained = record.entries.filter((e) => siblingRecordsJsonEntry(siblings, rel, e));
1530
1356
  const ours = { ...record, entries: record.entries.filter((e) => !retained.includes(e)) };
1531
1357
  for (const e of retained) ops.skip(`${rel} (${e.path.join(".")})`, "still registered by another installed plugin");