ast-mcp-server 0.7.2 → 0.8.0

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/CHANGELOG.md CHANGED
@@ -4,13 +4,45 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [0.7.2] — release candidate (unreleased, 2026-08-12)
7
+ ## [Unreleased]
8
+
9
+ ## [0.8.0] — release candidate (unreleased, 2026-08-14)
10
+
11
+ ### Fixed
12
+
13
+ - OpenCode effective-config discovery and verification now run against disposable routed copies because the client normalizes both configured files even for diagnostic commands; the real planned destination remains snapshot-protected.
14
+
15
+ ### Added
16
+
17
+ - Added managed `structural-code-editing` activation guidance for the verified Claude, OpenCode, Codex, and Gemini global instruction surfaces; Hermes and Copilot remain explicitly skill-only.
18
+ - Added an exact SHA-256 release manifest for safe upgrades from registry-proven skill bytes and packaged the canonical marker-free guidance payload.
19
+
20
+ ### Changed
21
+
22
+ - Setup now preflights MCP, skill, and guidance across every selected client before mutation, preserves user-owned instruction bytes, applies snapshot-checked descriptor-bound file writes, and reports schema v2 with logical outcomes plus completed, possibly committed, rolled-back, rollback-failed, pending, and successful physical asset states.
23
+ - `install-skill` uses the same official-byte manifest but remains skill-only; explicit force never bypasses guidance ownership, route, or race checks.
24
+ - Managed file writes hold staged and preimage inodes through publication, use no-clobber descriptor links for creation and atomic exchange with exact-pair rollback for replacement, reject same-inode content or mode races before cleanup, then reauthenticate unchanged/completed postimages before later asset or MCP writes.
25
+
26
+ ### Release status
27
+
28
+ - This version is bound to exact-SHA CI and the repository's staged `next` publication, public-registry verification, `latest` promotion, annotated-tag, and hosted-release sequence. npm registry and GitHub readbacks remain authoritative for those external transitions.
29
+
30
+ ## [0.7.2] — published to `latest` and `next`, registry verified (2026-08-13)
8
31
 
9
32
  ### Fixed
10
33
 
11
34
  - Promotion preflight now validates raw npm registry metadata exactly once, compares live integrity with the verified artifact before and after mutation, and classifies the normalized readback without reinterpreting its field schema.
12
35
  - Added a composed regression covering eligible, already-promoted, and integrity-drift promotion readbacks.
13
36
 
37
+ ### Added
38
+
39
+ - Expanded convergent setup to Claude Code, Hermes, OpenCode, Codex CLI, Gemini CLI, and GitHub Copilot CLI with fail-closed versioned contracts.
40
+ - Added native checkbox selection, shared `.agents/skills` planning, routed JSONC-safe OpenCode configuration, bounded correlated diagnostics, and fixture admission gates.
41
+
42
+ ### Release status
43
+
44
+ - npm exposes the immutable published `0.7.2` bytes under both `latest` and `next`. The `0.8.0` section above describes distinct release bytes and cannot be attributed to that package version.
45
+
14
46
  ## [0.7.1] — published to `next`, verified, not promoted (2026-08-12)
15
47
 
16
48
  ### Fixed
package/README.md CHANGED
@@ -82,7 +82,7 @@ The included batch benchmark records a 50% reduction in model round-trips and a
82
82
 
83
83
  ## Supported environment and trust boundary
84
84
 
85
- The supported v0.7.2 target is Linux x64 with GNU coreutils `mv` supporting `--update=none-fail`, under Node.js 22.5.0 and the current Node.js 24 line. The production-readiness matrix covers both runtimes on that target; the Node 22.5 persistence canary requires `--experimental-sqlite`. Other Linux architectures or systems without the required GNU `mv`, macOS, and Windows are unverified until they pass equivalent filesystem, process, MCP/CLI/package, and mutation gates.
85
+ The package engine floor remains Node.js `>=22.5.0`. The v0.8.0 release matrix covers Linux x64 under exact Node.js 22.5.0 and the current Node.js 24 line, including managed setup-file publication. That mutation additionally requires GNU coreutils 9.7 `mv` supporting `--update=none-fail`, `--exchange`, `--no-copy`, and `--no-target-directory`, GNU coreutils `ln -L -T`, procfs descriptor paths at `/proc/self/fd`, and `O_DIRECTORY`/`O_NOFOLLOW`. Other Linux architectures or systems without those filesystem primitives, macOS, and Windows remain unverified until equivalent exact-tree gates pass.
86
86
 
87
87
  This is a local stdio server. It runs with the invoking user's filesystem permissions, and clients may request any `project_root` that user can access. It does not provide HTTP authentication, sandboxing, tenant isolation, or a remote-service security boundary. Remote, untrusted, and multi-tenant operation is unsupported.
88
88
 
@@ -134,24 +134,44 @@ From a source checkout, use the Yarn script; it builds first and then opens the
134
134
  yarn setup
135
135
  ```
136
136
 
137
- The wizard detects Claude Code and Hermes from `PATH`, shows their executable and version, selects every detected agent by default, and lets you deselect agents before confirmation. For each selected agent it:
137
+ The wizard supports exactly six CLI clients in this order: Claude Code, Hermes, OpenCode, Codex CLI, Gemini CLI, and GitHub Copilot CLI. Cursor, Windsurf, Cline, and other editor-integrated clients are intentionally excluded. Compatible detected clients start checked; unavailable or incompatible clients are disabled with a reason. Use Up/Down to move, Space to toggle, Enter to submit, or Escape/Ctrl-C to cancel.
138
138
 
139
- 1. preflights any existing `ast` MCP registration;
140
- 2. installs the bundled `structural-code-editing` skill;
141
- 3. registers this package's MCP server through the agent's official CLI;
142
- 4. reconnects and verifies the expected tools.
139
+ 1. preflights every selected client's existing `ast` MCP registration, skill destination, and effective managed-guidance destination;
140
+ 2. installs or safely upgrades the bundled `structural-code-editing` skill;
141
+ 3. adds one marker-owned activation block to each verified global instruction surface while preserving all user-owned bytes;
142
+ 4. registers this package's MCP server through the agent's official CLI;
143
+ 5. reconnects and verifies the expected tools.
143
144
 
144
- Existing matching registrations and skill files are unchanged. Conflicting MCP registrations fail before any write; remove or rename them explicitly instead of letting a setup script guess. Conflicting skill content also fails closed unless `--force-skill` is explicit.
145
+ Existing matching registrations, skill files, and managed blocks are unchanged. Conflicting MCP registrations or malformed/unknown managed guidance fail before any write; resolve them explicitly instead of letting a setup script guess. Skill upgrades are automatic only when the installed bytes match an exact SHA-256 admitted from a published npm tarball. Unknown or customized skill bytes fail closed unless `--force-skill` is explicit. That flag applies only to the skill and cannot override guidance conflicts, unsafe routes, or filesystem races.
146
+
147
+ Guidance uses each client's verified global instruction contract rather than one universal filename:
148
+
149
+ | Client | Managed guidance destination |
150
+ | -------- | --------------------------------------------------------------------------------------------------------------------- |
151
+ | Claude | `$CLAUDE_CONFIG_DIR/CLAUDE.md`, or `~/.claude/CLAUDE.md` |
152
+ | OpenCode | Effective native `AGENTS.md`; an existing Claude fallback may be shared or preserved when OpenCode has no native file |
153
+ | Codex | Non-empty `$CODEX_HOME/AGENTS.override.md`, otherwise `$CODEX_HOME/AGENTS.md`; `CODEX_HOME` defaults to `~/.codex` |
154
+ | Gemini | The one supported safe `context.fileName` from `~/.gemini/settings.json`, otherwise `~/.gemini/GEMINI.md` |
155
+ | Hermes | `skill_only`; setup does not modify `SOUL.md` or invent a global instruction destination |
156
+ | Copilot | `skill_only`; setup does not invent a personal global instruction destination |
157
+
158
+ The managed range is delimited by `ast-tool:structural-code-editing guidance v1` begin/end markers. Setup updates only that range, preserves the file's UTF-8 BOM, newline style, mode, and all content outside the range, and rejects duplicate, partial, reordered, unknown, symlinked, or non-regular destinations. Writes pin the parent chain, preimage, held temporary inode, and destination. New files use descriptor-bound no-clobber publication; replacements use an atomic same-directory exchange, validate both exchanged identities plus the pinned preimage bytes and mode, and roll the exact pair back when an in-call substitution or same-inode edit is detected. Every completed postimage is reauthenticated before later asset or MCP mutation. Cross-client setup is convergent rather than globally transactional.
159
+
160
+ Successful setup output uses schema `version: 2`. Each agent reports `mcp`, `skill`, and `guidance`; physical writes include an `asset` of `skill`, `guidance`, or `mcp_config`. A complete replay returns every applicable item as `unchanged`/`skill_only` and an empty `physical_writes` array. A failed managed publication separates `completed_writes`, `possibly_committed`, `rolled_back`, `rollback_failed`, and `pending`; an uncertain commit or failed rollback is never reported as untouched and requires inspection plus a fresh replan.
145
161
 
146
162
  For automation, make the target set and confirmation explicit:
147
163
 
148
164
  ```bash
149
165
  ast-tool setup --agents all --yes
150
- ast-tool setup --agents claude --yes
166
+ ast-tool setup --agents claude,codex --yes
151
167
  ```
152
168
 
153
169
  From a source checkout, replace `ast-tool` with `yarn setup` in those commands.
154
170
 
171
+ `--agents all` is resolved only after detection and means every detected compatible client. If any detected client has unknown or incompatible output, setup fails before writes. Explicit IDs are strict and reject unavailable clients. Non-interactive setup requires both `--agents` and `--yes`.
172
+
173
+ OpenCode 1.18.18 or newer is required. Because `opencode mcp add` ignores custom config routing, setup updates only `mcp.ast` in `OPENCODE_CONFIG`, then `OPENCODE_CONFIG_DIR/opencode.json`, then `~/.config/opencode/opencode.json`. JSONC comments, unrelated keys, and file mode are preserved. OpenCode's nominally diagnostic config command normalizes both routed config files, so setup runs discovery and verification against disposable copies while retaining the selected config bytes and fails closed if the planned real destination changes. Gemini setup may require trusting the current folder before registration. Diagnostics use a correlation ID and omit command arguments, environment, credentials, and raw provider output; setup failures may include a bounded destination path so the operator can inspect an uncertain or pending write.
174
+
155
175
  ## Install the agent skill
156
176
 
157
177
  The package bundles a `structural-code-editing` skill that teaches an agent when to use the AST tools, how to minimize context, and how to review mutations safely. Install it for both Claude Code and Hermes with one command:
@@ -169,10 +189,11 @@ ast-tool install-skill hermes
169
189
 
170
190
  The default is user scope. It writes to Claude Code's personal skill directory and to the active `HERMES_HOME`:
171
191
 
172
- | Target | Destination |
173
- | ----------- | ---------------------------------------------------------------------------------------------------------- |
174
- | Claude Code | `$CLAUDE_CONFIG_DIR/skills/structural-code-editing/SKILL.md`, or `~/.claude/skills/...` by default |
175
- | Hermes | `$HERMES_HOME/skills/software-development/structural-code-editing/SKILL.md`, or `~/.hermes/...` by default |
192
+ | Target | Destination |
193
+ | -------------------------------- | ---------------------------------------------------------------------------------------------------------- |
194
+ | Claude Code | `$CLAUDE_CONFIG_DIR/skills/structural-code-editing/SKILL.md`, or `~/.claude/skills/...` by default |
195
+ | Hermes | `$HERMES_HOME/skills/software-development/structural-code-editing/SKILL.md`, or `~/.hermes/...` by default |
196
+ | OpenCode, Codex, Gemini, Copilot | `~/.agents/skills/structural-code-editing/SKILL.md` (one physical write, four logical outcomes) |
176
197
 
177
198
  To commit the skill into one project for Claude Code, use project scope:
178
199
 
@@ -182,7 +203,7 @@ ast-tool install-skill claude --scope project --project-root /absolute/project
182
203
 
183
204
  This writes `.claude/skills/structural-code-editing/SKILL.md` below that project. Project scope is intentionally rejected for Hermes because Hermes skills belong to a profile, not a source repository.
184
205
 
185
- Installation is idempotent. Existing identical content is left untouched; different content fails closed unless `--force` is explicit. From an unlinked source checkout, replace `ast-tool` with `yarn node /absolute/path/to/ast-mcp-server/dist/cli.js`.
206
+ Installation is idempotent. Existing current bytes are left untouched; exact predecessor bytes admitted by the bundled npm-provenance manifest are upgraded safely. Unknown or customized bytes fail closed unless `--force` is explicit. `install-skill` never writes global guidance or configures MCP. From an unlinked source checkout, replace `ast-tool` with `yarn node /absolute/path/to/ast-mcp-server/dist/cli.js`.
186
207
 
187
208
  Claude Code detects changes in an existing skill directory live; restart it if the top-level skills directory did not exist when the session started. In Hermes, run `/reload-skills` or start a new session, then verify with `hermes skills list`.
188
209
 
@@ -421,11 +442,12 @@ yarn build
421
442
  yarn test:mcp
422
443
  yarn test:cli
423
444
  yarn test:package
445
+ yarn test:installed-agents
424
446
  yarn npm audit --all --recursive
425
447
  yarn pack --dry-run
426
448
  ```
427
449
 
428
- `test:mcp` exercises the built stdio server. `test:cli` runs a read pipeline and a prepare/apply/replay workflow across separate Node processes.
450
+ `test:mcp` exercises the built stdio server. `test:cli` runs a read pipeline and a prepare/apply/replay workflow across separate Node processes. `test:installed-agents` is a host-dependent manual gate: it builds first, detects locally installed supported clients, uses only disposable homes/config roots, verifies deterministic effective discovery without model calls, reports unavailable clients, and removes the disposable state. It is not a portable CI requirement because CI does not install every external client.
429
451
 
430
452
  ## Benchmarks
431
453
 
package/SECURITY.md CHANGED
@@ -2,16 +2,16 @@
2
2
 
3
3
  ## Supported versions
4
4
 
5
- Security fixes are provided for the latest published minor release and the current `main` development line. During the v0.7.2 release-candidate cycle, the support window is:
5
+ Security fixes are provided for the exact version currently identified by the npm registry's `latest` dist-tag and for the current `main` development line. The support window is registry-authoritative:
6
6
 
7
- | Version | Supported |
8
- | ---------------------------------- | ---------------------------------- |
9
- | `main` / v0.7.2 release candidates | Yes |
10
- | 0.6.x | Yes, until v0.7.2 becomes `latest` |
11
- | 0.7.0 / 0.7.1 | No, superseded `next` candidates |
12
- | 0.5.x and earlier | No |
7
+ | Release state | Supported |
8
+ | ------------------------------------------- | --------- |
9
+ | `main` / `Unreleased` | Yes |
10
+ | Exact npm version currently tagged `latest` | Yes |
11
+ | Earlier npm versions | No |
12
+ | Unpublished release candidates | No |
13
13
 
14
- This project supports trusted single-user local stdio use. The current v0.7.2 target supports Linux x64 only when GNU coreutils `mv` provides `--update=none-fail`, with Node.js 22.5.0 or the current Node.js 24 line. Other Linux architectures, systems without that primitive, macOS and Windows are unverified. Remote, untrusted and multi-tenant use is outside the supported security boundary.
14
+ This project supports trusted single-user local stdio use; the supported operating-system and architecture boundary remains Linux x64 only. The package engine floor is Node.js `>=22.5.0`, and the v0.8.0 release matrix covers Linux x64 under exact Node.js 22.5.0 and the current Node.js 24 line, including managed setup-file publication. That mutation requires GNU coreutils 9.7 `mv --update=none-fail --exchange --no-copy --no-target-directory`, GNU coreutils `ln -L -T`, and procfs descriptor-relative paths at `/proc/self/fd` with `O_DIRECTORY`/`O_NOFOLLOW`. Other Linux architectures and systems without those primitives are unverified, as are macOS and Windows. Remote, untrusted, and multi-tenant use is outside the supported security boundary.
15
15
 
16
16
  ## Reporting a vulnerability
17
17
 
package/dist/cli.js CHANGED
@@ -1,15 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from "node:fs/promises";
3
3
  import process from "node:process";
4
- import { createInterface } from "node:readline/promises";
5
4
  import { ZodError } from "zod";
6
5
  import { BatchExecutionError, parseBatchDocument, runBatchDocument } from "./batch/runner.js";
7
6
  import { MAX_BATCH_INPUT_BYTES, isPrepareBatchTool } from "./batch/schema.js";
8
7
  import { CliOutputError, serializeCliSuccess } from "./cli-output.js";
9
8
  import { applyPersistedOperation, persistOperationPlan } from "./services/operation-plan-file.js";
10
9
  import { AgentSetupError, detectInstalledAgents, resolveServerEntryPath, runAgentSetup, } from "./services/agent-setup.js";
11
- import { installBundledSkill, resolveBundledSkillPath, } from "./services/skill-installer.js";
12
- import { parseAgentsArgument, promptForAgentSelection, } from "./services/setup-wizard.js";
10
+ import { installBundledSkill, resolveBundledSkillAssets, } from "./services/skill-installer.js";
11
+ import { parseAgentsArgument, promptForAgentSelection, resolveAgentSelection, } from "./services/setup-wizard.js";
13
12
  class CliError extends Error {
14
13
  code;
15
14
  exitCode;
@@ -241,30 +240,33 @@ export async function runCli(args) {
241
240
  const options = parseSetupArgs(commandArgs);
242
241
  try {
243
242
  const detections = await detectInstalledAgents();
244
- let agents = options.agents;
245
- if (agents === undefined) {
243
+ let agents;
244
+ if (options.agents === undefined) {
246
245
  if (process.stdin.isTTY !== true || process.stderr.isTTY !== true) {
247
246
  throw new CliError("Interactive setup requires a TTY. Use --agents claude,hermes --yes for automation.", "USAGE", 2, command);
248
247
  }
249
- const prompt = createInterface({ input: process.stdin, output: process.stderr });
250
- try {
251
- agents = await promptForAgentSelection(detections, (question) => prompt.question(question));
252
- }
253
- finally {
254
- prompt.close();
255
- }
248
+ agents = await promptForAgentSelection(detections, {
249
+ input: process.stdin,
250
+ output: process.stderr,
251
+ signals: process,
252
+ });
256
253
  if (agents.length === 0) {
257
254
  return { status: "cancelled", command: "setup", agents: [] };
258
255
  }
259
256
  }
257
+ else
258
+ agents = resolveAgentSelection(options.agents, detections);
260
259
  const executablePath = process.argv[1];
261
260
  if (!executablePath) {
262
261
  throw new Error("Cannot resolve the ast-tool executable path.");
263
262
  }
263
+ const assets = await resolveBundledSkillAssets(executablePath);
264
264
  return await runAgentSetup({
265
265
  agents,
266
266
  detections,
267
- sourceSkillPath: await resolveBundledSkillPath(executablePath),
267
+ sourceSkillPath: assets.skillPath,
268
+ sourceGuidancePath: assets.guidancePath,
269
+ releaseManifestPath: assets.releasesPath,
268
270
  serverEntryPath: await resolveServerEntryPath(executablePath),
269
271
  forceSkill: options.forceSkill,
270
272
  });
@@ -286,9 +288,11 @@ export async function runCli(args) {
286
288
  if (!executablePath) {
287
289
  throw new Error("Cannot resolve the ast-tool executable path.");
288
290
  }
291
+ const assets = await resolveBundledSkillAssets(executablePath);
289
292
  return await installBundledSkill({
290
293
  ...options,
291
- sourceSkillPath: await resolveBundledSkillPath(executablePath),
294
+ sourceSkillPath: assets.skillPath,
295
+ releaseManifestPath: assets.releasesPath,
292
296
  });
293
297
  }
294
298
  catch (error) {
@@ -1,10 +1,15 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { constants as fsConstants } from "node:fs";
3
3
  import { access, realpath, stat } from "node:fs/promises";
4
+ import os from "node:os";
4
5
  import path from "node:path";
5
6
  import process from "node:process";
6
- import { installBundledSkill, SkillConflictError, } from "./skill-installer.js";
7
- import { AGENT_IDS, getAgentTarget, MCP_SERVER_NAME, } from "./agent-targets.js";
7
+ import { randomUUID } from "node:crypto";
8
+ import { planBundledSkillInstallation, SkillConflictError, } from "./skill-installer.js";
9
+ import { planManagedGuidance, } from "./managed-guidance.js";
10
+ import { applyManagedFilePlan, createManagedFileApplyContext, ManagedFileApplyError, ManagedFileRollbackError, revalidateManagedFilePlan, verifyManagedFilePostimage, } from "./managed-file.js";
11
+ import { AGENT_IDS, classifyAgentVersion, getAgentTarget, MCP_SERVER_NAME, } from "./agent-targets.js";
12
+ import { applyOpenCodeConfigPlan, planOpenCodeConfig, resolveOpenCodeConfigPath, withIsolatedOpenCodeConfig, } from "./opencode-config.js";
8
13
  const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
9
14
  const MAX_COMMAND_OUTPUT_BYTES = 1024 * 1024;
10
15
  export class AgentSetupError extends Error {
@@ -17,6 +22,87 @@ export class AgentSetupError extends Error {
17
22
  this.details = details;
18
23
  }
19
24
  }
25
+ export async function applyManagedAssetPlans(skillPlan, guidancePlan, context = createManagedFileApplyContext(), hooks = {}) {
26
+ const physicalWrites = [];
27
+ const possiblyCommitted = [];
28
+ const rolledBack = [];
29
+ const rollbackFailed = [];
30
+ const authenticated = [];
31
+ let activePlan;
32
+ try {
33
+ for (const file of [...skillPlan.files, ...guidancePlan.files]) {
34
+ await revalidateManagedFilePlan(file);
35
+ }
36
+ const plans = [
37
+ ...skillPlan.files.map((file) => ({ file, asset: "skill" })),
38
+ ...guidancePlan.files.map((file) => ({ file, asset: "guidance" })),
39
+ ];
40
+ for (const { file, asset } of plans) {
41
+ activePlan = { file, asset };
42
+ for (const current of authenticated) {
43
+ await verifyManagedFilePostimage(current, context);
44
+ }
45
+ await applyManagedFilePlan(file, context, hooks);
46
+ authenticated.push(file);
47
+ if (file.status !== "unchanged") {
48
+ physicalWrites.push({ path: file.path, asset, status: file.status });
49
+ }
50
+ activePlan = undefined;
51
+ }
52
+ for (const current of authenticated) {
53
+ await verifyManagedFilePostimage(current, context);
54
+ }
55
+ return physicalWrites;
56
+ }
57
+ catch (error) {
58
+ if (activePlan !== undefined && activePlan.file.status !== "unchanged") {
59
+ const write = {
60
+ path: activePlan.file.path,
61
+ asset: activePlan.asset,
62
+ status: activePlan.file.status,
63
+ };
64
+ if (error instanceof ManagedFileRollbackError) {
65
+ rolledBack.push(write);
66
+ }
67
+ else if (error instanceof ManagedFileApplyError) {
68
+ if (error.commitState === "committed") {
69
+ physicalWrites.push(write);
70
+ }
71
+ else {
72
+ possiblyCommitted.push(write);
73
+ }
74
+ if (error.rollbackState === "failed") {
75
+ rollbackFailed.push(write);
76
+ }
77
+ }
78
+ }
79
+ throw new AgentSetupError(error instanceof Error ? error.message : "Managed asset apply failed.", "AGENT_ASSET_APPLY_FAILED", {
80
+ completed_writes: physicalWrites,
81
+ possibly_committed: possiblyCommitted,
82
+ rolled_back: rolledBack,
83
+ rollback_failed: rollbackFailed,
84
+ pending: [
85
+ ...skillPlan.files
86
+ .filter((file) => file.status !== "unchanged")
87
+ .map((file) => ({ path: file.path, asset: "skill", status: file.status })),
88
+ ...guidancePlan.files
89
+ .filter((file) => file.status !== "unchanged")
90
+ .map((file) => ({ path: file.path, asset: "guidance", status: file.status })),
91
+ ].filter((pending) => !physicalWrites.some((completed) => completed.path === pending.path && completed.asset === pending.asset) &&
92
+ !possiblyCommitted.some((uncertain) => uncertain.path === pending.path && uncertain.asset === pending.asset)),
93
+ });
94
+ }
95
+ }
96
+ async function verifyManagedAssetPlans(skillPlan, guidancePlan, context) {
97
+ try {
98
+ for (const file of [...skillPlan.files, ...guidancePlan.files]) {
99
+ await verifyManagedFilePostimage(file, context);
100
+ }
101
+ }
102
+ catch (error) {
103
+ throw new AgentSetupError(error instanceof Error ? error.message : "Managed asset postimage verification failed.", "AGENT_ASSET_APPLY_FAILED", { cause: error instanceof Error ? error.message : String(error) });
104
+ }
105
+ }
20
106
  function environmentPath(environment) {
21
107
  return environment.PATH ?? environment.Path ?? environment.path ?? "";
22
108
  }
@@ -76,7 +162,7 @@ async function runCommand(executable, args, options) {
76
162
  const appendOutput = (target, chunk) => {
77
163
  outputBytes += chunk.byteLength;
78
164
  if (outputBytes > MAX_COMMAND_OUTPUT_BYTES) {
79
- finishWithError(new AgentSetupError(`Agent command exceeded ${MAX_COMMAND_OUTPUT_BYTES} output bytes.`, "AGENT_COMMAND_OUTPUT_LIMIT", { executable, args }));
165
+ finishWithError(new AgentSetupError(`Agent command exceeded ${MAX_COMMAND_OUTPUT_BYTES} output bytes.`, "AGENT_COMMAND_OUTPUT_LIMIT"));
80
166
  return;
81
167
  }
82
168
  if (target === "stdout") {
@@ -87,15 +173,12 @@ async function runCommand(executable, args, options) {
87
173
  }
88
174
  };
89
175
  const timeout = setTimeout(() => {
90
- finishWithError(new AgentSetupError(`Agent command timed out after ${options.timeoutMs}ms.`, "AGENT_COMMAND_TIMEOUT", { executable, args }));
176
+ finishWithError(new AgentSetupError(`Agent command timed out after ${options.timeoutMs}ms.`, "AGENT_COMMAND_TIMEOUT"));
91
177
  }, options.timeoutMs);
92
178
  child.stdout.on("data", (chunk) => appendOutput("stdout", chunk));
93
179
  child.stderr.on("data", (chunk) => appendOutput("stderr", chunk));
94
- child.on("error", (error) => {
95
- finishWithError(new AgentSetupError(`Failed to start ${executable}: ${error.message}`, "AGENT_COMMAND_START", {
96
- executable,
97
- args,
98
- }));
180
+ child.on("error", () => {
181
+ finishWithError(new AgentSetupError("Failed to start the agent command.", "AGENT_COMMAND_START"));
99
182
  });
100
183
  child.on("close", (exitCode) => {
101
184
  if (settled) {
@@ -109,8 +192,7 @@ async function runCommand(executable, args, options) {
109
192
  });
110
193
  }
111
194
  function commandFailure(agent, operation, result) {
112
- const output = `${result.stdout}\n${result.stderr}`.trim().slice(0, 4000);
113
- return new AgentSetupError(`${getAgentTarget(agent).label} ${operation} failed with exit code ${result.exitCode}.`, "AGENT_COMMAND_FAILED", { agent, operation, output });
195
+ return new AgentSetupError(`${getAgentTarget(agent).label} ${operation} failed with exit code ${result.exitCode}.`, "AGENT_COMMAND_FAILED", { agent, operation, reason: `Command exited with code ${result.exitCode}.` });
114
196
  }
115
197
  function targetRuntime(detection, environment, timeoutMs) {
116
198
  return {
@@ -136,6 +218,33 @@ async function inspectMcp(agent, detection, serverEntryPath, nodeExecutable, env
136
218
  ...(inspection.detail ? { detail: inspection.detail } : {}),
137
219
  };
138
220
  }
221
+ async function verifyOpenCode(detection, environment, timeoutMs, context) {
222
+ await withIsolatedOpenCodeConfig(environment, environment.HOME ?? os.homedir(), async (isolatedEnvironment) => {
223
+ const runtime = targetRuntime(detection, isolatedEnvironment, timeoutMs);
224
+ const resolved = await runtime.run(["debug", "config", "--pure"]);
225
+ if (resolved.exitCode !== 0)
226
+ throw commandFailure("opencode", "resolved configuration verification", resolved);
227
+ let config;
228
+ try {
229
+ config = JSON.parse(resolved.stdout);
230
+ }
231
+ catch {
232
+ throw new AgentSetupError("OpenCode resolved configuration output is unknown.", "AGENT_VERIFICATION_FAILED", { agent: "opencode" });
233
+ }
234
+ const command = config?.mcp?.ast?.command;
235
+ if (!Array.isArray(command) ||
236
+ command.length !== 2 ||
237
+ command[0] !== context.nodeExecutable ||
238
+ command[1] !== context.serverEntryPath) {
239
+ throw new AgentSetupError("OpenCode resolved configuration does not contain the expected ast server.", "AGENT_VERIFICATION_FAILED", { agent: "opencode" });
240
+ }
241
+ const listed = await runtime.run(["mcp", "list", "--pure"]);
242
+ const plainList = listed.stdout.replaceAll("\u001b", "");
243
+ if (listed.exitCode !== 0 || !/ast[\s\S]*connected/i.test(plainList)) {
244
+ throw new AgentSetupError("OpenCode did not report the ast server as connected.", "AGENT_VERIFICATION_FAILED", { agent: "opencode" });
245
+ }
246
+ });
247
+ }
139
248
  async function configureMcp(agent, detection, serverEntryPath, nodeExecutable, environment, timeoutMs) {
140
249
  const target = getAgentTarget(agent);
141
250
  const added = await target.mcp.register(targetRuntime(detection, environment, timeoutMs), {
@@ -175,7 +284,12 @@ export async function detectInstalledAgents(options = {}) {
175
284
  const definition = getAgentTarget(id);
176
285
  const executable = await findExecutable(definition.command, environment);
177
286
  if (executable === undefined) {
178
- return { id, label: definition.label, installed: false };
287
+ return {
288
+ id,
289
+ label: definition.label,
290
+ installed: false,
291
+ compatibility: { status: "unavailable", reason: "Not found in PATH." },
292
+ };
179
293
  }
180
294
  let version;
181
295
  try {
@@ -191,10 +305,17 @@ export async function detectInstalledAgents(options = {}) {
191
305
  catch {
192
306
  version = undefined;
193
307
  }
194
- return { id, label: definition.label, installed: true, executable, version };
308
+ const compatibility = version === undefined
309
+ ? {
310
+ status: "incompatible",
311
+ reason: "Version command did not produce admitted evidence.",
312
+ }
313
+ : classifyAgentVersion(id, version);
314
+ return { id, label: definition.label, installed: true, executable, version, compatibility };
195
315
  }));
196
316
  }
197
317
  export async function runAgentSetup(options) {
318
+ const correlationId = randomUUID();
198
319
  const environment = options.environment ?? process.env;
199
320
  const nodeExecutable = options.nodeExecutable ?? process.execPath;
200
321
  const timeoutMs = options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
@@ -208,42 +329,137 @@ export async function runAgentSetup(options) {
208
329
  if (!detection?.installed || detection.executable === undefined) {
209
330
  throw new AgentSetupError(`${getAgentTarget(agent).label} is not installed or is not available in PATH.`, "AGENT_NOT_INSTALLED", { agent });
210
331
  }
332
+ if (detection.compatibility && detection.compatibility.status !== "compatible") {
333
+ throw new AgentSetupError(`${getAgentTarget(agent).label} is incompatible: ${detection.compatibility.reason}`, detection.compatibility.status === "blocked_untrusted_folder"
334
+ ? "AGENT_TRUST_REQUIRED"
335
+ : "AGENT_INCOMPATIBLE", { agent, correlation_id: correlationId });
336
+ }
211
337
  }
212
338
  await assertRegularFile(options.serverEntryPath, "MCP server entrypoint");
213
339
  await assertRegularFile(options.sourceSkillPath, "Bundled skill");
214
- const inspections = await Promise.all(requested.map((agent) => inspectMcp(agent, detectionMap.get(agent), options.serverEntryPath, nodeExecutable, environment, timeoutMs)));
340
+ let openCodePlan;
341
+ if (requested.includes("opencode")) {
342
+ openCodePlan = await planOpenCodeConfig({
343
+ filePath: resolveOpenCodeConfigPath(environment, environment.HOME ?? os.homedir()),
344
+ nodeExecutable,
345
+ serverEntryPath: options.serverEntryPath,
346
+ }).catch((error) => {
347
+ throw new AgentSetupError(error instanceof Error ? error.message : "OpenCode configuration preflight failed.", "AGENT_MCP_CONFLICT", { agent: "opencode", correlation_id: correlationId });
348
+ });
349
+ const effective = await withIsolatedOpenCodeConfig(environment, environment.HOME ?? os.homedir(), (isolatedEnvironment) => targetRuntime(detectionMap.get("opencode"), isolatedEnvironment, timeoutMs).run([
350
+ "debug",
351
+ "config",
352
+ "--pure",
353
+ ]));
354
+ if (effective.exitCode !== 0) {
355
+ throw new AgentSetupError(`OpenCode effective configuration preflight failed with exit code ${effective.exitCode}.`, "AGENT_MCP_CONFLICT", { agent: "opencode", correlation_id: correlationId });
356
+ }
357
+ let effectiveConfig;
358
+ try {
359
+ effectiveConfig = JSON.parse(effective.stdout);
360
+ }
361
+ catch {
362
+ throw new AgentSetupError("OpenCode effective configuration output is unknown.", "AGENT_MCP_CONFLICT", { agent: "opencode", correlation_id: correlationId });
363
+ }
364
+ const effectiveAst = effectiveConfig?.mcp?.ast;
365
+ const desiredAst = {
366
+ type: "local",
367
+ command: [nodeExecutable, options.serverEntryPath],
368
+ enabled: true,
369
+ };
370
+ if (effectiveAst !== undefined && JSON.stringify(effectiveAst) !== JSON.stringify(desiredAst)) {
371
+ throw new AgentSetupError("OpenCode effective configuration conflict at mcp.ast.", "AGENT_MCP_CONFLICT", { agent: "opencode", correlation_id: correlationId });
372
+ }
373
+ if (effectiveAst !== undefined && openCodePlan.status === "installed") {
374
+ openCodePlan = { ...openCodePlan, status: "unchanged" };
375
+ }
376
+ }
377
+ const inspections = await Promise.all(requested.map((agent) => agent === "opencode"
378
+ ? Promise.resolve({
379
+ agent,
380
+ status: openCodePlan?.status === "unchanged" ? "current" : "missing",
381
+ })
382
+ : inspectMcp(agent, detectionMap.get(agent), options.serverEntryPath, nodeExecutable, environment, timeoutMs)));
215
383
  const conflict = inspections.find((item) => item.status === "conflict");
216
384
  if (conflict !== undefined) {
217
385
  throw new AgentSetupError(`Conflicting ${getAgentTarget(conflict.agent).label} MCP registration: ${conflict.detail}`, "AGENT_MCP_CONFLICT", { agent: conflict.agent });
218
386
  }
219
- const skillTarget = requested.length === AGENT_IDS.length ? "all" : getAgentTarget(requested[0]).skillTarget;
220
- const skillResult = await installBundledSkill({
221
- target: skillTarget,
222
- scope: "user",
223
- force: options.forceSkill ?? false,
224
- sourceSkillPath: options.sourceSkillPath,
225
- environment,
226
- }).catch((error) => {
387
+ const blocked = inspections.find((item) => item.status === "blocked_untrusted_folder");
388
+ if (blocked !== undefined) {
389
+ throw new AgentSetupError(`${getAgentTarget(blocked.agent).label} requires the working folder to be trusted before setup.`, "AGENT_TRUST_REQUIRED", { agent: blocked.agent, correlation_id: correlationId });
390
+ }
391
+ let skillPlan;
392
+ try {
393
+ skillPlan = await planBundledSkillInstallation({
394
+ target: requested,
395
+ scope: "user",
396
+ force: options.forceSkill ?? false,
397
+ sourceSkillPath: options.sourceSkillPath,
398
+ releaseManifestPath: options.releaseManifestPath,
399
+ environment,
400
+ homeDirectory: environment.HOME ?? os.homedir(),
401
+ });
402
+ }
403
+ catch (error) {
227
404
  if (error instanceof SkillConflictError) {
228
405
  throw new AgentSetupError(`Skill already exists with different content at ${error.destination}; pass --force-skill to replace it.`, "AGENT_SKILL_CONFLICT", { path: error.destination });
229
406
  }
230
- throw error;
231
- });
232
- const skillStatuses = new Map(skillResult.installations.map((installation) => [installation.target, installation.status]));
407
+ throw new AgentSetupError(error instanceof Error ? error.message : "Skill preflight failed.", "SETUP_ASSET_INVALID", { asset: "skill", correlation_id: correlationId });
408
+ }
409
+ let guidancePlan;
410
+ try {
411
+ guidancePlan = await planManagedGuidance({
412
+ agents: requested,
413
+ sourceGuidancePath: options.sourceGuidancePath,
414
+ environment,
415
+ homeDirectory: environment.HOME ?? os.homedir(),
416
+ });
417
+ }
418
+ catch (error) {
419
+ throw new AgentSetupError(error instanceof Error ? error.message : "Managed guidance preflight failed.", "AGENT_GUIDANCE_CONFLICT", { asset: "guidance", correlation_id: correlationId });
420
+ }
421
+ const skillStatuses = new Map(skillPlan.installations.map((installation) => [installation.target, installation.status]));
422
+ const guidanceStatuses = new Map(guidancePlan.installations.map((installation) => [installation.agent, installation.status]));
423
+ const applyContext = createManagedFileApplyContext();
424
+ const physicalWrites = await applyManagedAssetPlans(skillPlan, guidancePlan, applyContext);
233
425
  const completed = [];
234
426
  for (const agent of requested) {
235
427
  const detection = detectionMap.get(agent);
236
428
  const inspection = inspections.find((item) => item.agent === agent);
237
429
  try {
430
+ await verifyManagedAssetPlans(skillPlan, guidancePlan, applyContext);
238
431
  if (inspection.status === "missing") {
239
- await configureMcp(agent, detection, options.serverEntryPath, nodeExecutable, environment, timeoutMs);
432
+ if (agent === "opencode") {
433
+ await applyOpenCodeConfigPlan(openCodePlan);
434
+ if (openCodePlan.status === "installed") {
435
+ physicalWrites.push({
436
+ path: openCodePlan.filePath,
437
+ asset: "mcp_config",
438
+ status: "installed",
439
+ });
440
+ }
441
+ await verifyOpenCode(detection, environment, timeoutMs, {
442
+ nodeExecutable,
443
+ serverEntryPath: options.serverEntryPath,
444
+ });
445
+ }
446
+ else
447
+ await configureMcp(agent, detection, options.serverEntryPath, nodeExecutable, environment, timeoutMs);
448
+ }
449
+ else if (agent === "opencode") {
450
+ await verifyOpenCode(detection, environment, timeoutMs, {
451
+ nodeExecutable,
452
+ serverEntryPath: options.serverEntryPath,
453
+ });
240
454
  }
455
+ await verifyManagedAssetPlans(skillPlan, guidancePlan, applyContext);
241
456
  completed.push({
242
457
  agent,
243
458
  executable: detection.executable,
244
459
  version: detection.version,
245
460
  mcp: inspection.status === "current" ? "unchanged" : "configured",
246
461
  skill: skillStatuses.get(agent),
462
+ guidance: guidanceStatuses.get(agent),
247
463
  });
248
464
  }
249
465
  catch (error) {
@@ -251,16 +467,21 @@ export async function runAgentSetup(options) {
251
467
  throw new AgentSetupError(error.message, error.code, {
252
468
  cause: error.details,
253
469
  completed,
254
- skills: skillResult.installations,
470
+ skills: skillPlan.installations,
471
+ guidance: guidancePlan.installations,
472
+ completed_writes: physicalWrites,
255
473
  });
256
474
  }
257
475
  throw error;
258
476
  }
259
477
  }
260
478
  return {
479
+ version: 2,
261
480
  status: "ok",
262
481
  command: "setup",
263
482
  server: { name: MCP_SERVER_NAME, command: nodeExecutable, args: [options.serverEntryPath] },
264
483
  agents: completed,
484
+ correlation_id: correlationId,
485
+ physical_writes: physicalWrites,
265
486
  };
266
487
  }