@amsterdamdatalabs/enact-extensions 0.1.1 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +4 -3
  2. package/dist/index.d.ts +5 -3
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +3 -2
  5. package/dist/index.js.map +1 -1
  6. package/dist/install.d.ts +82 -1
  7. package/dist/install.d.ts.map +1 -1
  8. package/dist/install.js +187 -35
  9. package/dist/install.js.map +1 -1
  10. package/dist/internal/codex.d.ts.map +1 -1
  11. package/dist/internal/codex.js +7 -1
  12. package/dist/internal/codex.js.map +1 -1
  13. package/dist/internal/platform.d.ts +8 -0
  14. package/dist/internal/platform.d.ts.map +1 -1
  15. package/dist/internal/platform.js +46 -2
  16. package/dist/internal/platform.js.map +1 -1
  17. package/dist/provision.d.ts +30 -0
  18. package/dist/provision.d.ts.map +1 -0
  19. package/dist/provision.js +202 -0
  20. package/dist/provision.js.map +1 -0
  21. package/dist/validate/index.d.ts +23 -0
  22. package/dist/validate/index.d.ts.map +1 -1
  23. package/dist/validate/index.js +80 -0
  24. package/dist/validate/index.js.map +1 -1
  25. package/extensions/enact-context/.agents/plugin.json +40 -0
  26. package/extensions/enact-context/.mcp.json +8 -0
  27. package/extensions/enact-context/README.md +25 -0
  28. package/extensions/enact-context/assets/icon.png +0 -0
  29. package/extensions/enact-context/assets/logo.png +0 -0
  30. package/extensions/enact-context/hooks/hooks.json +105 -0
  31. package/extensions/enact-context/skills/enact-context/SKILL.md +149 -0
  32. package/extensions/enact-context/skills/enact-context/scripts/install.sh +69 -0
  33. package/extensions/enact-factory/.agents/plugin.json +42 -0
  34. package/extensions/enact-factory/.mcp.json +8 -0
  35. package/extensions/enact-factory/assets/icon.png +0 -0
  36. package/extensions/enact-factory/assets/logo.png +0 -0
  37. package/extensions/enact-factory/hooks/user-prompt-submit.mjs +67 -0
  38. package/extensions/enact-factory/skills/testing-strategy/SKILL.md +167 -0
  39. package/extensions/enact-factory/skills/workitem-triage/SKILL.md +22 -0
  40. package/extensions/enact-operator/.agents/plugin.json +57 -0
  41. package/extensions/enact-operator/.app.json +3 -0
  42. package/extensions/enact-operator/.mcp.json +10 -0
  43. package/extensions/enact-operator/_taxonomy.md +86 -0
  44. package/extensions/enact-operator/agents/README.md +5 -0
  45. package/extensions/enact-operator/agents/architect.toml +25 -0
  46. package/extensions/enact-operator/agents/code-reviewer.toml +24 -0
  47. package/extensions/enact-operator/agents/critic.toml +30 -0
  48. package/extensions/enact-operator/agents/executor.toml +24 -0
  49. package/extensions/enact-operator/agents/explore.toml +23 -0
  50. package/extensions/enact-operator/agents/planner.toml +24 -0
  51. package/extensions/enact-operator/agents/verifier.toml +24 -0
  52. package/extensions/enact-operator/assets/icon.png +0 -0
  53. package/extensions/enact-operator/assets/logo.png +0 -0
  54. package/extensions/enact-operator/commands/doctor.md +39 -0
  55. package/extensions/enact-operator/commands/setup.md +51 -0
  56. package/extensions/enact-operator/hooks/hooks.json +126 -0
  57. package/extensions/enact-operator/skills/_variants.md +44 -0
  58. package/extensions/enact-operator/skills/ai-slop-cleaner/SKILL.md +50 -0
  59. package/extensions/enact-operator/skills/analyze/SKILL.md +91 -0
  60. package/extensions/enact-operator/skills/ask/SKILL.md +47 -0
  61. package/extensions/enact-operator/skills/autopilot/SKILL.md +170 -0
  62. package/extensions/enact-operator/skills/autoresearch-goal/SKILL.md +79 -0
  63. package/extensions/enact-operator/skills/cancel/SKILL.md +99 -0
  64. package/extensions/enact-operator/skills/configure-notifications/SKILL.md +77 -0
  65. package/extensions/enact-operator/skills/deep-interview/SKILL.md +80 -0
  66. package/extensions/enact-operator/skills/doctor/SKILL.md +48 -0
  67. package/extensions/enact-operator/skills/hud/SKILL.md +49 -0
  68. package/extensions/enact-operator/skills/hyperplan/SKILL.md +47 -0
  69. package/extensions/enact-operator/skills/plan/SKILL.md +78 -0
  70. package/extensions/enact-operator/skills/ralph/SKILL.md +201 -0
  71. package/extensions/enact-operator/skills/ralph/gemini.md +18 -0
  72. package/extensions/enact-operator/skills/ralplan/SKILL.md +151 -0
  73. package/extensions/enact-operator/skills/remove-deadcode/SKILL.md +45 -0
  74. package/extensions/enact-operator/skills/research/SKILL.md +74 -0
  75. package/extensions/enact-operator/skills/review/SKILL.md +58 -0
  76. package/extensions/enact-operator/skills/security-research/SKILL.md +54 -0
  77. package/extensions/enact-operator/skills/setup/SKILL.md +91 -0
  78. package/extensions/enact-operator/skills/setup/scripts/install.sh +50 -0
  79. package/extensions/enact-operator/skills/skill/SKILL.md +82 -0
  80. package/extensions/enact-operator/skills/tdd/SKILL.md +59 -0
  81. package/extensions/enact-operator/skills/team/SKILL.md +199 -0
  82. package/extensions/enact-operator/skills/trace/SKILL.md +41 -0
  83. package/extensions/enact-operator/skills/ultragoal/SKILL.md +99 -0
  84. package/extensions/enact-operator/skills/ultraqa/SKILL.md +113 -0
  85. package/extensions/enact-operator/skills/ultrawork/SKILL.md +145 -0
  86. package/extensions/enact-operator/skills/ultrawork/planner.md +28 -0
  87. package/extensions/enact-operator/skills/wiki/SKILL.md +41 -0
  88. package/extensions/enact-operator/skills/work-with-workitem/SKILL.md +51 -0
  89. package/extensions/enact-wiki/.agents/plugin.json +42 -0
  90. package/extensions/enact-wiki/.mcp.json +15 -0
  91. package/extensions/enact-wiki/README.md +44 -0
  92. package/extensions/enact-wiki/assets/icon.png +0 -0
  93. package/extensions/enact-wiki/assets/logo.png +0 -0
  94. package/extensions/enact-wiki/skills/document-parser/SKILL.md +17 -0
  95. package/extensions/enact-wiki/skills/document-parser/scripts/parse.sh +60 -0
  96. package/extensions/enact-wiki/skills/document-parser/skill.json +9 -0
  97. package/extensions/enact-wiki/skills/enact-wiki/SKILL.md +30 -0
  98. package/extensions/enact-wiki/skills/enact-wiki/references/ingest.md +62 -0
  99. package/extensions/enact-wiki/skills/enact-wiki/references/manage.md +34 -0
  100. package/extensions/enact-wiki/skills/enact-wiki/references/query.md +59 -0
  101. package/extensions/enact-wiki/skills/search-lab/SKILL.md +57 -0
  102. package/extensions/enact-wiki/skills/search-lab/scripts/analyze.ts +23 -0
  103. package/package.json +1 -1
  104. package/scripts/enact-extensions.mjs +79 -12
  105. package/scripts/lib/hooks.mjs +352 -0
  106. package/scripts/lib/ledger.mjs +4 -3
  107. package/scripts/lib/provision-mcp.mjs +12 -365
  108. package/scripts/lib/run-install.mjs +87 -5
  109. package/scripts/lib/run-prune.mjs +73 -0
  110. package/scripts/lib/run-sync.mjs +9 -1
  111. package/scripts/lib/run-uninstall.mjs +26 -2
  112. package/scripts/lib/run-validate.mjs +10 -1
  113. package/scripts/lib/serve.mjs +19 -1
  114. package/scripts/version-bump.sh +463 -0
  115. package/spec/codex.json +1 -11
@@ -1,369 +1,16 @@
1
1
  /**
2
- * provision-mcp.mjs auto-provision a bundle's MCP-server dependencies at install.
2
+ * CLI/runtime wrapper over the typed library provisioning API.
3
3
  *
4
- * A bundle's canonical .agents/plugin.json may declare `mcpServers`, either inline
5
- * or as a relative path to a .mcp.json file, e.g.:
6
- *
7
- * { "mcpServers": "./.mcp.json" }
8
- *
9
- * and .mcp.json:
10
- *
11
- * { "mcpServers": { "net_revenue_rgm": { "command": "uvx",
12
- * "args": ["net-revenue-rgm"], "env": {} } } }
13
- *
14
- * Today install COPIES .mcp.json (registers the server) but never installs the
15
- * underlying package — `uvx net-revenue-rgm` only fetches it lazily at first MCP
16
- * launch. This module provisions those deps eagerly at install time.
17
- *
18
- * Command mapping (runner derived from the server's `command`):
19
- * uvx <pkg> → uv tool install <pkg> (uvx is uv's ephemeral runner;
20
- * `uv tool install` makes it resident)
21
- * npx <pkg> → npm install -g <pkg>
22
- * pipx <pkg> → pipx install <pkg>
23
- * pip install … → pip install … (as given)
24
- * pip3 install … → pip3 install … (as given)
25
- * anything else → skipped (local path / node / python / docker — nothing to fetch)
26
- *
27
- * SECURITY
28
- * - Only a fixed allowlist of runners may ever be invoked: uv, npm, pipx, pip, pip3.
29
- * - Arguments are derived from the bundle manifest and the fixed mapping above.
30
- * - Execution always uses spawnSync(runner, [args...], { shell: false }) — never a
31
- * shell string — so nothing in the manifest can inject shell metacharacters.
32
- * - The bundle's raw `command` is NEVER executed directly; only the mapped
33
- * provisioning runner runs.
34
- *
35
- * BEST-EFFORT
36
- * - Provisioning runs BY DEFAULT. Pass { noProvision: true } to skip it entirely.
37
- * - If the provisioning runner is missing from PATH, we WARN with install
38
- * guidance and record status "missing-runner" — the install is NOT failed.
39
- * - Any exec failure (nonzero exit, spawn error) WARNS and records "failed" —
40
- * the install is NOT failed.
41
- *
42
- * Per-server result shape:
43
- * { server, command, package, status, detail }
44
- * status ∈ "provisioned" | "skipped" | "missing-runner" | "failed"
45
- */
46
-
47
- import { spawnSync } from "node:child_process";
48
- import { existsSync, readFileSync } from "node:fs";
49
- import { isAbsolute, join, resolve } from "node:path";
50
-
51
- // ---------------------------------------------------------------------------
52
- // Runner allowlist + install guidance
53
- // ---------------------------------------------------------------------------
54
-
55
- /** Fixed allowlist of provisioning runners that may be invoked. */
56
- const ALLOWED_RUNNERS = new Set(["uv", "npm", "pipx", "pip", "pip3"]);
57
-
58
- /** Install guidance per runner, surfaced when the runner is missing from PATH. */
59
- const RUNNER_GUIDANCE = {
60
- uv: "install: https://docs.astral.sh/uv/",
61
- npm: "install Node.js + npm: https://nodejs.org/",
62
- pipx: "install: https://pipx.pypa.io/stable/installation/",
63
- pip: "install Python + pip: https://pip.pypa.io/",
64
- pip3: "install Python + pip: https://pip.pypa.io/",
65
- };
66
-
67
- // ---------------------------------------------------------------------------
68
- // Package-token validation
69
- // ---------------------------------------------------------------------------
70
-
71
- /**
72
- * Return true when a package token is safe to pass to a provisioning runner.
73
- *
74
- * Rejects tokens that:
75
- * - Start with "-" (option injection: e.g. `--with=evil`, `--index-url=…`)
76
- * - Are empty or non-string
77
- *
78
- * Shell metacharacters are not a concern (shell:false is enforced at exec time),
79
- * but leading dashes are: they become CLI flags for the runner even with
80
- * shell:false (e.g. `uv tool install --with=evil` or
81
- * `pip install --index-url=https://attacker.com pkg`).
82
- *
83
- * @param {unknown} token
84
- * @returns {boolean}
85
- */
86
- function isSafePackageToken(token) {
87
- return typeof token === "string" && token.length > 0 && !token.startsWith("-");
88
- }
89
-
90
- // ---------------------------------------------------------------------------
91
- // command → { runner, args } mapping
92
- // ---------------------------------------------------------------------------
93
-
94
- /**
95
- * Map a bundle server's { command, args } to a persistent provisioning command.
96
- * Returns null when the command is unknown / there is nothing to fetch, or when
97
- * the resolved package token fails the safety check (starts with "-").
98
- *
99
- * @param {string} command
100
- * @param {string[]} args
101
- * @returns {{ runner: string, args: string[], package: string } | null}
102
- */
103
- function mapProvisionCommand(command, args) {
104
- const a = Array.isArray(args) ? args.filter((x) => typeof x === "string") : [];
105
-
106
- switch (command) {
107
- case "uvx": {
108
- // uvx <pkg> [extra…] → uv tool install <pkg>.
109
- // Only a[0] is used; skip if it starts with "-" (option injection guard).
110
- const pkg = a[0];
111
- if (!isSafePackageToken(pkg)) return null;
112
- return { runner: "uv", args: ["tool", "install", pkg], package: pkg };
113
- }
114
- case "npx": {
115
- // npx [-y] <pkg> → npm install -g <pkg>. Skip leading flags to find the pkg.
116
- const pkg = a.find((x) => !x.startsWith("-"));
117
- if (!isSafePackageToken(pkg)) return null;
118
- return { runner: "npm", args: ["install", "-g", pkg], package: pkg };
119
- }
120
- case "pipx": {
121
- // pipx <pkg> OR pipx run <pkg> → pipx install <pkg>.
122
- const rest = a[0] === "run" ? a.slice(1) : a;
123
- const pkg = rest.find((x) => !x.startsWith("-"));
124
- if (!isSafePackageToken(pkg)) return null;
125
- return { runner: "pipx", args: ["install", pkg], package: pkg };
126
- }
127
- case "pip":
128
- case "pip3": {
129
- // Only honour an explicit `install` invocation.
130
- // SECURITY: pass only the package name, never the raw args array. Passing
131
- // the full args array lets a malicious manifest inject pip flags such as
132
- // --index-url, --trusted-host, --extra-index-url, or --target, all of
133
- // which can redirect the install to an attacker-controlled source/path.
134
- if (a[0] !== "install") return null;
135
- const pkg = a.slice(1).find((x) => !x.startsWith("-")) ?? "";
136
- if (!isSafePackageToken(pkg)) return null;
137
- return { runner: command, args: ["install", pkg], package: pkg };
138
- }
139
- default:
140
- // node / python / docker / local path → nothing to fetch.
141
- return null;
142
- }
143
- }
144
-
145
- // ---------------------------------------------------------------------------
146
- // Default exec runner — a thin spawnSync wrapper (shell:false). Injectable.
147
- // ---------------------------------------------------------------------------
148
-
149
- /**
150
- * Default exec runner. NEVER uses a shell.
151
- * @param {string} runner
152
- * @param {string[]} args
153
- * @returns {{ status: number|null, stdout: string, stderr: string, error?: Error }}
154
- */
155
- function defaultExec(runner, args) {
156
- const res = spawnSync(runner, args, {
157
- shell: false,
158
- encoding: "utf8",
159
- stdio: ["ignore", "pipe", "pipe"],
160
- });
161
- return {
162
- status: res.status,
163
- stdout: res.stdout ?? "",
164
- stderr: res.stderr ?? "",
165
- error: res.error,
166
- };
167
- }
168
-
169
- /**
170
- * Default PATH check — is the runner resolvable? Uses the platform `which`/`where`.
171
- * @param {string} runner
172
- * @returns {boolean}
173
- */
174
- defaultExec.which = (runner) => {
175
- const probe = process.platform === "win32" ? "where" : "which";
176
- const res = spawnSync(probe, [runner], { shell: false, stdio: "ignore" });
177
- return res.status === 0;
178
- };
179
-
180
- // ---------------------------------------------------------------------------
181
- // readMcpServers — resolve the declared MCP servers for a bundle.
182
- // ---------------------------------------------------------------------------
183
-
184
- function readManifest(pluginRoot) {
185
- const manifestPath = join(pluginRoot, ".agents", "plugin.json");
186
- if (!existsSync(manifestPath)) return null;
187
- try {
188
- return JSON.parse(readFileSync(manifestPath, "utf8"));
189
- } catch {
190
- return null;
191
- }
192
- }
193
-
194
- /**
195
- * Read the `mcpServers` map declared by a bundle's canonical .agents/plugin.json.
196
- *
197
- * The `mcpServers` field may be:
198
- * - a relative path (e.g. "./.mcp.json") → read that file's `mcpServers` object
199
- * - an inline object → returned directly
200
- * - absent → {}
201
- *
202
- * @param {string} pluginRoot
203
- * @returns {Record<string, { command?: string, args?: string[], env?: object }>}
204
- */
205
- export function readMcpServers(pluginRoot) {
206
- const manifest = readManifest(pluginRoot);
207
- if (!manifest) return {};
208
-
209
- const field = manifest.mcpServers;
210
- if (!field) return {};
211
-
212
- // Inline object form.
213
- if (typeof field === "object") {
214
- return field;
215
- }
216
-
217
- // Path form — resolve relative to the bundle root.
218
- if (typeof field === "string") {
219
- const mcpPath = isAbsolute(field) ? field : resolve(pluginRoot, field);
220
- if (!existsSync(mcpPath)) return {};
221
- try {
222
- const parsed = JSON.parse(readFileSync(mcpPath, "utf8"));
223
- const servers = parsed && typeof parsed === "object" ? parsed.mcpServers : null;
224
- return servers && typeof servers === "object" ? servers : {};
225
- } catch {
226
- return {};
227
- }
228
- }
229
-
230
- return {};
231
- }
232
-
233
- // ---------------------------------------------------------------------------
234
- // provisionMcp — best-effort provision of every declared server's package.
235
- // ---------------------------------------------------------------------------
236
-
237
- function warn(msg) {
238
- process.stderr.write(`[enact-extensions install] ${msg}\n`);
239
- }
240
-
241
- /**
242
- * Provision the underlying packages for every MCP server a bundle declares.
243
- *
244
- * @param {string} pluginRoot
245
- * @param {object} [options]
246
- * @param {boolean} [options.noProvision] - Skip provisioning entirely.
247
- * @param {Function} [options.exec] - Injectable exec runner (defaults to a
248
- * spawnSync wrapper). Must expose .which().
249
- * @returns {Array<{ server, command, package, status, detail }>}
4
+ * The script-layer helpers remain for backwards compatibility with tests and
5
+ * the CLI wrapper, but the source of truth now lives in src/provision.ts and is
6
+ * exported from dist/index.js for in-process consumers like enact-operator.
250
7
  */
251
- export function provisionMcp(pluginRoot, options = {}) {
252
- if (options.noProvision) return [];
253
-
254
- const exec = options.exec ?? defaultExec;
255
- const which =
256
- typeof exec.which === "function" ? exec.which : (r) => ALLOWED_RUNNERS.has(r);
257
-
258
- const servers = readMcpServers(pluginRoot);
259
- const results = [];
260
-
261
- for (const [serverName, def] of Object.entries(servers)) {
262
- const command = def && typeof def.command === "string" ? def.command : "";
263
- const args = def && Array.isArray(def.args) ? def.args : [];
264
-
265
- const mapped = mapProvisionCommand(command, args);
8
+ import {
9
+ provisionMcp as provisionMcpCore,
10
+ readMcpServers as readMcpServersCore,
11
+ summarizeProvision as summarizeProvisionCore,
12
+ } from "../../dist/index.js";
266
13
 
267
- // Unknown / nothing-to-fetch → skip.
268
- if (!mapped) {
269
- results.push({
270
- server: serverName,
271
- command,
272
- package: "",
273
- status: "skipped",
274
- detail: `no provisioning mapping for command "${command || "(none)"}"`,
275
- });
276
- continue;
277
- }
278
-
279
- // Defence-in-depth: never invoke a runner outside the allowlist.
280
- if (!ALLOWED_RUNNERS.has(mapped.runner)) {
281
- results.push({
282
- server: serverName,
283
- command,
284
- package: mapped.package,
285
- status: "skipped",
286
- detail: `runner "${mapped.runner}" not on allowlist`,
287
- });
288
- continue;
289
- }
290
-
291
- // PATH check — if the runner is missing, WARN with guidance, do NOT fail.
292
- let present;
293
- try {
294
- present = which(mapped.runner);
295
- } catch {
296
- present = false;
297
- }
298
- if (!present) {
299
- const guidance = RUNNER_GUIDANCE[mapped.runner] ?? "";
300
- const detail = `${mapped.package || serverName} needs \`${command}\` (${mapped.runner}); ${guidance}`;
301
- warn(`Warning: ${detail}`);
302
- results.push({
303
- server: serverName,
304
- command,
305
- package: mapped.package,
306
- status: "missing-runner",
307
- detail,
308
- });
309
- continue;
310
- }
311
-
312
- // Provision — best effort. Any failure WARNS but never throws.
313
- try {
314
- const res = exec(mapped.runner, mapped.args) ?? {};
315
- // Success: exit status 0 and no spawn error.
316
- if (res.status === 0 && !res.error) {
317
- results.push({
318
- server: serverName,
319
- command,
320
- package: mapped.package,
321
- status: "provisioned",
322
- detail: `${mapped.runner} ${mapped.args.join(" ")}`,
323
- });
324
- continue;
325
- }
326
- // Nonzero exit or spawn error.
327
- const stderr = res.stderr ? String(res.stderr).trim() : "";
328
- const code = res.status;
329
- const errMsg = res.error ? res.error.message : "";
330
- const detail =
331
- `${mapped.runner} ${mapped.args.join(" ")} failed` +
332
- (code != null ? ` (exit ${code})` : "") +
333
- (stderr ? `: ${stderr}` : errMsg ? `: ${errMsg}` : "");
334
- warn(`Warning: ${detail}`);
335
- results.push({
336
- server: serverName,
337
- command,
338
- package: mapped.package,
339
- status: "failed",
340
- detail,
341
- });
342
- } catch (err) {
343
- const detail = `${mapped.runner} ${mapped.args.join(" ")} failed: ${
344
- err instanceof Error ? err.message : String(err)
345
- }`;
346
- warn(`Warning: ${detail}`);
347
- results.push({
348
- server: serverName,
349
- command,
350
- package: mapped.package,
351
- status: "failed",
352
- detail,
353
- });
354
- }
355
- }
356
-
357
- return results;
358
- }
359
-
360
- /**
361
- * Render a concise one-line summary per provisioned/failed server, for logging.
362
- * @param {Array<{server, package, status, detail}>} results
363
- * @returns {string[]}
364
- */
365
- export function summarizeProvision(results) {
366
- return results
367
- .filter((r) => r.status !== "skipped")
368
- .map((r) => `mcp ${r.server}: ${r.status}${r.package ? ` (${r.package})` : ""}`);
369
- }
14
+ export const readMcpServers = readMcpServersCore;
15
+ export const provisionMcp = provisionMcpCore;
16
+ export const summarizeProvision = summarizeProvisionCore;
@@ -1,5 +1,6 @@
1
- import { join } from "node:path";
1
+ import { dirname, join } from "node:path";
2
2
  import { homedir } from "node:os";
3
+ import { existsSync, readFileSync } from "node:fs";
3
4
  import {
4
5
  installPluginBundle,
5
6
  installClaudePluginBundle,
@@ -12,9 +13,11 @@ import {
12
13
  import { appendEntry } from "./ledger.mjs";
13
14
  import { bundleHash } from "./bundle-hash.mjs";
14
15
  import { provisionMcp, summarizeProvision } from "./provision-mcp.mjs";
16
+ import { registerPluginHooks, removePluginHooks } from "./hooks.mjs";
17
+ import { assertSupportedHookEvents } from "../../dist/index.js";
15
18
 
16
- // scope: "global" (default) -> the agent's default home (~/.codex, ~/.enact, ~/.claude, ~/.cursor, ~/.agents)
17
- // "local" -> a project-scoped home under the current dir (./.codex, ./.enact, ...)
19
+ // scope: "global" (default) -> the agent's default home (~/.codex, ~/.enact/agent, ~/.claude, ~/.cursor, ~/.agents)
20
+ // "local" -> a project-scoped home under the current dir (./.codex, ./.enact/agent, ...)
18
21
  // An explicit --<platform>-home always wins over the scope default.
19
22
  function resolveHomes(options) {
20
23
  const scope = options.scope ?? "global";
@@ -22,7 +25,7 @@ function resolveHomes(options) {
22
25
  return {
23
26
  scope,
24
27
  codexHome: options.codexHome ?? (scope === "local" ? local(".codex") : undefined),
25
- enactHome: options.enactHome ?? (scope === "local" ? local(".enact") : defaultEnactHome()),
28
+ enactHome: options.enactHome ?? (scope === "local" ? local(".enact/agent") : defaultEnactHome()),
26
29
  claudeHome: options.claudeHome ?? (scope === "local" ? local(".claude") : undefined),
27
30
  cursorHome: options.cursorHome ?? (scope === "local" ? local(".cursor") : undefined),
28
31
  // For shared: sharedHome is the base directory; installSharedPluginBundle appends .agents/skills/<name>
@@ -35,7 +38,7 @@ function resolveHomes(options) {
35
38
  // ---------------------------------------------------------------------------
36
39
  // ALL_PLATFORMS — deterministic order for `--platform all`
37
40
  // ---------------------------------------------------------------------------
38
- const ALL_PLATFORMS = ["codex", "claude", "cursor", "enact", "shared"];
41
+ const ALL_PLATFORMS = ["codex", "claude", "cursor", "enact"];
39
42
 
40
43
  // ---------------------------------------------------------------------------
41
44
  // PLATFORM_TARGETS — data-driven dispatch table
@@ -186,6 +189,78 @@ function computeBundleHash(pluginRoot, options) {
186
189
  }
187
190
  }
188
191
 
192
+ function readJsonIfExists(path) {
193
+ if (!existsSync(path)) return null;
194
+ try {
195
+ return JSON.parse(readFileSync(path, "utf8"));
196
+ } catch {
197
+ return null;
198
+ }
199
+ }
200
+
201
+ function readPluginHookConfig(pluginRoot) {
202
+ const manifest = readJsonIfExists(join(pluginRoot, ".agents", "plugin.json"));
203
+ const hooksPath = typeof manifest?.hooks === "string" ? manifest.hooks : null;
204
+ if (!hooksPath) return null;
205
+ const normalized = hooksPath.replace(/^\.\//, "");
206
+ return readJsonIfExists(join(pluginRoot, normalized));
207
+ }
208
+
209
+ function registerInstalledPluginHooks(pluginRoot, platform, result, homes, options) {
210
+ // codex reads hooks from the installed plugin bundle (no config.toml
211
+ // registration); cursor likewise. claude/enact register into their config.
212
+ if (!["claude", "cursor", "enact"].includes(platform)) return;
213
+ const hookConfig = readPluginHookConfig(pluginRoot);
214
+ const name = result?.name ?? result?.results?.[0]?.name;
215
+ if (!name) return;
216
+
217
+ const hookOpts = {
218
+ cwd: options.cwd ?? pluginRoot,
219
+ claudeHome: platform === "claude" ? homes.claudeHome : undefined,
220
+ cursorHome: platform === "cursor" ? homes.cursorHome : undefined,
221
+ enactHome: platform === "enact" ? homes.enactHome : undefined,
222
+ };
223
+
224
+ // Self-healing reconcile: first remove ALL of THIS plugin's prior hook
225
+ // registrations (orphaned events dropped from the bundle + duplicate entries
226
+ // accumulated by earlier appends), then register the current bundle's set.
227
+ // Removal is marker/command scoped per plugin, so the user's own hooks and
228
+ // other plugins' hooks are never touched.
229
+ removePluginHooks(platform, name, hookOpts);
230
+
231
+ if (!hookConfig) return;
232
+ const hookResult = registerPluginHooks(platform, name, hookConfig, hookOpts);
233
+ if (hookResult.result !== "skipped") {
234
+ console.log(`hooks ${hookResult.result} for ${name} on ${platform} -> ${hookResult.location}`);
235
+ }
236
+ }
237
+
238
+ function removeLegacyCodexPluginHooks(name, result, homes, options) {
239
+ if (!name) return;
240
+
241
+ if (Array.isArray(result?.results)) {
242
+ for (const install of result.results) {
243
+ const codexHome = install.configPath ? dirname(install.configPath) : homes.codexHome;
244
+ const hookResult = removePluginHooks("codex", name, {
245
+ cwd: options.cwd ?? process.cwd(),
246
+ codexHome,
247
+ });
248
+ if (hookResult.result === "removed") {
249
+ console.log(`removed legacy codex hooks for ${name} -> ${hookResult.location}`);
250
+ }
251
+ }
252
+ return;
253
+ }
254
+
255
+ const hookResult = removePluginHooks("codex", name, {
256
+ cwd: options.cwd ?? process.cwd(),
257
+ codexHome: homes.codexHome,
258
+ });
259
+ if (hookResult.result === "removed") {
260
+ console.log(`removed legacy codex hooks for ${name} -> ${hookResult.location}`);
261
+ }
262
+ }
263
+
189
264
  // Append one install entry per installed target in a single-platform result.
190
265
  // Tolerant of every result shape (single platform, shared, codex multi-home).
191
266
  // `hash` is the pre-computed bundle hash (or null) for this install invocation.
@@ -222,6 +297,7 @@ function recordInstall(platform, result, homes, options, hash) {
222
297
  home: t.home,
223
298
  path: t.path,
224
299
  hash,
300
+ marketplaceName: options.marketplaceName ?? "enact-os-plugins",
225
301
  },
226
302
  homes,
227
303
  options,
@@ -241,6 +317,7 @@ function runSinglePlatform(pluginRoot, platform, homes, options, hash) {
241
317
  const result = target.install(pluginRoot, home, options);
242
318
  target.log(result);
243
319
  recordInstall(platform, result, homes, options, hash);
320
+ registerInstalledPluginHooks(pluginRoot, platform, result, homes, options);
244
321
  return result;
245
322
  }
246
323
 
@@ -259,6 +336,7 @@ function runSinglePlatform(pluginRoot, platform, homes, options, hash) {
259
336
  }
260
337
  }
261
338
  recordInstall("codex", result, homes, options, hash);
339
+ removeLegacyCodexPluginHooks(result.results[0]?.name, result, homes, options);
262
340
  return result;
263
341
  }
264
342
 
@@ -292,6 +370,10 @@ function runProvision(pluginRoot, options) {
292
370
  }
293
371
 
294
372
  export function runInstall(pluginRoot, options = {}) {
373
+ // Refuse to install a bundle that declares hook events outside the
374
+ // cross-surface intersection (claude, codex, cursor, enact).
375
+ assertSupportedHookEvents(pluginRoot);
376
+
295
377
  const platforms = parsePlatforms(options.platform);
296
378
  const homes = resolveHomes(options);
297
379
  console.log(`install scope: ${homes.scope}${homes.scope === "local" ? ` (${process.cwd()})` : ""}`);
@@ -0,0 +1,73 @@
1
+ import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { cwd as processCwd } from "node:process";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { listInstalled } from "./ledger.mjs";
7
+ import { parsePlatforms, runUninstall } from "./run-uninstall.mjs";
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const PACKAGE_ROOT = resolve(__dirname, "..", "..");
11
+ const DEFAULT_MARKETPLACE = "enact-os-plugins";
12
+
13
+ function defaultSourceRoots(cwd) {
14
+ const roots = [join(PACKAGE_ROOT, "extensions")];
15
+ const cwdExtensions = join(cwd, "extensions");
16
+ if (resolve(cwdExtensions) !== resolve(roots[0])) roots.push(cwdExtensions);
17
+ return roots;
18
+ }
19
+
20
+ function sourceBundleExists(name, sourceRoots) {
21
+ return sourceRoots.some((root) => existsSync(join(root, name, ".agents", "plugin.json")));
22
+ }
23
+
24
+ function homeOptionForPlatform(platform) {
25
+ switch (platform) {
26
+ case "codex": return "codexHome";
27
+ case "claude": return "claudeHome";
28
+ case "cursor": return "cursorHome";
29
+ case "enact": return "enactHome";
30
+ case "shared": return "sharedHome";
31
+ default: return "codexHome";
32
+ }
33
+ }
34
+
35
+ export function runPrune(options = {}) {
36
+ const cwd = options.cwd ?? processCwd();
37
+ const ledgerHome = options.ledgerHome ?? options.home ?? homedir();
38
+ const marketplaceName = options.marketplaceName ?? DEFAULT_MARKETPLACE;
39
+ const platforms = new Set(parsePlatforms(options.platform ?? "all"));
40
+ const sourceRoots = options.sourceRoots ?? defaultSourceRoots(cwd);
41
+ const installed = listInstalled({ home: ledgerHome });
42
+
43
+ const candidates = installed
44
+ .filter((entry) => platforms.has(entry.platform))
45
+ .filter((entry) => entry.marketplaceName === marketplaceName)
46
+ .filter((entry) => !sourceBundleExists(entry.name, sourceRoots));
47
+
48
+ const pruned = [];
49
+ const failed = [];
50
+
51
+ if (options.dryRun) {
52
+ return { candidates, pruned, failed, dryRun: true };
53
+ }
54
+
55
+ for (const entry of candidates) {
56
+ const homeOption = homeOptionForPlatform(entry.platform);
57
+ try {
58
+ runUninstall(entry.name, {
59
+ platform: entry.platform,
60
+ scope: entry.scope,
61
+ [homeOption]: entry.home,
62
+ marketplaceName,
63
+ ledgerHome,
64
+ version: entry.version,
65
+ });
66
+ pruned.push(entry);
67
+ } catch (err) {
68
+ failed.push({ entry, error: err instanceof Error ? err : new Error(String(err)) });
69
+ }
70
+ }
71
+
72
+ return { candidates, pruned, failed, dryRun: false };
73
+ }
@@ -1,6 +1,10 @@
1
1
  import { readFileSync, existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { syncPlatformManifests, createEnactManifest } from "../../dist/index.js";
3
+ import {
4
+ syncPlatformManifests,
5
+ createEnactManifest,
6
+ assertSupportedHookEvents,
7
+ } from "../../dist/index.js";
4
8
 
5
9
  export function runSync(pluginRoot, { name } = {}) {
6
10
  const enactPath = join(pluginRoot, ".agents/plugin.json");
@@ -16,6 +20,10 @@ export function runSync(pluginRoot, { name } = {}) {
16
20
  enact = createEnactManifest({ name });
17
21
  }
18
22
 
23
+ // Refuse to project manifests for a bundle that declares hook events outside
24
+ // the cross-surface intersection (claude, codex, cursor, enact).
25
+ assertSupportedHookEvents(pluginRoot);
26
+
19
27
  const results = syncPlatformManifests(pluginRoot, enact);
20
28
  for (const r of results) {
21
29
  console.log(`${r.written ? "wrote" : "skip"} ${r.manifestPath}`);