@amsterdamdatalabs/enact-extensions 0.1.51 → 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.
- 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/extensions/enact-repo/skills/to-feature/SKILL.md +2 -2
- package/extensions/enact-repo/skills/to-feature/references/FEATURE-SPEC-TEMPLATE.md +6 -5
- package/extensions/enact-repo/skills/to-workitems/SKILL.md +6 -5
- package/extensions/enact-repo/skills/to-workitems/references/TICKET-TEMPLATES.md +7 -5
- package/package.json +1 -1
- package/scripts/lib/run-repo-doctor.mjs +55 -34
- package/scripts/lib/run-repo-install.mjs +111 -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
|
+
* 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
|
-
*
|
|
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,26 @@ 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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
);
|
|
613
|
-
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
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
|
|
635
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
924
|
-
|
|
925
|
-
(dirRel)
|
|
926
|
-
|
|
927
|
-
|
|
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
|
-
|
|
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
|
|
1037
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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");
|