ast-mcp-server 0.7.1 → 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.
@@ -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
  }