gx-code-agent 0.0.0-stage → 0.1.1

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 (45) hide show
  1. package/README.md +276 -2
  2. package/bin/gx-stdio-mcp.js +145 -0
  3. package/bin/gx.js +99 -0
  4. package/contracts/README.md +40 -0
  5. package/contracts/alignment.schema.json +99 -0
  6. package/contracts/code-chunk.schema.json +28 -0
  7. package/contracts/evidence.schema.json +108 -0
  8. package/contracts/examples/alignment.example.json +26 -0
  9. package/contracts/examples/code-chunk.example.json +20 -0
  10. package/contracts/examples/evidence.add-field.json +49 -0
  11. package/contracts/examples/evidence.verify-manifest.json +52 -0
  12. package/contracts/examples/ext-method.example.json +14 -0
  13. package/contracts/examples/manifest.example.json +13 -0
  14. package/contracts/examples/unknown.extension-coverage.json +29 -0
  15. package/contracts/ext-method.schema.json +57 -0
  16. package/contracts/manifest.schema.json +27 -0
  17. package/contracts/unknown.schema.json +119 -0
  18. package/eval/README.md +52 -0
  19. package/eval/code-search-eval.json +216 -0
  20. package/package.json +38 -4
  21. package/src/adapters/cli/args.js +94 -0
  22. package/src/adapters/cli/output.js +66 -0
  23. package/src/adapters/platform/mcpClient.js +202 -0
  24. package/src/application/applyWorkflow.js +138 -0
  25. package/src/application/auditCommand.js +130 -0
  26. package/src/application/auditWorkflow.js +41 -0
  27. package/src/application/chunksCommand.js +87 -0
  28. package/src/application/errors.js +27 -0
  29. package/src/application/evalSearchCommand.js +148 -0
  30. package/src/application/initWorkflow.js +563 -0
  31. package/src/application/pullWorkflow.js +74 -0
  32. package/src/application/scanCommand.js +71 -0
  33. package/src/application/scanWorkflow.js +689 -0
  34. package/src/application/verifyWorkflow.js +142 -0
  35. package/src/domain/canonicalJson.js +72 -0
  36. package/src/domain/chunker.js +297 -0
  37. package/src/domain/digest.js +60 -0
  38. package/src/domain/java/JavaAstChunker.java +139 -0
  39. package/src/domain/java/JavaAstScanner.java +107 -0
  40. package/src/domain/javaAst.js +186 -0
  41. package/src/domain/jdk.js +66 -0
  42. package/src/domain/keepBlocks.js +122 -0
  43. package/src/store/backupStore.js +106 -0
  44. package/src/store/localStore.js +79 -0
  45. package/templates/platform-rules.md +29 -0
@@ -0,0 +1,563 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import readline from "node:readline/promises";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { McpHttpClient } from "../adapters/platform/mcpClient.js";
7
+ import { printPlan } from "../adapters/cli/output.js";
8
+ import { ApprovalRequiredError, UsageError } from "./errors.js";
9
+ import { InitTransaction, createBackupRoot, pruneBackups } from "../store/backupStore.js";
10
+ import { readJson, readText, writeText } from "../store/localStore.js";
11
+
12
+ const RULES_START = "<!-- GX-MCP-RULES:START -->";
13
+ const RULES_END = "<!-- GX-MCP-RULES:END -->";
14
+ const DEFAULT_TIMEOUT_MS = 180000;
15
+ const STARTUP_TIMEOUT_MS = 15000;
16
+ const CATALOG_TIMEOUT_MS = 30000;
17
+ const STDIO_BIN = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../bin/gx-stdio-mcp.js");
18
+ const GITIGNORE_LINES = ["backup/", "generated/", "*.local.json", "cline-mcp-settings.json"];
19
+
20
+ /**
21
+ * 构造鉴权头。默认写环境变量引用,避免明文 Token 落盘。
22
+ *
23
+ * @param {string} client
24
+ * @param {string} tokenEnv
25
+ * @param {string | undefined} token
26
+ * @param {boolean} embedToken
27
+ * @returns {string}
28
+ */
29
+ function authHeader(client, tokenEnv, token, embedToken) {
30
+ if (embedToken) {
31
+ return `Bearer ${token}`;
32
+ }
33
+ if (client === "opencode") {
34
+ return `Bearer {env:${tokenEnv}}`;
35
+ }
36
+ if (client === "claude") {
37
+ return `Bearer \${${tokenEnv}}`;
38
+ }
39
+ return `Bearer \${env:${tokenEnv}}`;
40
+ }
41
+
42
+ /**
43
+ * @param {string} url
44
+ * @param {string} tokenEnv
45
+ * @param {string | undefined} token
46
+ * @param {boolean} embedToken
47
+ * @param {number} timeoutMs
48
+ * @returns {Record<string, unknown>}
49
+ */
50
+ function remoteEntry(url, tokenEnv, token, embedToken, timeoutMs) {
51
+ return {
52
+ type: "remote",
53
+ url,
54
+ enabled: true,
55
+ oauth: false,
56
+ headers: { Authorization: authHeader("opencode", tokenEnv, token, embedToken) },
57
+ timeout: timeoutMs
58
+ };
59
+ }
60
+
61
+ /**
62
+ * OpenCode 本地 stdio MCP 配置。
63
+ *
64
+ * @param {"legacy"|"v2"} format
65
+ * @returns {Record<string, unknown>}
66
+ */
67
+ function openCodeLocalEntry(format) {
68
+ const base = {
69
+ type: "local",
70
+ command: [process.execPath, STDIO_BIN],
71
+ cwd: "."
72
+ };
73
+ if (format === "v2") {
74
+ return { ...base, disabled: false, timeout: { startup: STARTUP_TIMEOUT_MS, catalog: CATALOG_TIMEOUT_MS, execution: DEFAULT_TIMEOUT_MS } };
75
+ }
76
+ return { ...base, enabled: true };
77
+ }
78
+
79
+ /**
80
+ * @param {string} client
81
+ * @param {string} url
82
+ * @param {string} tokenEnv
83
+ * @param {string | undefined} token
84
+ * @param {boolean} embedToken
85
+ * @param {{ withLocal: boolean, opencodeFormat: "legacy"|"v2", timeoutMs: number }} options
86
+ * @returns {Record<string, unknown>}
87
+ */
88
+ function mcpEntry(client, url, tokenEnv, token, embedToken, options) {
89
+ const headers = { Authorization: authHeader(client, tokenEnv, token, embedToken) };
90
+ if (client === "opencode") {
91
+ if (options.opencodeFormat === "v2") {
92
+ return {
93
+ type: "remote",
94
+ url,
95
+ oauth: false,
96
+ headers,
97
+ timeout: {
98
+ startup: STARTUP_TIMEOUT_MS,
99
+ catalog: CATALOG_TIMEOUT_MS,
100
+ execution: options.timeoutMs
101
+ }
102
+ };
103
+ }
104
+ return { ...remoteEntry(url, tokenEnv, token, embedToken, options.timeoutMs) };
105
+ }
106
+ if (client === "claude") {
107
+ return { type: "http", url, headers };
108
+ }
109
+ return { url, headers, disabled: false, autoApprove: [] };
110
+ }
111
+
112
+ /**
113
+ * 生成客户端配置计划。
114
+ *
115
+ * @param {string} client
116
+ * @param {string} dir
117
+ * @param {string} url
118
+ * @param {string} tokenEnv
119
+ * @param {string | undefined} token
120
+ * @param {boolean} embedToken
121
+ * @param {{ withLocal: boolean, opencodeFormat: "legacy"|"v2", timeoutMs: number }} options
122
+ * @returns {{ path: string, before: string | null, after: string }}
123
+ */
124
+ function clientPlan(client, dir, url, tokenEnv, token, embedToken, options) {
125
+ const entry = mcpEntry(client, url, tokenEnv, token, embedToken, options);
126
+
127
+ if (client === "opencode") {
128
+ const file = path.join(dir, "opencode.json");
129
+ const existed = fs.existsSync(file);
130
+ const config = readJson(file, { $schema: "https://opencode.ai/config.json" });
131
+ if (options.opencodeFormat === "v2") {
132
+ config.mcp = config.mcp && typeof config.mcp === "object" ? config.mcp : {};
133
+ config.mcp.servers = config.mcp.servers && typeof config.mcp.servers === "object"
134
+ ? config.mcp.servers : {};
135
+ config.mcp.servers["gx-code"] = entry;
136
+ if (options.withLocal) {
137
+ config.mcp.servers["gx-local"] = openCodeLocalEntry("v2");
138
+ }
139
+ }
140
+ else {
141
+ config.mcp = config.mcp || {};
142
+ config.mcp["gx-code"] = entry;
143
+ if (options.withLocal) {
144
+ config.mcp["gx-local"] = openCodeLocalEntry("legacy");
145
+ }
146
+ }
147
+ return {
148
+ path: file,
149
+ before: existed ? readText(file) : null,
150
+ after: `${JSON.stringify(config, null, 2)}\n`
151
+ };
152
+ }
153
+
154
+ if (client === "claude") {
155
+ const file = path.join(dir, ".mcp.json");
156
+ const existed = fs.existsSync(file);
157
+ const config = readJson(file, { mcpServers: {} });
158
+ config.mcpServers = config.mcpServers || {};
159
+ config.mcpServers["gx-code"] = entry;
160
+ if (options.withLocal) {
161
+ config.mcpServers["gx-local"] = {
162
+ type: "stdio",
163
+ command: process.execPath,
164
+ args: [STDIO_BIN]
165
+ };
166
+ }
167
+ return {
168
+ path: file,
169
+ before: existed ? readText(file) : null,
170
+ after: `${JSON.stringify(config, null, 2)}\n`
171
+ };
172
+ }
173
+
174
+ if (client === "cline") {
175
+ const file = path.join(dir, ".gx", "cline-mcp-settings.json");
176
+ const existed = fs.existsSync(file);
177
+ const config = readJson(file, { mcpServers: {} });
178
+ config.mcpServers = config.mcpServers || {};
179
+ config.mcpServers["gx-code"] = entry;
180
+ if (options.withLocal) {
181
+ config.mcpServers["gx-local"] = {
182
+ command: process.execPath,
183
+ args: [STDIO_BIN],
184
+ disabled: false,
185
+ autoApprove: []
186
+ };
187
+ }
188
+ return {
189
+ path: file,
190
+ before: existed ? readText(file) : null,
191
+ after: `${JSON.stringify(config, null, 2)}\n`
192
+ };
193
+ }
194
+
195
+ throw new UsageError(`不支持的客户端:${client},可选 opencode / claude / cline`);
196
+ }
197
+
198
+ /**
199
+ * 注入平台规范块。
200
+ *
201
+ * @param {string} existing
202
+ * @param {string} rules
203
+ * @returns {string}
204
+ */
205
+ export function injectRulesText(existing, rules) {
206
+ const block = `${RULES_START}\n${rules.trim()}\n${RULES_END}`;
207
+ const start = existing.indexOf(RULES_START);
208
+ const end = existing.indexOf(RULES_END);
209
+ if (start >= 0 && end > start) {
210
+ return `${existing.slice(0, start)}${block}${existing.slice(end + RULES_END.length)}`;
211
+ }
212
+ return existing.trim() ? `${existing.trimEnd()}\n\n${block}\n` : `${block}\n`;
213
+ }
214
+
215
+ /**
216
+ * 生成项目绑定块。
217
+ *
218
+ * @param {{ planId?: string, templateId?: string, mcpUrl?: string, generatedDir?: string }} input
219
+ * @returns {string}
220
+ */
221
+ export function buildProjectBinding(input) {
222
+ const planId = input.planId || "<planId>";
223
+ const templateId = input.templateId || "<templateId>";
224
+ const generatedDir = input.generatedDir || "<按 .gx/manifest.json 的实际路径填写>";
225
+ return `## GX 项目绑定
226
+
227
+ - planId: ${planId}
228
+ - templateId: ${templateId}
229
+ - MCP Server: gx-code(remote)
230
+ - 本地校验: gx-local(stdio,可选)
231
+ - 生成层目录: ${generatedDir}
232
+ - MCP 地址: ${input.mcpUrl || "<mcpUrl>"}
233
+
234
+ ### 固定动作
235
+ 1. 任何结构问题先调用 get_task_context 或 get_plan_structure,不要猜字段。
236
+ 2. 任何写操作先 dryRun=true,展示 affectedFiles / changeSet / warnings,得到确认后再 apply。
237
+ 3. 生成层文件只允许通过 generate_code + gx pull/apply 更新。
238
+ 4. 行为代码只写扩展层,并加 @ExtMethod。
239
+ 5. 结束前执行 gx verify --strict、gx scan、gx audit --strict;有切片变更再执行 gx chunks。`;
240
+ }
241
+
242
+ /**
243
+ * 从 manifest 推断生成层目录。
244
+ *
245
+ * @param {string} dir
246
+ * @returns {string[]}
247
+ */
248
+ function detectGeneratedDirs(dir) {
249
+ const manifest = readJson(path.join(dir, ".gx", "manifest.json"), null);
250
+ const files = Array.isArray(manifest?.files) ? manifest.files : [];
251
+ const dirs = [];
252
+ for (const item of files) {
253
+ const value = typeof item === "string" ? item : item?.path;
254
+ if (typeof value !== "string" || !value.trim()) {
255
+ continue;
256
+ }
257
+ const normalized = value.replaceAll("\\", "/");
258
+ const dirname = path.posix.dirname(normalized);
259
+ if (dirname && dirname !== "." && !dirs.includes(dirname)) {
260
+ dirs.push(dirname);
261
+ }
262
+ if (dirs.length >= 5) {
263
+ break;
264
+ }
265
+ }
266
+ return dirs;
267
+ }
268
+
269
+ /**
270
+ * 生成规则文件计划。
271
+ *
272
+ * @param {string} dir
273
+ * @param {string} rulesPath
274
+ * @param {string} binding
275
+ * @returns {{ path: string, before: string | null, after: string }}
276
+ */
277
+ function rulesPlan(dir, rulesPath, binding) {
278
+ const preferred = ["AGENTS.md", "CLAUDE.md"];
279
+ const target = preferred.map((name) => path.join(dir, name)).find((file) => fs.existsSync(file))
280
+ || path.join(dir, "AGENTS.md");
281
+ const before = fs.existsSync(target) ? readText(target) : null;
282
+ const rules = fs.readFileSync(rulesPath, "utf8");
283
+ const content = binding ? `${rules.trim()}\n\n${binding.trim()}\n` : rules;
284
+ return { path: target, before, after: injectRulesText(before ?? "", content) };
285
+ }
286
+
287
+ /**
288
+ * 生成 .gx/.gitignore 计划。
289
+ *
290
+ * @param {string} dir
291
+ * @returns {{ path: string, before: string | null, after: string } | null}
292
+ */
293
+ function gitignorePlan(dir) {
294
+ const file = path.join(dir, ".gx", ".gitignore");
295
+ const existed = fs.existsSync(file);
296
+ const before = existed ? readText(file) : null;
297
+ const existingLines = (before ?? "")
298
+ .split(/\r?\n/u)
299
+ .map((line) => line.trim())
300
+ .filter(Boolean);
301
+ const missing = GITIGNORE_LINES.filter((line) => !existingLines.includes(line));
302
+ if (missing.length === 0) {
303
+ return null;
304
+ }
305
+ return {
306
+ path: file,
307
+ before,
308
+ after: `${[...existingLines, ...missing].join("\n")}\n`
309
+ };
310
+ }
311
+
312
+ /**
313
+ * 生成项目配置计划。
314
+ *
315
+ * @param {string} dir
316
+ * @param {string | undefined} planId
317
+ * @param {string} mcpUrl
318
+ * @param {string | undefined} templateId
319
+ * @returns {{ path: string, before: string | null, after: string } | null}
320
+ */
321
+ function projectPlan(dir, planId, mcpUrl, templateId) {
322
+ if (!planId && !templateId) {
323
+ return null;
324
+ }
325
+ const file = path.join(dir, ".gx", "config.json");
326
+ const existed = fs.existsSync(file);
327
+ const config = readJson(file, {});
328
+ if (planId) {
329
+ config.planId = planId;
330
+ }
331
+ if (mcpUrl) {
332
+ config.mcpUrl = mcpUrl;
333
+ }
334
+ if (templateId) {
335
+ config.templateId = templateId;
336
+ }
337
+ return {
338
+ path: file,
339
+ before: existed ? readText(file) : null,
340
+ after: `${JSON.stringify(config, null, 2)}\n`
341
+ };
342
+ }
343
+
344
+ /**
345
+ * @param {Record<string, unknown>} args
346
+ * @param {{ stdin: NodeJS.ReadStream, stdout: NodeJS.WriteStream, stderr: NodeJS.WriteStream }} io
347
+ * @returns {Promise<boolean>}
348
+ */
349
+ async function confirm(plan, io) {
350
+ if (!io.stdin.isTTY) {
351
+ throw new ApprovalRequiredError("非交互模式写入需要 --yes 确认");
352
+ }
353
+ io.stderr.write(`即将写入 ${plan.length} 个文件:\n`);
354
+ for (const item of plan) {
355
+ io.stderr.write(` - ${item.path}${item.before === null ? "(新建)" : "(更新)"}\n`);
356
+ }
357
+ const rl = readline.createInterface({ input: io.stdin, output: io.stdout });
358
+ try {
359
+ const answer = await rl.question("继续?[y/N] ");
360
+ return /^y(es)?$/iu.test(answer.trim());
361
+ } finally {
362
+ rl.close();
363
+ }
364
+ }
365
+
366
+ /**
367
+ * 连接检查:ping + tools/list + get_current_user。
368
+ *
369
+ * @param {string} url
370
+ * @param {string | undefined} token
371
+ * @param {number} timeoutMs
372
+ * @returns {Promise<Record<string, unknown>>}
373
+ */
374
+ async function verifyConnection(url, token, timeoutMs) {
375
+ if (!token) {
376
+ return { status: "skipped", reason: "未提供 Token" };
377
+ }
378
+ const client = new McpHttpClient({
379
+ url,
380
+ token,
381
+ clientName: "gx-code-agent-init",
382
+ timeoutMs
383
+ });
384
+ try {
385
+ await client.initialize();
386
+ const tools = await client.listTools();
387
+ const ping = await client.callTool("ping", { client: "gx init" });
388
+ let user = null;
389
+ try {
390
+ user = await client.callTool("get_current_user", {});
391
+ }
392
+ catch {
393
+ user = null;
394
+ }
395
+ return {
396
+ status: "ok",
397
+ protocolVersion: client.protocolVersion,
398
+ toolCount: tools.length,
399
+ tools: tools.map((item) => item.name).filter(Boolean),
400
+ ping,
401
+ user
402
+ };
403
+ }
404
+ catch (error) {
405
+ return {
406
+ status: "failed",
407
+ reason: error instanceof Error ? error.message : String(error)
408
+ };
409
+ }
410
+ finally {
411
+ await client.close().catch(() => {});
412
+ }
413
+ }
414
+
415
+ /**
416
+ * 输出连接检查结果。
417
+ *
418
+ * @param {Record<string, unknown>} verification
419
+ * @param {NodeJS.WriteStream} stdout
420
+ */
421
+ function printVerification(verification, stdout) {
422
+ if (verification.status === "skipped") {
423
+ stdout.write(`- 连接检查: 跳过(${verification.reason})\n`);
424
+ return;
425
+ }
426
+ if (verification.status === "failed") {
427
+ stdout.write(`- 连接检查: 失败(${verification.reason})\n`);
428
+ return;
429
+ }
430
+ const user = verification.user && typeof verification.user === "object" ? verification.user : null;
431
+ const scopes = Array.isArray(user?.scopes) ? user.scopes.join(",") : "";
432
+ const planScope = Array.isArray(user?.planScope) ? user.planScope.join(",") : "";
433
+ stdout.write(`- 连接检查: OK(协议 ${verification.protocolVersion},工具 ${verification.toolCount} 个)\n`);
434
+ if (user) {
435
+ stdout.write(`- 当前用户: userId=${user.userId ?? "-"}, tokenId=${user.tokenId ?? "-"}\n`);
436
+ stdout.write(`- scopes: ${scopes || "-"}\n`);
437
+ stdout.write(`- planScope: ${planScope || "全部方案"}\n`);
438
+ }
439
+ }
440
+
441
+ /**
442
+ * 执行 gx init。
443
+ *
444
+ * @param {Record<string, unknown>} args
445
+ * @param {{ stdin: NodeJS.ReadStream, stdout: NodeJS.WriteStream, stderr: NodeJS.WriteStream }} [io]
446
+ * @returns {Promise<Record<string, unknown>>}
447
+ */
448
+ export async function runInit(args, io = { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr }) {
449
+ const client = String(args.client || "opencode");
450
+ const url = String(args.url || "http://127.0.0.1:4013/mcp");
451
+ const tokenEnv = String(args["token-env"] || "GX_API_TOKEN");
452
+ const embedToken = Boolean(args["embed-token"]);
453
+ const token = typeof args.token === "string" ? args.token : process.env[tokenEnv];
454
+ const dir = path.resolve(String(args.dir || process.cwd()));
455
+ const dryRun = Boolean(args["dry-run"]);
456
+ const check = Boolean(args.check);
457
+ const yes = Boolean(args.yes);
458
+ const withLocal = Boolean(args["with-local"]);
459
+ const skipVerify = Boolean(args["skip-verify"]) || process.env.GX_INIT_SKIP_VERIFY === "1";
460
+ const opencodeFormat = String(args["opencode-format"] || "legacy");
461
+ const timeoutMs = Number(args.timeout || DEFAULT_TIMEOUT_MS);
462
+ const existingConfig = readJson(path.join(dir, ".gx", "config.json"), {});
463
+ const planId = typeof args.plan === "string" ? args.plan : existingConfig.planId;
464
+ const templateId = typeof args.template === "string" ? args.template : existingConfig.templateId;
465
+ const generatedDir = typeof args["generated-dir"] === "string"
466
+ ? args["generated-dir"]
467
+ : detectGeneratedDirs(dir).join(", ");
468
+
469
+ if (embedToken && !token) {
470
+ throw new UsageError("使用 --embed-token 时必须提供 --token 或设置对应环境变量。");
471
+ }
472
+ if (!embedToken && !token) {
473
+ io.stderr.write(`[gx] 未检测到 ${tokenEnv},配置将写入环境变量引用;启动客户端前请设置该变量。\n`);
474
+ }
475
+ if (!fs.existsSync(dir)) {
476
+ throw new UsageError(`目标目录不存在:${dir}`);
477
+ }
478
+ if (client === "opencode" && !["legacy", "v2"].includes(opencodeFormat)) {
479
+ throw new UsageError("--opencode-format 仅支持 legacy 或 v2");
480
+ }
481
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
482
+ throw new UsageError("--timeout 必须是正整数毫秒");
483
+ }
484
+
485
+ const rulesPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../templates/platform-rules.md");
486
+ const binding = buildProjectBinding({ planId, templateId, mcpUrl: url, generatedDir });
487
+ const plan = [
488
+ clientPlan(client, dir, url, tokenEnv, token, embedToken, { withLocal, opencodeFormat, timeoutMs }),
489
+ rulesPlan(dir, rulesPath, binding)
490
+ ];
491
+ const project = projectPlan(dir, planId, url, templateId);
492
+ if (project) {
493
+ plan.push(project);
494
+ }
495
+ const gitignore = gitignorePlan(dir);
496
+ if (gitignore) {
497
+ plan.push(gitignore);
498
+ }
499
+
500
+ if (check) {
501
+ const changed = plan.filter((item) => item.before !== item.after);
502
+ io.stdout.write("检查模式(不写入任何文件)\n");
503
+ if (changed.length === 0) {
504
+ io.stdout.write("- 配置文件: 已是最新\n");
505
+ }
506
+ else {
507
+ io.stdout.write(`- 配置文件: 需要更新 ${changed.length} 个\n`);
508
+ for (const item of changed) {
509
+ io.stdout.write(` - ${item.path}\n`);
510
+ }
511
+ }
512
+ const verification = skipVerify
513
+ ? { status: "skipped", reason: "使用 --skip-verify" }
514
+ : await verifyConnection(url, token, Math.min(timeoutMs, 5000));
515
+ printVerification(verification, io.stdout);
516
+ const failed = changed.length > 0 || verification.status === "failed";
517
+ if (failed) {
518
+ throw new Error(changed.length > 0 ? "配置需要更新,请执行 gx init 写入" : "连接检查失败");
519
+ }
520
+ return { status: "checked", files: plan.map((item) => item.path), verification };
521
+ }
522
+
523
+ if (dryRun) {
524
+ printPlan(plan);
525
+ return { status: "planned", files: plan.map((item) => item.path) };
526
+ }
527
+
528
+ const approved = yes || await confirm(plan, io);
529
+ if (!approved) {
530
+ throw new ApprovalRequiredError("用户取消写入");
531
+ }
532
+
533
+ const backupRoot = createBackupRoot(dir);
534
+ const transaction = new InitTransaction(dir, backupRoot);
535
+ try {
536
+ for (const item of plan) {
537
+ transaction.record(item.path);
538
+ writeText(item.path, item.after);
539
+ }
540
+ }
541
+ catch (error) {
542
+ transaction.rollback();
543
+ throw error;
544
+ }
545
+ pruneBackups(dir);
546
+
547
+ const verification = skipVerify
548
+ ? { status: "skipped", reason: "使用 --skip-verify" }
549
+ : await verifyConnection(url, token, Math.min(timeoutMs, 5000));
550
+
551
+ io.stdout.write("\n初始化完成。\n");
552
+ io.stdout.write(`- 客户端: ${client}${client === "opencode" ? `(${opencodeFormat})` : ""}\n`);
553
+ io.stdout.write(`- MCP 地址: ${url}\n`);
554
+ io.stdout.write(`- 鉴权方式: ${embedToken ? "明文 Token(已写入配置,请勿提交)" : `环境变量 ${tokenEnv}`}\n`);
555
+ io.stdout.write(`- 本地 MCP: ${withLocal ? "已注册 gx-local" : "未注册(可加 --with-local)"}\n`);
556
+ for (const item of plan) {
557
+ io.stdout.write(`- 已写入: ${item.path}\n`);
558
+ }
559
+ printVerification(verification, io.stdout);
560
+ io.stdout.write("\n下一步:重启客户端,确认 gx-code 已连接。\n");
561
+
562
+ return { status: "applied", files: plan.map((item) => item.path), verification };
563
+ }
@@ -0,0 +1,74 @@
1
+ import path from "node:path";
2
+
3
+ import { McpHttpClient } from "../adapters/platform/mcpClient.js";
4
+ import { readJson, writeJson, writeText } from "../store/localStore.js";
5
+
6
+ /**
7
+ * 从平台拉取生成结果并写入本地 .gx 目录。
8
+ *
9
+ * @param {Record<string, unknown>} args
10
+ * @param {{ stdout: NodeJS.WriteStream, stderr: NodeJS.WriteStream }} io
11
+ * @returns {Promise<Record<string, unknown>>}
12
+ */
13
+ export async function runPull(args, io = { stdout: process.stdout, stderr: process.stderr }) {
14
+ const dir = path.resolve(String(args.dir || process.cwd()));
15
+ const config = readJson(path.join(dir, ".gx", "config.json"), {});
16
+ const planId = String(args.plan || config.planId || "");
17
+ const templateId = String(args.template || config.templateId || "");
18
+ const url = String(args.url || config.mcpUrl || "http://127.0.0.1:4013/mcp");
19
+ const token = String(args.token || process.env.GX_API_TOKEN || "");
20
+ const modelIds = typeof args["model-ids"] === "string"
21
+ ? args["model-ids"].split(",").map((item) => item.trim()).filter(Boolean)
22
+ : undefined;
23
+ const dryRun = Boolean(args["dry-run"]);
24
+
25
+ if (!planId) {
26
+ throw new Error("gx pull 需要 --plan 或 .gx/config.json 中的 planId");
27
+ }
28
+ if (!templateId) {
29
+ throw new Error("gx pull 需要 --template 或 .gx/config.json 中的 templateId");
30
+ }
31
+ if (!token) {
32
+ throw new Error("缺少 API Token;请设置 GX_API_TOKEN 或传入 --token");
33
+ }
34
+
35
+ const client = new McpHttpClient({ url, token });
36
+ try {
37
+ const result = await client.callTool("generate_code", {
38
+ planId,
39
+ templateId,
40
+ modelIds,
41
+ includeContent: true
42
+ });
43
+ if (!result || !Array.isArray(result.files) || !result.manifest) {
44
+ throw new Error("generate_code 返回结构不完整");
45
+ }
46
+ io.stdout.write(`生成成功:${result.files.length} 个文件,metadataVersion=${result.metadataVersion}\n`);
47
+ for (const warning of result.warnings ?? []) {
48
+ io.stderr.write(`[warn] ${warning}\n`);
49
+ }
50
+ if (dryRun) {
51
+ for (const file of result.files) {
52
+ io.stdout.write(`[dry-run] ${file.path} sha256=${file.sha256}\n`);
53
+ }
54
+ return { status: "planned", planId, templateId, fileCount: result.files.length };
55
+ }
56
+
57
+ for (const file of result.files) {
58
+ const target = path.join(dir, ".gx", "generated", file.path);
59
+ writeText(target, file.content ?? "");
60
+ }
61
+ writeJson(path.join(dir, ".gx", "manifest.json"), result.manifest);
62
+ writeJson(path.join(dir, ".gx", "pull.json"), {
63
+ planId,
64
+ templateId,
65
+ metadataVersion: result.metadataVersion,
66
+ generatedAt: result.generatedAt,
67
+ fileCount: result.files.length
68
+ });
69
+ io.stdout.write(`已写入 .gx/generated 与 .gx/manifest.json\n`);
70
+ return { status: "pulled", planId, templateId, fileCount: result.files.length };
71
+ } finally {
72
+ await client.close();
73
+ }
74
+ }