@amsterdamdatalabs/enact-extensions 0.1.54 → 0.1.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -11
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/agents.d.ts +31 -6
- package/dist/internal/agents.d.ts.map +1 -1
- package/dist/internal/agents.js +234 -4
- package/dist/internal/agents.js.map +1 -1
- package/extensions/enact-repo/.agents/plugin.json +1 -1
- package/extensions/enact-repo/README.md +16 -9
- package/package.json +1 -1
- package/scripts/lib/run-repo-doctor.mjs +55 -34
- package/scripts/lib/run-repo-install.mjs +106 -285
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
+
* Its process-level hook carrier is global, while this installer
|
|
21
|
+
* owns every repo-local artifact and legacy-root migration.
|
|
30
22
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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`
|
|
60
|
-
* `.
|
|
61
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
196
|
-
//
|
|
197
|
-
//
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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 = `${
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,21 @@ const HOST_ADAPTERS = {
|
|
|
596
498
|
},
|
|
597
499
|
|
|
598
500
|
kimi(plan, bundle, pluginRoot) {
|
|
599
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
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
|
+
addNativeAgents(plan, pluginRoot, "opencode");
|
|
511
|
+
addNativeSkills(plan, bundle, "opencode");
|
|
512
|
+
const mcp = jsonMerge(plan, "opencode.json");
|
|
513
|
+
for (const [name, def] of Object.entries(deriveOpenCodeMcp({ mcpServers: bundle.mcpServers }).mcp)) {
|
|
514
|
+
mcp.keys.push({ path: ["mcp", name], value: def });
|
|
515
|
+
}
|
|
617
516
|
},
|
|
618
517
|
};
|
|
619
518
|
|
|
@@ -631,10 +530,8 @@ export function buildPlan(pluginRoot, bundle, hosts, repo) {
|
|
|
631
530
|
// `--force`, never captured for a displaced-content restore. See the
|
|
632
531
|
// file-header doc comment.
|
|
633
532
|
forcedFiles: new Set(),
|
|
634
|
-
// Files
|
|
635
|
-
//
|
|
636
|
-
// exempt from the uninstall drift check -- a sibling's install or uninstall
|
|
637
|
-
// legitimately rewrites them.
|
|
533
|
+
// Files regenerated from a source of truth. They are exempt from uninstall
|
|
534
|
+
// drift checks when a sibling can legitimately rewrite them.
|
|
638
535
|
derivedFiles: new Set(),
|
|
639
536
|
json: new Map(),
|
|
640
537
|
toml: new Map(),
|
|
@@ -645,40 +542,15 @@ export function buildPlan(pluginRoot, bundle, hosts, repo) {
|
|
|
645
542
|
plan.gitignore.set(GITIGNORE_PATH, GITIGNORE_LINES);
|
|
646
543
|
const leanCtxConfig = addLeanCtxConfig(plan, pluginRoot);
|
|
647
544
|
|
|
648
|
-
// The repo's
|
|
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.
|
|
545
|
+
// The repo's declared extension set is the user-editable membership roster.
|
|
653
546
|
const configPath = abs(repo, CONFIG_PATH);
|
|
654
547
|
const configText = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
|
|
655
548
|
const declared = readDeclaredExtensions(configText);
|
|
656
549
|
const declaredExtensions = declared.includes(bundle.name) ? declared : [...declared, bundle.name];
|
|
657
550
|
|
|
658
|
-
|
|
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 };
|
|
551
|
+
const ctx = { repo, leanCtxConfig, declaredExtensions };
|
|
673
552
|
for (const host of hosts) HOST_ADAPTERS[host](plan, bundle, pluginRoot, ctx);
|
|
674
553
|
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
554
|
for (const [rel, merge] of [...plan.json]) {
|
|
683
555
|
if (merge.keys.length + merge.items.length === 0) plan.json.delete(rel);
|
|
684
556
|
}
|
|
@@ -919,14 +791,12 @@ export function runRepoInstall(pluginRoot, options = {}) {
|
|
|
919
791
|
// made every sibling-owned dir look foreign, which refused the install with
|
|
920
792
|
// a false "non-managed" claim and advised deleting files another plugin
|
|
921
793
|
// depends on.
|
|
922
|
-
const skillConflicts = [...plan.skills]
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
(dirRel)
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
!siblingOwnsPrefix(siblings, dirRel),
|
|
929
|
-
);
|
|
794
|
+
const skillConflicts = [...plan.skills].filter(
|
|
795
|
+
(dirRel) =>
|
|
796
|
+
existsSync(abs(repo, dirRel)) &&
|
|
797
|
+
!Object.keys(prev.files).some((f) => f.startsWith(`${dirRel}/`)) &&
|
|
798
|
+
!siblingOwnsPrefix(siblings, dirRel),
|
|
799
|
+
);
|
|
930
800
|
if (skillConflicts.length > 0) {
|
|
931
801
|
throw new Error(
|
|
932
802
|
`Refusing to overwrite existing user-authored skill dir(s); rename or remove them first:\n ${skillConflicts.join("\n ")}`,
|
|
@@ -940,13 +810,7 @@ export function runRepoInstall(pluginRoot, options = {}) {
|
|
|
940
810
|
// that reads it, so two plugins cannot ship different skills under one name.
|
|
941
811
|
// Refuse, and do NOT let --force through -- forcing would leave the other
|
|
942
812
|
// plugin's lock pointing at bytes it did not write.
|
|
943
|
-
|
|
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"]);
|
|
813
|
+
const SHARED_CONTENT_EXEMPT = new Set();
|
|
950
814
|
const divergent = [];
|
|
951
815
|
// Every repo-relative path this run changed, in write order. Declared HERE
|
|
952
816
|
// rather than beside the other `-- apply` locals because the stale-symlink
|
|
@@ -1033,8 +897,8 @@ export function runRepoInstall(pluginRoot, options = {}) {
|
|
|
1033
897
|
// Owned by US or by any SIBLING enact plugin. A key another installed
|
|
1034
898
|
// extension wrote is not foreign: capturing it as a "displaced original"
|
|
1035
899
|
// made uninstall RESTORE it instead of deleting it, so a shared
|
|
1036
|
-
// registration key
|
|
1037
|
-
//
|
|
900
|
+
// legacy registration key survives migration only while another installed
|
|
901
|
+
// extension still records it; otherwise the final uninstall removes it.
|
|
1038
902
|
const ownedKeys = new Set([
|
|
1039
903
|
...(record?.entries ?? []).filter((e) => e.type === "key").map((e) => pathKey(e.path)),
|
|
1040
904
|
...siblings.flatMap(({ lock: l }) =>
|
|
@@ -1166,13 +1030,10 @@ export function runRepoInstall(pluginRoot, options = {}) {
|
|
|
1166
1030
|
const created = [];
|
|
1167
1031
|
for (const s of want.scaffold) {
|
|
1168
1032
|
if (getAt(doc, s.path) === undefined) doc[s.path[0]] = s.value;
|
|
1169
|
-
//
|
|
1170
|
-
//
|
|
1171
|
-
//
|
|
1172
|
-
// removed
|
|
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.
|
|
1033
|
+
// Record shared framing whether or not we created it. This is retained
|
|
1034
|
+
// for lock migration: all current install projections are direct native
|
|
1035
|
+
// files, but an old lock can still name shared metadata that must be
|
|
1036
|
+
// removed only after its final owner uninstalls.
|
|
1176
1037
|
if (!scaffold.some((x) => pathKey(x.path) === pathKey(s.path))) scaffold.push({ path: s.path });
|
|
1177
1038
|
}
|
|
1178
1039
|
const entries = [];
|
|
@@ -1362,19 +1223,8 @@ export function runRepoInstall(pluginRoot, options = {}) {
|
|
|
1362
1223
|
console.log(
|
|
1363
1224
|
written.length > 0 ? `${dryRun ? "would change" : "changed"} ${written.length} file(s)` : "up to date (no changes)",
|
|
1364
1225
|
);
|
|
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
1226
|
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
|
-
);
|
|
1227
|
+
console.log("claude: trust this folder and review the project hooks before using the installed agents and skills");
|
|
1378
1228
|
}
|
|
1379
1229
|
if (hosts.includes("codex"))
|
|
1380
1230
|
console.log("codex: config and hooks load after you trust this project and review hooks (/hooks)");
|
|
@@ -1398,22 +1248,8 @@ export function runRepoUninstall(name, options = {}) {
|
|
|
1398
1248
|
// Other plugins still installed here; their claims outlive this uninstall.
|
|
1399
1249
|
const siblings = readSiblingLocks(repo, name);
|
|
1400
1250
|
|
|
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
1251
|
const uninstallConfigPath = abs(repo, CONFIG_PATH);
|
|
1405
1252
|
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
1253
|
|
|
1418
1254
|
const edited = [];
|
|
1419
1255
|
for (const [rel, record] of Object.entries(lock.files)) {
|
|
@@ -1439,11 +1275,9 @@ export function runRepoUninstall(name, options = {}) {
|
|
|
1439
1275
|
const text = readFileSync(path, "utf8");
|
|
1440
1276
|
const doc = readJsonStrict(path, rel);
|
|
1441
1277
|
for (const entry of record.entries) {
|
|
1442
|
-
// An entry a SIBLING also records is shared
|
|
1443
|
-
//
|
|
1444
|
-
//
|
|
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.
|
|
1278
|
+
// An entry a SIBLING also records is shared lock-owned metadata, not
|
|
1279
|
+
// drift. This only matters while uninstalling historical lock content;
|
|
1280
|
+
// current installs create no plugin registration or marketplace data.
|
|
1447
1281
|
if (siblingRecordsJsonEntry(siblings, rel, entry)) continue;
|
|
1448
1282
|
if (!entryPresent(doc, entry)) edited.push(`${rel} (${entry.path.join(".")})`);
|
|
1449
1283
|
}
|
|
@@ -1497,16 +1331,6 @@ export function runRepoUninstall(name, options = {}) {
|
|
|
1497
1331
|
for (const rel of Object.keys(lock.symlinks ?? {})) {
|
|
1498
1332
|
ops.removeSymlink(rel, "managed symlink");
|
|
1499
1333
|
}
|
|
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
1334
|
// Undeclare this extension: remove its own `[extensions.<name>]` table. The
|
|
1511
1335
|
// `[extensions]` root table is cut only by --purge, and only once no other
|
|
1512
1336
|
// extension is declared.
|
|
@@ -1520,12 +1344,9 @@ export function runRepoUninstall(name, options = {}) {
|
|
|
1520
1344
|
for (const [rel, record] of Object.entries(lock.merged)) {
|
|
1521
1345
|
if (!jsonDocs.has(rel)) continue;
|
|
1522
1346
|
const { text, doc } = jsonDocs.get(rel);
|
|
1523
|
-
// Reference
|
|
1524
|
-
//
|
|
1525
|
-
//
|
|
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.
|
|
1347
|
+
// Reference-count historical shared metadata just as managed files. A
|
|
1348
|
+
// sibling lock can still own an old registration during migration, so the
|
|
1349
|
+
// first uninstall must leave it for the final owner to remove.
|
|
1529
1350
|
const retained = record.entries.filter((e) => siblingRecordsJsonEntry(siblings, rel, e));
|
|
1530
1351
|
const ours = { ...record, entries: record.entries.filter((e) => !retained.includes(e)) };
|
|
1531
1352
|
for (const e of retained) ops.skip(`${rel} (${e.path.join(".")})`, "still registered by another installed plugin");
|