ast-mcp-server 0.3.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/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { createServer } from "./server.js";
4
+ async function main() {
5
+ const server = createServer();
6
+ const transport = new StdioServerTransport();
7
+ await server.connect(transport);
8
+ console.error("ast-mcp-server running over stdio.");
9
+ }
10
+ main().catch((error) => {
11
+ console.error("Fatal ast-mcp-server error:", error);
12
+ process.exit(1);
13
+ });
package/dist/server.js ADDED
@@ -0,0 +1,28 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { registerApplyOperation } from "./tools/apply_operation.js";
3
+ import { registerFindReferences } from "./tools/find_references.js";
4
+ import { registerGetDiagnostics } from "./tools/get_diagnostics.js";
5
+ import { registerGetOperationPreview } from "./tools/get_operation_preview.js";
6
+ import { registerGetOutline } from "./tools/get_outline.js";
7
+ import { registerGetSymbolSource } from "./tools/get_symbol_source.js";
8
+ import { registerListFiles } from "./tools/list_files.js";
9
+ import { registerRenameSymbol } from "./tools/rename_symbol.js";
10
+ import { registerReplaceSymbolBody } from "./tools/replace_symbol_body.js";
11
+ import { registerSearchSymbols } from "./tools/search_symbols.js";
12
+ export function createServer() {
13
+ const server = new McpServer({
14
+ name: "ast-mcp-server",
15
+ version: "0.3.0",
16
+ });
17
+ registerListFiles(server);
18
+ registerGetOutline(server);
19
+ registerGetSymbolSource(server);
20
+ registerSearchSymbols(server);
21
+ registerFindReferences(server);
22
+ registerGetDiagnostics(server);
23
+ registerRenameSymbol(server);
24
+ registerReplaceSymbolBody(server);
25
+ registerGetOperationPreview(server);
26
+ registerApplyOperation(server);
27
+ return server;
28
+ }
@@ -0,0 +1,346 @@
1
+ import { spawn } from "node:child_process";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { access, realpath, stat } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import { installBundledSkill, SkillConflictError, } from "./skill-installer.js";
7
+ import { AGENT_IDS } from "./setup-wizard.js";
8
+ const MCP_SERVER_NAME = "ast";
9
+ const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
10
+ const MAX_COMMAND_OUTPUT_BYTES = 1024 * 1024;
11
+ const EXPECTED_HERMES_TOOLS = [
12
+ "ast_list_files",
13
+ "ast_get_outline",
14
+ "ast_get_symbol_source",
15
+ "ast_search_symbols",
16
+ "ast_find_references",
17
+ "ast_get_diagnostics",
18
+ "ast_rename_symbol",
19
+ "ast_replace_symbol_body",
20
+ "ast_get_operation_preview",
21
+ "ast_apply_operation",
22
+ ];
23
+ const AGENT_DEFINITIONS = {
24
+ claude: { label: "Claude Code", command: "claude" },
25
+ hermes: { label: "Hermes", command: "hermes" },
26
+ };
27
+ export class AgentSetupError extends Error {
28
+ code;
29
+ details;
30
+ constructor(message, code, details) {
31
+ super(message);
32
+ this.name = "AgentSetupError";
33
+ this.code = code;
34
+ this.details = details;
35
+ }
36
+ }
37
+ function environmentPath(environment) {
38
+ return environment.PATH ?? environment.Path ?? environment.path ?? "";
39
+ }
40
+ async function isExecutableFile(candidate) {
41
+ try {
42
+ const candidateStat = await stat(candidate);
43
+ if (!candidateStat.isFile()) {
44
+ return false;
45
+ }
46
+ if (process.platform !== "win32") {
47
+ await access(candidate, fsConstants.X_OK);
48
+ }
49
+ return true;
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ async function findExecutable(command, environment) {
56
+ const extensions = process.platform === "win32"
57
+ ? (environment.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)
58
+ : [""];
59
+ for (const directory of environmentPath(environment).split(path.delimiter)) {
60
+ if (directory === "") {
61
+ continue;
62
+ }
63
+ for (const extension of extensions) {
64
+ const candidate = path.resolve(directory, `${command}${extension}`);
65
+ if (await isExecutableFile(candidate)) {
66
+ return realpath(candidate);
67
+ }
68
+ }
69
+ }
70
+ return undefined;
71
+ }
72
+ async function runCommand(executable, args, options) {
73
+ return new Promise((resolve, reject) => {
74
+ const child = spawn(executable, args, {
75
+ env: options.environment,
76
+ shell: false,
77
+ stdio: ["pipe", "pipe", "pipe"],
78
+ windowsHide: true,
79
+ });
80
+ let stdout = "";
81
+ let stderr = "";
82
+ let outputBytes = 0;
83
+ let settled = false;
84
+ const finishWithError = (error) => {
85
+ if (settled) {
86
+ return;
87
+ }
88
+ settled = true;
89
+ clearTimeout(timeout);
90
+ child.kill("SIGKILL");
91
+ reject(error);
92
+ };
93
+ const appendOutput = (target, chunk) => {
94
+ outputBytes += chunk.byteLength;
95
+ if (outputBytes > MAX_COMMAND_OUTPUT_BYTES) {
96
+ finishWithError(new AgentSetupError(`Agent command exceeded ${MAX_COMMAND_OUTPUT_BYTES} output bytes.`, "AGENT_COMMAND_OUTPUT_LIMIT", { executable, args }));
97
+ return;
98
+ }
99
+ if (target === "stdout") {
100
+ stdout += chunk.toString("utf8");
101
+ }
102
+ else {
103
+ stderr += chunk.toString("utf8");
104
+ }
105
+ };
106
+ const timeout = setTimeout(() => {
107
+ finishWithError(new AgentSetupError(`Agent command timed out after ${options.timeoutMs}ms.`, "AGENT_COMMAND_TIMEOUT", { executable, args }));
108
+ }, options.timeoutMs);
109
+ child.stdout.on("data", (chunk) => appendOutput("stdout", chunk));
110
+ child.stderr.on("data", (chunk) => appendOutput("stderr", chunk));
111
+ child.on("error", (error) => {
112
+ finishWithError(new AgentSetupError(`Failed to start ${executable}: ${error.message}`, "AGENT_COMMAND_START", {
113
+ executable,
114
+ args,
115
+ }));
116
+ });
117
+ child.on("close", (exitCode) => {
118
+ if (settled) {
119
+ return;
120
+ }
121
+ settled = true;
122
+ clearTimeout(timeout);
123
+ resolve({ exitCode: exitCode ?? 1, stdout, stderr });
124
+ });
125
+ child.stdin.end(options.input);
126
+ });
127
+ }
128
+ function commandFailure(agent, operation, result) {
129
+ const output = `${result.stdout}\n${result.stderr}`.trim().slice(0, 4000);
130
+ return new AgentSetupError(`${AGENT_DEFINITIONS[agent].label} ${operation} failed with exit code ${result.exitCode}.`, "AGENT_COMMAND_FAILED", { agent, operation, output });
131
+ }
132
+ function isMissingClaudeMcp(output) {
133
+ return (/\bno\s+mcp\s+server\b/i.test(output) &&
134
+ /(?:\bfound\b|\bnamed?\b)/i.test(output) &&
135
+ /\bast\b/i.test(output));
136
+ }
137
+ async function inspectClaudeMcp(detection, serverEntryPath, nodeExecutable, environment, timeoutMs) {
138
+ const result = await runCommand(detection.executable, ["mcp", "get", MCP_SERVER_NAME], {
139
+ environment,
140
+ timeoutMs,
141
+ });
142
+ const output = `${result.stdout}\n${result.stderr}`;
143
+ if (result.exitCode !== 0) {
144
+ if (isMissingClaudeMcp(output)) {
145
+ return { agent: "claude", status: "missing" };
146
+ }
147
+ throw commandFailure("claude", "MCP inspection", result);
148
+ }
149
+ const fields = new Map();
150
+ for (const line of result.stdout.split(/\r?\n/)) {
151
+ const match = line.match(/^\s*(Status|Type|Command|Args):\s*(.*)$/i);
152
+ if (match !== null) {
153
+ fields.set(match[1].toLowerCase(), match[2].trim());
154
+ }
155
+ }
156
+ const current = /connected/i.test(fields.get("status") ?? "") &&
157
+ fields.get("type")?.toLowerCase() === "stdio" &&
158
+ fields.get("command") === nodeExecutable &&
159
+ fields.get("args") === serverEntryPath;
160
+ return current
161
+ ? { agent: "claude", status: "current" }
162
+ : {
163
+ agent: "claude",
164
+ status: "conflict",
165
+ detail: "The existing user-scoped 'ast' registration does not match this package.",
166
+ };
167
+ }
168
+ async function inspectHermesMcp(detection, environment, timeoutMs) {
169
+ const listed = await runCommand(detection.executable, ["mcp", "list"], {
170
+ environment,
171
+ timeoutMs,
172
+ });
173
+ if (listed.exitCode !== 0) {
174
+ throw commandFailure("hermes", "MCP list", listed);
175
+ }
176
+ if (!/^\s*ast(?:\s|$)/im.test(listed.stdout)) {
177
+ return { agent: "hermes", status: "missing" };
178
+ }
179
+ const tested = await runCommand(detection.executable, ["mcp", "test", MCP_SERVER_NAME], {
180
+ environment,
181
+ timeoutMs,
182
+ });
183
+ const current = tested.exitCode === 0 && EXPECTED_HERMES_TOOLS.every((tool) => tested.stdout.includes(tool));
184
+ return current
185
+ ? { agent: "hermes", status: "current" }
186
+ : {
187
+ agent: "hermes",
188
+ status: "conflict",
189
+ detail: "The existing 'ast' registration does not expose the expected structural tools.",
190
+ };
191
+ }
192
+ async function inspectMcp(agent, detection, serverEntryPath, nodeExecutable, environment, timeoutMs) {
193
+ return agent === "claude"
194
+ ? inspectClaudeMcp(detection, serverEntryPath, nodeExecutable, environment, timeoutMs)
195
+ : inspectHermesMcp(detection, environment, timeoutMs);
196
+ }
197
+ async function configureClaudeMcp(detection, serverEntryPath, nodeExecutable, environment, timeoutMs) {
198
+ const added = await runCommand(detection.executable, [
199
+ "mcp",
200
+ "add",
201
+ "--scope",
202
+ "user",
203
+ "--transport",
204
+ "stdio",
205
+ MCP_SERVER_NAME,
206
+ "--",
207
+ nodeExecutable,
208
+ serverEntryPath,
209
+ ], { environment, timeoutMs });
210
+ if (added.exitCode !== 0) {
211
+ throw commandFailure("claude", "MCP registration", added);
212
+ }
213
+ const verified = await inspectClaudeMcp(detection, serverEntryPath, nodeExecutable, environment, timeoutMs);
214
+ if (verified.status !== "current") {
215
+ throw new AgentSetupError("Claude Code saved the MCP registration but verification did not match.", "AGENT_VERIFICATION_FAILED", { agent: "claude", inspection: verified });
216
+ }
217
+ }
218
+ async function configureHermesMcp(detection, serverEntryPath, nodeExecutable, environment, timeoutMs) {
219
+ const added = await runCommand(detection.executable, ["mcp", "add", MCP_SERVER_NAME, "--command", nodeExecutable, "--args", serverEntryPath], { environment, timeoutMs, input: "\n" });
220
+ if (added.exitCode !== 0 || !/saved\s+'ast'/i.test(added.stdout)) {
221
+ throw commandFailure("hermes", "MCP registration", added);
222
+ }
223
+ const verified = await inspectHermesMcp(detection, environment, timeoutMs);
224
+ if (verified.status !== "current") {
225
+ throw new AgentSetupError("Hermes saved the MCP registration but verification did not expose the expected tools.", "AGENT_VERIFICATION_FAILED", { agent: "hermes", inspection: verified });
226
+ }
227
+ }
228
+ async function assertRegularFile(filePath, label) {
229
+ let fileStat;
230
+ try {
231
+ fileStat = await stat(filePath);
232
+ }
233
+ catch (error) {
234
+ throw new AgentSetupError(`${label} does not exist: ${filePath}`, "SETUP_ARTIFACT_MISSING", {
235
+ cause: error instanceof Error ? error.message : String(error),
236
+ });
237
+ }
238
+ if (!fileStat.isFile()) {
239
+ throw new AgentSetupError(`${label} is not a regular file: ${filePath}`, "SETUP_ARTIFACT_INVALID");
240
+ }
241
+ }
242
+ export async function resolveServerEntryPath(cliExecutablePath) {
243
+ const resolvedCli = await realpath(cliExecutablePath);
244
+ return path.join(path.dirname(resolvedCli), "index.js");
245
+ }
246
+ export async function detectInstalledAgents(options = {}) {
247
+ const environment = options.environment ?? process.env;
248
+ const timeoutMs = options.commandTimeoutMs ?? 5000;
249
+ return Promise.all(AGENT_IDS.map(async (id) => {
250
+ const definition = AGENT_DEFINITIONS[id];
251
+ const executable = await findExecutable(definition.command, environment);
252
+ if (executable === undefined) {
253
+ return { id, label: definition.label, installed: false };
254
+ }
255
+ let version;
256
+ try {
257
+ const result = await runCommand(executable, ["--version"], {
258
+ environment,
259
+ timeoutMs,
260
+ });
261
+ version = `${result.stdout}\n${result.stderr}`
262
+ .split(/\r?\n/)
263
+ .map((line) => line.trim())
264
+ .find(Boolean);
265
+ }
266
+ catch {
267
+ version = undefined;
268
+ }
269
+ return { id, label: definition.label, installed: true, executable, version };
270
+ }));
271
+ }
272
+ export async function runAgentSetup(options) {
273
+ const environment = options.environment ?? process.env;
274
+ const nodeExecutable = options.nodeExecutable ?? process.execPath;
275
+ const timeoutMs = options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
276
+ const requested = AGENT_IDS.filter((agent) => options.agents.includes(agent));
277
+ if (requested.length === 0) {
278
+ throw new AgentSetupError("At least one agent must be selected.", "SETUP_NO_AGENTS");
279
+ }
280
+ const detectionMap = new Map(options.detections.map((item) => [item.id, item]));
281
+ for (const agent of requested) {
282
+ const detection = detectionMap.get(agent);
283
+ if (!detection?.installed || detection.executable === undefined) {
284
+ throw new AgentSetupError(`${AGENT_DEFINITIONS[agent].label} is not installed or is not available in PATH.`, "AGENT_NOT_INSTALLED", { agent });
285
+ }
286
+ }
287
+ await assertRegularFile(options.serverEntryPath, "MCP server entrypoint");
288
+ await assertRegularFile(options.sourceSkillPath, "Bundled skill");
289
+ const inspections = await Promise.all(requested.map((agent) => inspectMcp(agent, detectionMap.get(agent), options.serverEntryPath, nodeExecutable, environment, timeoutMs)));
290
+ const conflict = inspections.find((item) => item.status === "conflict");
291
+ if (conflict !== undefined) {
292
+ throw new AgentSetupError(`Conflicting ${AGENT_DEFINITIONS[conflict.agent].label} MCP registration: ${conflict.detail}`, "AGENT_MCP_CONFLICT", { agent: conflict.agent });
293
+ }
294
+ const skillTarget = requested.length === 2 ? "all" : requested[0];
295
+ const skillResult = await installBundledSkill({
296
+ target: skillTarget,
297
+ scope: "user",
298
+ force: options.forceSkill ?? false,
299
+ sourceSkillPath: options.sourceSkillPath,
300
+ environment,
301
+ }).catch((error) => {
302
+ if (error instanceof SkillConflictError) {
303
+ throw new AgentSetupError(`Skill already exists with different content at ${error.destination}; pass --force-skill to replace it.`, "AGENT_SKILL_CONFLICT", { path: error.destination });
304
+ }
305
+ throw error;
306
+ });
307
+ const skillStatuses = new Map(skillResult.installations.map((installation) => [installation.target, installation.status]));
308
+ const completed = [];
309
+ for (const agent of requested) {
310
+ const detection = detectionMap.get(agent);
311
+ const inspection = inspections.find((item) => item.agent === agent);
312
+ try {
313
+ if (inspection.status === "missing") {
314
+ if (agent === "claude") {
315
+ await configureClaudeMcp(detection, options.serverEntryPath, nodeExecutable, environment, timeoutMs);
316
+ }
317
+ else {
318
+ await configureHermesMcp(detection, options.serverEntryPath, nodeExecutable, environment, timeoutMs);
319
+ }
320
+ }
321
+ completed.push({
322
+ agent,
323
+ executable: detection.executable,
324
+ version: detection.version,
325
+ mcp: inspection.status === "current" ? "unchanged" : "configured",
326
+ skill: skillStatuses.get(agent),
327
+ });
328
+ }
329
+ catch (error) {
330
+ if (error instanceof AgentSetupError) {
331
+ throw new AgentSetupError(error.message, error.code, {
332
+ cause: error.details,
333
+ completed,
334
+ skills: skillResult.installations,
335
+ });
336
+ }
337
+ throw error;
338
+ }
339
+ }
340
+ return {
341
+ status: "ok",
342
+ command: "setup",
343
+ server: { name: MCP_SERVER_NAME, command: nodeExecutable, args: [options.serverEntryPath] },
344
+ agents: completed,
345
+ };
346
+ }
@@ -0,0 +1,51 @@
1
+ import path from "node:path";
2
+ import { DiagnosticCategory, ts } from "ts-morph";
3
+ export function normalizeDiagnostic(diagnostic, projectRoot) {
4
+ const sourceFile = diagnostic.getSourceFile();
5
+ const start = diagnostic.getStart();
6
+ const location = sourceFile && start !== undefined ? sourceFile.getLineAndColumnAtPos(start) : undefined;
7
+ return {
8
+ code: diagnostic.getCode(),
9
+ category: DiagnosticCategory[diagnostic.getCategory()] ?? String(diagnostic.getCategory()),
10
+ file: sourceFile ? path.relative(projectRoot, sourceFile.getFilePath()) : null,
11
+ line: location?.line ?? null,
12
+ column: location?.column ?? null,
13
+ message: ts.flattenDiagnosticMessageText(diagnostic.compilerObject.messageText, "\n"),
14
+ };
15
+ }
16
+ function identity(diagnostic) {
17
+ return JSON.stringify([
18
+ diagnostic.code,
19
+ diagnostic.category,
20
+ diagnostic.file,
21
+ diagnostic.message,
22
+ ]);
23
+ }
24
+ function subtract(candidates, baseline) {
25
+ const counts = new Map();
26
+ for (const diagnostic of baseline) {
27
+ const key = identity(diagnostic);
28
+ counts.set(key, (counts.get(key) ?? 0) + 1);
29
+ }
30
+ const difference = [];
31
+ for (const diagnostic of candidates) {
32
+ const key = identity(diagnostic);
33
+ const remaining = counts.get(key) ?? 0;
34
+ if (remaining > 0) {
35
+ counts.set(key, remaining - 1);
36
+ }
37
+ else {
38
+ difference.push(diagnostic);
39
+ }
40
+ }
41
+ return difference;
42
+ }
43
+ export function compareDiagnostics(before, after) {
44
+ const added = subtract(after, before);
45
+ const removed = subtract(before, after);
46
+ return {
47
+ added,
48
+ removed,
49
+ addedErrors: added.filter((diagnostic) => diagnostic.category === "Error"),
50
+ };
51
+ }
@@ -0,0 +1,191 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { chmod, open, realpath, rename, unlink } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { z } from "zod";
6
+ import { applyOperation, configureOperationApply, exportOperationRecord, importOperationRecord, } from "./operations.js";
7
+ import { ensurePrivateDirectory, resolveRuntimeStateDirectory, } from "./runtime-state.js";
8
+ export const PLAN_SCHEMA_VERSION = 1;
9
+ export const MAX_PLAN_BYTES = 50 * 1024 * 1024;
10
+ const HashSchema = z.string().regex(/^[a-f0-9]{64}$/);
11
+ const IsoDateSchema = z.string().datetime({ offset: true });
12
+ const DiagnosticSchema = z
13
+ .object({
14
+ code: z.number().int(),
15
+ category: z.string(),
16
+ file: z.string().nullable(),
17
+ line: z.number().int().positive().nullable(),
18
+ column: z.number().int().positive().nullable(),
19
+ message: z.string(),
20
+ })
21
+ .strict();
22
+ const DiagnosticDeltaSchema = z
23
+ .object({
24
+ added: z.array(DiagnosticSchema),
25
+ removed: z.array(DiagnosticSchema),
26
+ addedErrors: z.array(DiagnosticSchema),
27
+ })
28
+ .strict();
29
+ const AffectedFileSchema = z
30
+ .object({
31
+ file: z.string().min(1),
32
+ original_hash: HashSchema,
33
+ updated_hash: HashSchema,
34
+ })
35
+ .strict();
36
+ const PersistedFileSchema = AffectedFileSchema.extend({
37
+ original_bytes_base64: z.string(),
38
+ updated_bytes_base64: z.string(),
39
+ mode: z.number().int().nonnegative(),
40
+ }).strict();
41
+ const PersistedOperationSchema = z
42
+ .object({
43
+ operation_id: z.string().uuid(),
44
+ plan_hash: HashSchema,
45
+ kind: z.enum(["rename_symbol", "replace_symbol_body"]),
46
+ status: z.enum(["prepared", "applied"]),
47
+ project_root: z.string().min(1),
48
+ created_at: IsoDateSchema,
49
+ expires_at: IsoDateSchema,
50
+ affected_files: z.array(AffectedFileSchema).min(1),
51
+ reference_count: z.number().int().nonnegative(),
52
+ workspace_hash: HashSchema,
53
+ post_workspace_hash: HashSchema,
54
+ workspace_file_count: z.number().int().positive(),
55
+ diagnostics: DiagnosticDeltaSchema,
56
+ allow_new_errors: z.boolean(),
57
+ blocked: z.boolean(),
58
+ block_reason: z.string().nullable(),
59
+ preview: z.string().nullable(),
60
+ preview_truncated: z.boolean(),
61
+ applied_at: IsoDateSchema.optional(),
62
+ files: z.array(PersistedFileSchema).min(1),
63
+ })
64
+ .strict();
65
+ const PlanEnvelopeSchema = z
66
+ .object({
67
+ schema_version: z.literal(PLAN_SCHEMA_VERSION),
68
+ operation: PersistedOperationSchema,
69
+ })
70
+ .strict();
71
+ function stateDirectory(options) {
72
+ return resolveRuntimeStateDirectory(options);
73
+ }
74
+ async function syncDirectory(directory) {
75
+ const handle = await open(directory, constants.O_RDONLY);
76
+ try {
77
+ await handle.sync();
78
+ }
79
+ finally {
80
+ await handle.close();
81
+ }
82
+ }
83
+ async function atomicWritePlan(planFile, envelope) {
84
+ const directory = path.dirname(planFile);
85
+ await ensurePrivateDirectory(directory);
86
+ const serialized = `${JSON.stringify(envelope)}\n`;
87
+ const bytes = Buffer.byteLength(serialized);
88
+ if (bytes > MAX_PLAN_BYTES) {
89
+ throw new Error(`Persisted plan is ${bytes} bytes; maximum is ${MAX_PLAN_BYTES}.`);
90
+ }
91
+ const temporaryPath = path.join(directory, `.${path.basename(planFile)}.${randomUUID()}.tmp`);
92
+ const handle = await open(temporaryPath, "wx", 0o600);
93
+ try {
94
+ await handle.writeFile(serialized, "utf8");
95
+ await handle.sync();
96
+ }
97
+ finally {
98
+ await handle.close();
99
+ }
100
+ try {
101
+ await rename(temporaryPath, planFile);
102
+ if (process.platform !== "win32")
103
+ await chmod(planFile, 0o600);
104
+ await syncDirectory(directory);
105
+ }
106
+ catch (error) {
107
+ await unlink(temporaryPath).catch(() => undefined);
108
+ throw error;
109
+ }
110
+ }
111
+ async function readPlanEnvelope(planFileInput) {
112
+ const planFile = path.resolve(planFileInput);
113
+ const flags = process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
114
+ const handle = await open(planFile, flags);
115
+ try {
116
+ const fileStat = await handle.stat();
117
+ if (!fileStat.isFile())
118
+ throw new Error(`Plan path is not a regular file: ${planFile}`);
119
+ const canonicalPlanFile = await realpath(planFile);
120
+ const samePath = process.platform === "win32"
121
+ ? canonicalPlanFile.toLowerCase() === planFile.toLowerCase()
122
+ : canonicalPlanFile === planFile;
123
+ if (!samePath) {
124
+ throw new Error(`Plan path must not traverse symbolic links: ${planFile}`);
125
+ }
126
+ if (process.platform !== "win32" && fileStat.nlink !== 1) {
127
+ throw new Error(`Plan file must have exactly one hard link: ${planFile}`);
128
+ }
129
+ const uid = process.getuid?.();
130
+ if (uid !== undefined && fileStat.uid !== uid) {
131
+ throw new Error(`Plan file is not owned by the current user: ${planFile}`);
132
+ }
133
+ if (fileStat.size > MAX_PLAN_BYTES) {
134
+ throw new Error(`Persisted plan is ${fileStat.size} bytes; maximum is ${MAX_PLAN_BYTES}.`);
135
+ }
136
+ if (process.platform !== "win32" && (fileStat.mode & 0o077) !== 0) {
137
+ throw new Error(`Plan file permissions are too broad; expected 0600: ${planFile}`);
138
+ }
139
+ const serialized = await handle.readFile({ encoding: "utf8" });
140
+ const parsed = PlanEnvelopeSchema.parse(JSON.parse(serialized));
141
+ return { planFile, envelope: parsed };
142
+ }
143
+ finally {
144
+ await handle.close();
145
+ }
146
+ }
147
+ export async function persistOperationPlan(operationId, options = {}) {
148
+ const plansDirectory = path.join(stateDirectory(options), "plans");
149
+ const planFile = path.join(plansDirectory, `${operationId}.astplan`);
150
+ await atomicWritePlan(planFile, {
151
+ schema_version: PLAN_SCHEMA_VERSION,
152
+ operation: exportOperationRecord(operationId),
153
+ });
154
+ return planFile;
155
+ }
156
+ export async function applyPersistedOperation(planFileInput, planHash, options = {}) {
157
+ if (!/^[a-f0-9]{64}$/.test(planHash)) {
158
+ throw new Error("plan_hash must be a 64-character lowercase SHA-256 digest.");
159
+ }
160
+ const current = await readPlanEnvelope(planFileInput);
161
+ const operationId = current.envelope.operation.operation_id;
162
+ importOperationRecord(current.envelope.operation, planHash);
163
+ configureOperationApply(operationId, {
164
+ stateDirectory: options.stateDirectory,
165
+ receiptWriter: async () => {
166
+ await atomicWritePlan(current.planFile, {
167
+ schema_version: PLAN_SCHEMA_VERSION,
168
+ operation: exportOperationRecord(operationId),
169
+ });
170
+ },
171
+ });
172
+ return applyOperation(operationId, planHash);
173
+ }
174
+ export async function inspectPersistedPlan(planFileInput) {
175
+ const { envelope } = await readPlanEnvelope(planFileInput);
176
+ const operation = envelope.operation;
177
+ return {
178
+ schema_version: PLAN_SCHEMA_VERSION,
179
+ operation_id: operation.operation_id,
180
+ plan_hash: operation.plan_hash,
181
+ status: operation.status,
182
+ project_root: operation.project_root,
183
+ created_at: operation.created_at,
184
+ expires_at: operation.expires_at,
185
+ affected_files: operation.affected_files,
186
+ blocked: operation.blocked,
187
+ block_reason: operation.block_reason,
188
+ preview: operation.preview,
189
+ preview_truncated: operation.preview_truncated,
190
+ };
191
+ }