From 5993da4ce6cf707dff5babf4b1e752096549e206 Mon Sep 17 00:00:00 2001 From: lanyizi Date: Thu, 10 Sep 2026 19:18:15 +0200 Subject: [PATCH] ai agent --- README.md | 16 ++ README.zh-CN.md | 6 +- docs/ai-agent-integration-plan.md | 349 +++++++++++++++++++++++++++++ package.json | 2 +- src/agent/cli.ts | 352 ++++++++++++++++++++++-------- src/agent/instances.ts | 222 +++++++++++++++++++ src/agent/liveClient.ts | 272 +++++++++++++++++++++++ src/agent/mcpServer.ts | 176 +++------------ src/agent/runtime.ts | 256 ++++++++++++++++++++++ src/agent/setup.ts | 57 +++-- src/agent/skill.ts | 43 +++- src/extension.ts | 122 ++++++++++- test/agentCli.test.mjs | 187 ++++++++++++++++ test/agentInstances.test.mjs | 168 ++++++++++++++ test/agentLiveClient.test.mjs | 205 +++++++++++++++++ test/agentLiveE2E.test.mjs | 297 +++++++++++++++++++++++++ test/agentMcpRouting.test.mjs | 81 ++----- test/agentRuntime.test.mjs | 241 ++++++++++++++++++++ test/agentSkill.test.mjs | 26 +++ tools/probe-electron-node.cjs | 89 ++++++++ tools/serve-fake-index.cjs | 89 ++++++++ 21 files changed, 2916 insertions(+), 340 deletions(-) create mode 100644 src/agent/instances.ts create mode 100644 src/agent/liveClient.ts create mode 100644 src/agent/runtime.ts create mode 100644 test/agentCli.test.mjs create mode 100644 test/agentInstances.test.mjs create mode 100644 test/agentLiveClient.test.mjs create mode 100644 test/agentLiveE2E.test.mjs create mode 100644 test/agentRuntime.test.mjs create mode 100644 tools/probe-electron-node.cjs create mode 100644 tools/serve-fake-index.cjs diff --git a/README.md b/README.md index 8f924c5..e938cec 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,22 @@ reports truncation instead of silently dropping results. Discovery is per project (`~/.ra3modxml/endpoints/.json`), so several VS Code windows can enable agent access at the same time without shadowing each other, and a client can never be silently answered from a different project. +Each window also registers itself under `~/.ra3modxml/instances/`, and a small +merged `~/.ra3modxml/index.json` lists the live instances and their project +roots. A window that crashes is cleaned up by whichever instance starts next — +no workspace has to be reopened first. + +**No Node installation is required.** The launcher runs the bundled server on +VS Code's own Electron binary (`ELECTRON_RUN_AS_NODE=1`) and only falls back to +`node` from `PATH` if that binary is missing. The launcher is rewritten on every +activation, so updating or moving VS Code does not break an existing MCP config. + +The MCP server is plain JSON-RPC over stdio, so an agent can also query the +index without any MCP setup at all by piping a request into the launcher. A +matching CLI (`cli.js`, next to `mcpServer.js` in `dist/agent/`) answers from +the live index when VS Code is running and from the exported snapshot +otherwise; commands that need element context say so explicitly rather than +returning an empty result. See `docs/ai-agent-integration-plan.md` for the full design and progress. diff --git a/README.zh-CN.md b/README.zh-CN.md index 4491820..5630c3a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -178,7 +178,11 @@ MCP Server 在扩展运行时优先查询内存中的实时索引;扩展关闭 `get_asset_references` 会沿 `inheritFrom` 祖先链遍历,并用 `definedIn` 标出条目实际写在哪个祖先的文件里,因此"这个单位自己没有 `WeaponSetUpdate`,但它继承的基础单位有"可以在一次调用里回答。它受 `depth`(默认 1,上限 3)、`targetTypes` 与 `maxEdges` 三重限制,并在截断时显式报告,而不是静默丢弃结果。 -Endpoint 按项目存放(`~/.ra3modxml/endpoints/.json`),因此多个 VS Code 窗口可以同时启用 AI Agent 访问而不互相覆盖,客户端也不会被静默地用另一个项目的数据回答。 +Endpoint 按项目存放(`~/.ra3modxml/endpoints/.json`),因此多个 VS Code 窗口可以同时启用 AI Agent 访问而不互相覆盖,客户端也不会被静默地用另一个项目的数据回答。每个窗口还会在 `~/.ra3modxml/instances/` 下登记自己,并由一份合并的只读 `~/.ra3modxml/index.json` 列出当前实例与项目根。崩溃残留的实例会被下一个启动的实例顺手清理,不需要先重新打开同一个工作区。 + +**不需要安装 Node。** launcher 使用 VS Code 自带的 Electron 二进制(`ELECTRON_RUN_AS_NODE=1`)运行内置的 MCP Server,只有在该二进制缺失时才回退到 `PATH` 上的 `node`。launcher 在每次激活时重写,所以升级或移动 VS Code 都不会让已有的 MCP 配置失效。 + +MCP Server 本质上就是 stdio 上的 JSON-RPC,因此 agent 也可以完全不做 MCP 配置,直接把请求管道给 launcher。另有一个配套 CLI(`cli.js`,与 `mcpServer.js` 同在 `dist/agent/`):VS Code 运行时走实时索引,否则读导出的快照;需要元素上下文的命令会明确说明,而不是返回空结果。 完整设计与进度见 `docs/ai-agent-integration-plan.md`。 diff --git a/docs/ai-agent-integration-plan.md b/docs/ai-agent-integration-plan.md index 8f7beb9..75a7c58 100644 --- a/docs/ai-agent-integration-plan.md +++ b/docs/ai-agent-integration-plan.md @@ -35,6 +35,18 @@ | 二期 C2 | 本地 HTTP `/get_asset_references` 与 MCP 工具接入 | ✅ 完成 | | 二期 C3 | 二期测试与文档 | ✅ 完成(264/264 测试通过) | +### 三期进度(2026-09-10) + +| 阶段 | 内容 | 状态 | +|---|---|---| +| 三期 0 | 讨论结论与进度落档 | ✅ 完成 | +| 三期 1 | 运行时解析:无 Node 时用 VS Code Electron(`ELECTRON_RUN_AS_NODE`) | ✅ 完成 | +| 三期 2 | 抽出 `src/agent/liveClient.ts`,MCP 与 CLI 共用转发/校验/负缓存 | ✅ 完成 | +| 三期 3 | `instances/` 注册表 + 跨实例剪枝 + 只读 `index.json` 发现面 | ✅ 完成 | +| 三期 4 | CLI 瘦身:live 优先、快照兜底、cwd 项目识别、补 `projects`/`get_asset_references` | ✅ 完成 | +| 三期 5 | 三期测试与文档 | ✅ 完成(302 用例,299 通过 / 3 因沙箱跳过) | +| 三期 6 | (可选)TCP → 命名管道 / UDS 传输 | ⬜ 未开始 | + --- ## 1. 背景与目标 @@ -770,4 +782,341 @@ VS Code 扩展进程内的 localServer ← 仅在"启用 AI Agent 访问"后启 验证方式为:逐个 `node test/*.test.mjs`、直接调用 esbuild CLI 构建 dist、 用管道驱动 `dist/agent/mcpServer.js` 做端到端 smoke test。 +--- +--- +# 第三部分:三期计划(讨论结论 + 实施) + +> 讨论时间:2026-09-10 +> 主题:Agent 自发现 MCP 的现实边界、崩溃残留清理、CLI 定位、无 Node 环境 + +--- + +## 13. 讨论结论:自操作 Agent 能否自行发现并配置 MCP + +### 现状(核实过代码) + +`SKILL_MD` 全文不含 `~/.ra3modxml`、launcher 路径、CLI 或任何"工具不存在时怎么办" +的说明。它的第一条指令是"调用 `get_status`"。因此当 skill 已加载但 MCP 工具 +不在工具列表里时,agent 只能得到 unknown tool,然后没有下一步。 + +三个独立缺口: + +1. **没有发现面。** 磁盘上的文件名带 `sha1-12` 哈希与 slug + (`endpoints/-.json`、`snapshots/-.json.gz`), + 只有读过本仓库源码的人才知道这套命名。agent 没有"读一个文件就知道全貌"的入口。 +2. **skill 与 MCP 是独立安装的。** `installAgentSkill` 装的是 + `~/.agents/skills/ra3-mod-xml/`,与 MCP 配置无关,所以"skill 在、MCP 不在" + 是常见状态,而这恰恰是最需要 fallback 的场景。 +3. **多数 harness 不允许 agent 给自己挂 MCP。** Claude Code 需要重启才能加载新的 + MCP server(相关 issue 与 SIGHUP workaround 见调研),Reddit 上也有"每次装 + MCP 都要手动重启"的反馈;反例是 LibreChat 的设置面板明确"take effect without + a restart"。所以这是 **harness 相关**:agent 可以**写**配置,但通常不能让自己 + **当前会话**用上,只能为下一次会话准备好。 + +### 结论 + +- **skill 不应假设 MCP 已就绪**,而要给出"如何触达索引"的阶梯: + 1. MCP 工具已在工具列表 → 直接用; + 2. 否则读发现面清单;不存在 → 告诉用户去 VS Code 跑一次 Enable,**不要瞎猜**; + 存在 → 用 shell 走 stdio,或用 CLI; + 3. 若能写自己的 MCP 配置,可以提议代写,但必须告知**下个会话才生效**。 +- **"需要重启才生效"这类细节不必写进 skill。** 新一代自操作 agent 本来就该自己 + 探测能力边界;skill 只需提示它"检查自己的能力范围并告知用户"。 +- **不要把各 harness 的配置路径写进 skill 文本。** 那些路径会变,写进 skill 等于 + 把易变知识固化进 agent 上下文。逻辑留在 `setup.ts` + (已有 `addMcpServerToConfigFile` / `commonMcpConfigTargets`),通过 CLI 暴露成 + `configure --harness `;skill 只说"在用户同意后可以运行这条命令"。 + +--- + +## 14. 讨论结论:崩溃残留与 `instances/` 注册表 + +### 问题确认 + +核实结果:全仓只有两处 `rm`,都在 `stopAgentLocalServer`(即优雅 dispose)中。 +`endpoints/` **没有任何剪枝逻辑**。因此: + +- **崩溃后文件永久残留**,只有下次打开**同一个工作区**才会覆盖同名文件; + 删掉项目、移动目录、改 SDK 路径后,旧文件永远躺着。 +- **PID 复用是真实风险。** Windows PID 会被回收;若残留 PID 恰好被无关进程占用, + `isProcessAlive` 返回 true → 连接失败 → 5 秒负缓存 → 反复重试。不会给出错误 + 答案(`responseProjectMismatch` 仍在),但会持续做无用功。 + +### 结论:不要"共享一个 JSON + 加锁合并" + +共享单文件需要锁文件(Windows 上 `wx` + 退避 + 陈旧锁窃取)、需要 +read-modify-write 合并(否则并发写互相丢条目),而锁本身也会被崩溃留下 +——**为了修 A 的崩溃问题引入了 B 的崩溃问题**。 + +改用**写入方互不重叠**的结构: + +```text +~/.ra3modxml/ + instances/ + vscode--.json ← 每个 VS Code 窗口一个,只有属主会写 + index.json ← 只读合并视图,供 agent/人发现 + snapshots/-.json.gz + skill-install.json +``` + +- **无锁、无合并**:每实例只写自己那一个文件,天然无竞争。 +- **崩溃自愈**:任何实例激活时扫一遍 `instances/`,`isProcessAlive(pid) === false` + 的直接 unlink,不需要等"下次打开同一个工作区"。 +- **读方仍要校验**:liveness = PID 存活 **且** 能连上;PID 复用场景下连接失败即 + 视为死,并顺带触发剪枝。 +- `index.json` 是纯只读派生物(由 `instances/` 生成),机器协调文件与人类/agent + 发现面分离。 + +`endpoints/` 保留为兼容读取路径,不再主动写入。 + +--- + +## 15. 讨论结论:CLI 定位与"稳定 IPC" + +### 澄清:端口从未写进 harness 永久设置 + +写进配置的是 launcher: + +```json +{ "mcpServers": { "ra3-mod-xml": { "command": "~/.ra3modxml/ra3-mod-xml-mcp.cmd", "args": ["--project", "..."] } } } +``` + +端口由 `instances/*.json` 桥接,每次开新会话时由 MCP server 现读。所以 +"临时端口会变"对 harness 完全透明。换命名管道/UDS 的收益不是"稳定性",而是: + +- Windows 防火墙对监听 TCP socket 可能弹窗,命名管道不会; +- 不占端口; +- 权限模型更自然(管道可设 ACL;TCP loopback 任何本机进程都能连,现在靠 token)。 + +代价:Node 的 `fetch` 不支持 socketPath,客户端要从 `fetch` 换成 +`http.request({ socketPath })`(服务端 `listen(pipePath)` 即可,路由不用改)。 + +### CLI 定位:薄 + +CLI 只做两件事:**读磁盘快照** + **转发给扩展(live)**。不要在 CLI 里重新实现 +"高级功能",那等于写两遍。 + +**但要注意**:转发逻辑现在锁死在 `mcpServer.ts` 里 +(`tryLiveQuery` / `liveUrlForTool` / `responseProjectMismatch` / 负缓存)。 +若 CLI 再写一遍就是重复。因此必须先抽 `src/agent/liveClient.ts`,让 +`mcpServer.ts` 与 `cli.ts` **同时**依赖它: + +- 换传输层时只改一个文件; +- `projectDir` 校验、负缓存等安全逻辑不会在两个入口之间漂移。 + +--- + +## 16. 讨论结论:无 Node 环境(已验证) + +### 问题 + +- 不能假定用户装了 Node。 +- `writeLauncher` 生成的是裸 `node "" ...`,所以**没装 Node 的用户 + 点完 Enable 之后 MCP 起不来**,而且不会报"缺 node"。这是**已存在的缺口**, + 不是未来风险。 +- 打包自包含或原生二进制是否必要? + +### 结论:不需要打包,VS Code 自带运行时,只是还没用它 + +VS Code 的 Electron 二进制可以当 Node 用: + +```bat +@echo off +set ELECTRON_RUN_AS_NODE=1 +"C:\...\Microsoft VS Code\Code.exe" "\dist\agent\mcpServer.js" --project "D:\..." +``` + +**本机实测(2026-09-10,VS Code 1.135.0 / Electron 42.8.1)**: + +| 项目 | 结果 | +|---|---| +| `ELECTRON_RUN_AS_NODE=1` 生效 | ✅ `process.versions.electron = 42.8.1` | +| Node 版本 | `24.18.1`(系统 Node 为 24.16.0) | +| 必需模块(`fs`/`fs/promises`/`path`/`os`/`http`/`readline`/`zlib`/`crypto`/`util`/`net`/`child_process`) | ✅ 全部可载入 | +| `node:test` | ✅ 可载入 | +| `forwardRefs.parseLoadedXml` | ✅ `elements=2` | +| `localServer.startLocalServer` + HTTP 请求 | ✅ 正常返回 | +| `zlib` gzip/gunzip 往返 | ✅ | +| **打包后的 `dist/agent/mcpServer.js` 完整 stdio 会话** | ✅ `initialize` / `tools/list`(9 个工具) / `tools/call get_status` 全部正确 | + +因此: + +- **零外部依赖**,只装 VS Code 就够; +- 路径来自 `process.execPath`,自动适配 Code / Insiders / VSCodium / 各平台; +- VS Code 升级后路径变了也没关系——扩展每次激活重写 launcher,而 launcher 本身 + 在 `~/.ra3modxml/` 下路径稳定; +- 需要 fallback:Electron 二进制被移动/卸载时回退到 PATH 上的 `node`。 + +已知代价(实测未测,需留意的边界): + +- Electron 以 Node 模式启动比真 Node 慢(握手延迟增加,对 MCP 应无碍); +- 远程/WSL/容器场景下 `process.execPath` 是 server 端二进制,MCP 必须跑在远端 + —— 独立边界问题,本期不解决。 + +### 如何在"本机有 Node"的前提下测试"无 Node" + +不需要真的卸载 Node。要做的是**证明 Electron 路径可以独立工作**,即整条链路 +不依赖"PATH 上能解析到 `node`": + +1. 直接只用 Electron 二进制驱动 `dist/agent/mcpServer.js`(已做,见上表); +2. 让解析出的 launcher **只用** Electron,不回落; +3. 断言 launcher 文本中不出现裸 `node`(当 Electron 可用时)。 + +这三点都在本环境可做,无需移除 Node。 + +--- + +## 17. 三期实施清单 + +| 优先级 | 内容 | 理由 | +|---|---|---| +| 1 | 运行时解析(`src/agent/runtime.ts`)+ launcher 优先 Electron | 现在是"配好了但可能起不来",最伤 | +| 2 | 抽 `src/agent/liveClient.ts` | 后续所有传输层改动的前提;避免转发逻辑写两遍 | +| 3 | `instances/` + 跨实例剪枝 + 只读 `index.json` | 修崩溃残留,兼做 agent 发现面 | +| 4 | CLI 瘦身(live 优先 / 快照兜底 / cwd 识别 / `projects`) | 让"没有 MCP 的 agent"也能用 | +| 5 | 传输层 TCP → 命名管道 / UDS | 收益中等,需 2 完成,可缓 | + +--- + +## 18. 三期实施结果 + +全部完成。 + +### 新增 / 修改文件 + +```text +新增: + src/agent/runtime.ts 运行时解析 + launcher 脚本生成(纯函数,可测) + src/agent/liveClient.ts 共享 live 转发层(MCP 与 CLI 共用) + src/agent/instances.ts instances/ 注册表、剪枝、只读 index.json + test/agentRuntime.test.mjs + test/agentLiveClient.test.mjs + test/agentInstances.test.mjs + test/agentCli.test.mjs + test/agentLiveE2E.test.mjs + tools/probe-electron-node.cjs Electron-as-Node 能力探针(保留为证据) + tools/serve-fake-index.cjs 假 live 索引服务器(手工 smoke 用) + +修改: + src/agent/setup.ts writeLauncher 改用 runtime + launcherScript, + 返回 { path, runtime, nodeFree } + src/agent/mcpServer.ts 改用 LiveClient;新增 list_projects; + 启动时剪枝崩溃实例;require.main 守卫 + src/agent/cli.ts 重写为薄客户端:live 优先 / 快照兜底 / + cwd 项目识别 / projects / outgoing + src/extension.ts instances/ 写入与剪枝、index.json 刷新、 + launcher 激活时刷新、nodeFree 警告 +``` + +### 关键行为 + +**无 Node 运行(三期 1)** + +- launcher 优先用 VS Code 的 Electron 二进制(`ELECTRON_RUN_AS_NODE=1`), + Node 只作为"Electron 二进制不存在"时的兜底: + `if not exist "%RA3_RUNTIME%" goto :ra3_node`。 +- 用 `goto` 而不是 `if (...)` 块:批处理里块内的 `%errorlevel%` 在**解析时**展开, + 会导致退出码错误。 +- 扩展激活时会重写 launcher(不只是 Enable 时),所以 VS Code 升级换了路径 + 也能自动跟上。 +- 解析不到 Electron 时会警告用户"launcher 将依赖 PATH 上的 Node"。 + +**`instances/` 注册表(三期 3)** + +```text +~/.ra3modxml/ + instances/vscode--.json 每窗口一个,只有属主写 + index.json 只读合并视图(不含 token) + endpoints/-.json 兼容读取,不再主动写 + snapshots/… + skill-install.json +``` + +- **无锁无合并**:写入方互不重叠,没有 read-modify-write 竞争。 +- **崩溃自愈**:任何实例启动时剪掉 PID 已死的条目,不需要等同一工作区重开。 +- **只剪确定的死 PID**:`isProcessAlive` 对未知/缺失 PID 返回 true, + 所以老格式文件不会被误删。 +- `index.json` 是派生物,只用于发现;断言过其中不含 token。 + +**共享 live 层(三期 2)** + +`liveClient.ts` 现在同时被 MCP 与 CLI 使用,集中了: + +- 请求永远带 `?project=`(否则会被活动编辑器的项目回答); +- `index.projectDir` 不匹配就拒答; +- 死 PID 视为失效; +- 失败后 5 秒负缓存(可注入时钟,测试可确定性验证); +- 端点发现顺序:按项目文件 → 任何 `projects` 含该项目的实例 → 全局兜底指针。 + +**薄 CLI(三期 4)** + +- `outgoing` / `projects` 是 live-only:不可用时返回**明确原因**, + 而不是空结果(空结果会被 agent 读成"没有引用")。 +- 项目解析顺序:`--project` → `--snapshot` → 从 cwd 向上找 mod 项目根。 +- 退出码:0 成功 / 2 用法错误 / 3 不可用。 + +### 已知限制 + +- `get_asset_references` 不计算 `xai:joinAction`(`Replace`/`Remove`)合并后的 + 有效值,需要调用方自行确认。 +- 传输层仍是 loopback HTTP(临时端口 + token)。换命名管道/UDS 的收益是 + 免防火墙弹窗与更自然的权限模型,代价是客户端要从 `fetch` 改为 + `http.request({ socketPath })`;因为转发逻辑已集中,改动面只剩 + `liveClient.ts`。 +- 远程 / WSL / 容器场景下 `process.execPath` 是服务端二进制,MCP 必须跑在 + 远端;本期未处理。 +- CLI 在"无 Node 且无 VS Code"的环境下无法运行(这是逻辑必然:它需要一个 + 运行时)。有 VS Code 时用 Electron 二进制即可。 + +--- + +## 19. 三期测试与验证 + +``` +npx tsc --noEmit 通过 +test/*.test.mjs 37 个文件 / 302 个用例,299 通过,3 跳过 +``` + +跳过的 3 个都是同一原因:本沙箱禁止从 Node 进程 spawn shell(`EPERM`), +所以"真正拉起 launcher / CLI 子进程"的集成用例会优雅跳过并给出原因。 +它们在本机正常运行时会执行。 + +### Electron-as-Node 实测证据 + +`tools/probe-electron-node.cjs`(VS Code 1.135.0 / Electron 42.8.1,Windows): + +| 项目 | 结果 | +|---|---| +| `ELECTRON_RUN_AS_NODE=1` 生效 | ✅ `process.versions.electron = 42.8.1` | +| Node 版本 | `24.18.1`(系统 Node 24.16.0) | +| 必需模块 | ✅ `fs`/`fs/promises`/`path`/`os`/`http`/`readline`/`zlib`/`crypto`/`util`/`net`/`child_process` | +| `node:test` | ✅ 可载入 | +| `parseLoadedXml` | ✅ | +| `startLocalServer` + HTTP 请求 | ✅ | +| `zlib` gzip/gunzip 往返 | ✅ | +| **打包后 `dist/agent/mcpServer.js` 完整 stdio 会话** | ✅ initialize / tools/list(10 个) / tools/call | + +### 无 Node 运行的证明方式 + +不是卸载 Node,而是证明整条链路不依赖"PATH 上能解析到 node": + +1. 用 `cmd /c` + `ELECTRON_RUN_AS_NODE=1` 直接驱动 `dist/agent/mcpServer.js` + → 完整 MCP 会话成功; +2. 生成的 launcher 里把 **Node 兜底指向一个不存在的路径**,会话仍然成功 + → 证明走的是 Electron 分支; +3. 断言 launcher 文本:Windows 下 Node 分支只能通过 + `if not exist "%RA3_RUNTIME%" goto :ra3_node` 到达。 + +### 其他已验证行为 + +- **CLI 会自然退出**:实测一次 live 查询后进程 59ms 内自然退出, + `fetch` 的 keep-alive 不会吊住 CLI(否则对 agent 是致命的可用性问题)。 +- **live 端到端**:`test/agentLiveE2E.test.mjs` 启动真实 `startLocalServer`, + 按扩展的方式写 instance/endpoint/manifest,然后用**同一个 `LiveClient`** + 跑通全部工具,包括 `get_asset_references` 的 `definedIn` 继承溯源。 +- **跨项目隔离**:为第三个项目创建的客户端在只有 A/B 实例时返回 null, + 不会回落到 A 的数据。 +- **发现降级**:删掉 `endpoints/` 后仍能通过 `instances/` 找到实例。 +- **崩溃剪枝**:插入一个 PID 必死的实例,剪枝后另一个实例仍正常工作; + 清理本窗口实例不影响其他窗口。 diff --git a/package.json b/package.json index 9304049..bd6f602 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "ra3-mod-xml", "displayName": "%ra3modxml.displayName%", "description": "%ra3modxml.description%", - "version": "0.1.25", + "version": "0.1.26", "publisher": "lanyi", "license": "SEE LICENSE IN LICENSE", "icon": "images/icon.png", diff --git a/src/agent/cli.ts b/src/agent/cli.ts index 2bc63ee..b57580a 100644 --- a/src/agent/cli.ts +++ b/src/agent/cli.ts @@ -1,16 +1,37 @@ /** - * Minimal CLI for querying an exported RA3 Mod XML agent snapshot. + * Thin CLI for RA3 Mod XML index queries. * - * This is intentionally not installed into PATH. It is meant for scripts, - * debugging, and as a reference for MCP tool implementations. + * Deliberately thin: it does **not** reimplement the query surface. It does + * exactly two things. * - * Usage examples: - * node out/agent/cli.js --project D:/Mods/Example status - * node out/agent/cli.js --snapshot /path/to/snapshot.json.gz find ExampleUnit - * node out/agent/cli.js --project D:/Mods/Example refs ExampleUnit GameObject + * 1. Forward the call to the live VS Code extension through the shared + * `LiveClient` (same transport, project pinning and validation as the MCP + * server, so the two entry points cannot drift apart). + * 2. Fall back to the on-disk snapshot exported by the extension when no live + * instance is reachable. + * + * Features that need the DOM (element-level provenance) are live-only; the + * CLI reports that clearly instead of returning an empty result that would + * look like "no references exist". + * + * The CLI is not installed into PATH. It is reached through an absolute path, + * normally by an agent following the skill's discovery instructions. + * + * Usage: + * node dist/agent/cli.js status + * node dist/agent/cli.js find [type] + * node dist/agent/cli.js refs [type] + * node dist/agent/cli.js outgoing [type] [--depth N] [--target-types A,B] + * node dist/agent/cli.js list [prefix] + * node dist/agent/cli.js active + * node dist/agent/cli.js define + * node dist/agent/cli.js resolve + * node dist/agent/cli.js projects */ -import { readSnapshotFile, snapshotPathForProject } from "./snapshot"; +import { resolve } from "node:path"; +import { findProjectRootUpward } from "../projectRoot"; +import { LiveClient, type LiveClientOptions } from "./liveClient"; import { findAssets, findDefine, @@ -20,32 +41,63 @@ import { resolveIncludeSource, statusFromSnapshot, } from "./query"; +import { readSnapshotFile, snapshotPathForProject } from "./snapshot"; +import type { AgentIndexSnapshot } from "./types"; -interface CliOptions { - snapshotPath: string | null; +const EXIT_OK = 0; +const EXIT_USAGE = 2; +const EXIT_UNAVAILABLE = 3; + +export interface CliOptions { projectDir: string | null; + snapshotPath: string | null; + agentHome: string | undefined; command: string; args: string[]; + depth: number | undefined; + targetTypes: string[]; + maxEdges: number | undefined; + includeUnresolved: boolean; } -function parseArgs(argv: string[]): CliOptions { +export function parseArgs(argv: string[]): CliOptions { const options: CliOptions = { - snapshotPath: null, projectDir: null, + snapshotPath: null, + agentHome: undefined, command: "status", args: [], + depth: undefined, + targetTypes: [], + maxEdges: undefined, + includeUnresolved: false, }; const positional: string[] = []; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; - if (arg === "--snapshot" || arg === "-s") { - options.snapshotPath = argv[++i] ?? null; - } else if (arg === "--project" || arg === "-p") { + if (arg === "--project" || arg === "-p") { options.projectDir = argv[++i] ?? null; + } else if (arg === "--snapshot" || arg === "-s") { + options.snapshotPath = argv[++i] ?? null; + } else if (arg === "--agent-home") { + options.agentHome = argv[++i] ?? undefined; + } else if (arg === "--depth" || arg === "-d") { + const raw = argv[++i]; + options.depth = raw != null ? Number(raw) : undefined; + } else if (arg === "--target-types" || arg === "-t") { + options.targetTypes = (argv[++i] ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } else if (arg === "--max-edges") { + const raw = argv[++i]; + options.maxEdges = raw != null ? Number(raw) : undefined; + } else if (arg === "--include-unresolved") { + options.includeUnresolved = true; } else if (arg === "--help" || arg === "-h") { options.command = "help"; } else if (arg.startsWith("-")) { - // ignore unknown flags + // Ignore unknown flags rather than failing an agent's probing call. } else { positional.push(arg); } @@ -58,102 +110,214 @@ function parseArgs(argv: string[]): CliOptions { } function printHelp(): void { - console.log(`RA3 Mod XML agent snapshot CLI + process.stdout.write(`RA3 Mod XML agent CLI Usage: - node out/agent/cli.js --project [args...] - node out/agent/cli.js --snapshot [args...] + node cli.js [--project ] [args...] + node cli.js [--snapshot ] [args...] + +Project resolution order: + 1. --project + 2. --snapshot + 3. nearest mod project root above the current directory Commands: - status - find [type] - refs [type] - list [prefix] - active - define - resolve + status index state (+ source: live or snapshot) + find [type] asset definitions + refs [type] incoming semantic references + outgoing [type] outgoing reference edges (live only) + --depth N --target-types A,B --max-edges N --include-unresolved + list [prefix] assets of one type + active whether a file is in an indexed stream + define $DEFINE constants + resolve Include source candidate + projects indexed project roots (live only) help `); } -async function main(): Promise { - const options = parseArgs(process.argv.slice(2)); - if (options.command === "help") { - printHelp(); - return; - } - const snapshotPath = - options.snapshotPath ?? - (options.projectDir ? snapshotPathForProject(options.projectDir) : null); - if (!snapshotPath) { - console.error("No --project or --snapshot provided."); - process.exitCode = 2; - return; - } - const snapshot = await readSnapshotFile(snapshotPath); - if (!snapshot) { - console.error(`Snapshot not found or unreadable: ${snapshotPath}`); - process.exitCode = 3; - return; - } +/** Resolves the project directory, inferring it from cwd when not given. */ +export function resolveProjectDir(options: CliOptions): string | null { + if (options.projectDir) return resolve(options.projectDir); + const inferred = findProjectRootUpward(process.cwd()); + return inferred; +} +/** Writes JSON to stdout and returns the exit code. */ +function emit(value: unknown): number { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); + return EXIT_OK; +} + +function fail(message: string, code = EXIT_USAGE): number { + process.stderr.write(`${message}\n`); + return code; +} + +/** Live-only commands, reported explicitly when live is unreachable. */ +export const LIVE_ONLY = new Set(["outgoing", "projects"]); + +export function liveArgsFor(options: CliOptions): Record { const [a, b] = options.args; switch (options.command) { - case "status": - console.log(JSON.stringify(statusFromSnapshot(snapshot), null, 2)); - break; case "find": - if (!a) { - console.error("find requires an id."); - process.exitCode = 2; - return; - } - console.log(JSON.stringify(findAssets(snapshot, a, b), null, 2)); - break; + return { id: a, type: b }; case "refs": - if (!a) { - console.error("refs requires an id."); - process.exitCode = 2; - return; - } - console.log(JSON.stringify(findReferenceGroups(snapshot, a, b), null, 2)); - break; + return { id: a, type: b }; + case "outgoing": + return { + id: a, + type: b, + depth: options.depth, + targetTypes: options.targetTypes?.length ? options.targetTypes : undefined, + maxEdges: options.maxEdges, + includeUnresolved: options.includeUnresolved ? true : undefined, + }; case "list": - if (!a) { - console.error("list requires a type."); - process.exitCode = 2; - return; - } - console.log(JSON.stringify(listAssetsByType(snapshot, a, b ?? ""), null, 2)); - break; + return { type: a, prefix: b }; case "active": - if (!a) { - console.error("active requires a file path."); - process.exitCode = 2; - return; - } - console.log(JSON.stringify({ active: isFileActive(snapshot, a) }, null, 2)); - break; + return { path: a }; case "define": - if (!a) { - console.error("define requires a name."); - process.exitCode = 2; - return; - } - console.log(JSON.stringify(findDefine(snapshot, a), null, 2)); - break; + return { name: a }; case "resolve": - if (!a) { - console.error("resolve requires a source."); - process.exitCode = 2; - return; - } - console.log(JSON.stringify(resolveIncludeSource(snapshot, a), null, 2)); - break; + return { source: a }; default: - console.error(`Unknown command: ${options.command}`); - process.exitCode = 2; + return {}; } } -void main(); +/** Maps a CLI command to the live/MCP tool name. */ +export function toolNameFor(command: string): string { + switch (command) { + case "find": + return "find_asset"; + case "refs": + return "find_references"; + case "outgoing": + return "get_asset_references"; + case "list": + return "list_assets_by_type"; + case "active": + return "is_file_active"; + case "define": + return "find_define"; + case "resolve": + return "resolve_include"; + case "projects": + return "list_projects"; + default: + return "get_status"; + } +} + +/** Runs the command against the on-disk snapshot. */ +function runSnapshotCommand( + options: CliOptions, + snapshot: AgentIndexSnapshot, +): number { + const [a, b] = options.args; + switch (options.command) { + case "status": + return emit({ source: "snapshot", index: statusFromSnapshot(snapshot) }); + case "find": + if (!a) return fail("find requires an id."); + return emit(findAssets(snapshot, a, b)); + case "refs": + if (!a) return fail("refs requires an id."); + return emit(findReferenceGroups(snapshot, a, b)); + case "list": + if (!a) return fail("list requires a type."); + return emit(listAssetsByType(snapshot, a, b ?? "")); + case "active": + if (!a) return fail("active requires a file path."); + return emit({ active: isFileActive(snapshot, a) }); + case "define": + if (!a) return fail("define requires a name."); + return emit(findDefine(snapshot, a.replace(/^\$/, ""))); + case "resolve": + if (!a) return fail("resolve requires a source."); + return emit(resolveIncludeSource(snapshot, a)); + default: + return fail(`Unknown command: ${options.command}`); + } +} + +/** Explains why a live-only command could not run. */ +function liveOnlyUnavailable(options: CliOptions): number { + return emit({ + index: { state: "no_index" }, + source: "unavailable", + error: + options.command === "outgoing" + ? "get_asset_references requires a live index: the element context it reports is not stored in the on-disk snapshot. Open the project in VS Code with AI Agent access enabled, then retry." + : "list_projects requires a live index. Open the project in VS Code with AI Agent access enabled, then retry.", + }); +} + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + if (options.command === "help") { + printHelp(); + return EXIT_OK; + } + + const projectDir = resolveProjectDir(options); + + // Live first: the extension's in-memory index is the most complete source. + if (!options.snapshotPath) { + const liveOptions: LiveClientOptions = { + projectDir, + agentHome: options.agentHome, + }; + const client = new LiveClient(liveOptions); + const result = await client.query( + toolNameFor(options.command), + liveArgsFor(options), + ); + if (result?.mismatched) { + return emit({ + index: { state: "error" }, + source: "live", + error: `The live server answered for a different project than "${projectDir}"; refusing the result.`, + }); + } + if (result) { + return emit({ source: "live", ...(result.payload as object) }); + } + // No live index: fall through to the snapshot when the command allows it. + if (LIVE_ONLY.has(options.command)) return liveOnlyUnavailable(options); + } + + // Snapshot fallback. + const snapshotPath = + options.snapshotPath ?? + (projectDir ? snapshotPathForProject(projectDir, options.agentHome) : null); + if (!snapshotPath) { + return fail( + "No project found. Pass --project , --snapshot , or run from inside a mod project.", + EXIT_UNAVAILABLE, + ); + } + const snapshot = await readSnapshotFile(snapshotPath); + if (!snapshot) { + return fail( + `No live VS Code instance and no readable snapshot at ${snapshotPath}. Open the project in VS Code with AI Agent access enabled, or run the "Export AI Agent index snapshot" command.`, + EXIT_UNAVAILABLE, + ); + } + return runSnapshotCommand(options, snapshot); +} + +// Only run the CLI when executed directly, so the module stays importable by +// tests (same guard as the MCP server). +if (typeof require !== "undefined" && require.main === module) { + void main().then( + (code) => { + process.exitCode = code; + }, + (err) => { + process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); + process.exitCode = EXIT_UNAVAILABLE; + }, + ); +} diff --git a/src/agent/instances.ts b/src/agent/instances.ts new file mode 100644 index 0000000..976f7e2 --- /dev/null +++ b/src/agent/instances.ts @@ -0,0 +1,222 @@ +/** + * Live-instance registry. + * + * Each VS Code window that enables AI Agent access writes **its own** file + * under `instances/`. Two properties follow from that: + * + * - **No locking and no merging.** Writers never touch each other's files, so + * there is no read-modify-write race to guard. An earlier design that + * shared a single JSON file would have needed a lock file (itself another + * thing a crash can leave behind) plus a merge step. + * - **Crash recovery does not wait for the same workspace.** Any instance can + * prune entries whose recorded PID is dead, so a crashed window is cleaned + * up the next time *any* VS Code window with the extension activates. + * + * A merged, read-only `index.json` is derived from the instance files purely + * for discovery (agents/humans looking at the directory), so the machine + * coordination files and the human-readable view stay separate. + * + * Pure TypeScript: no VS Code dependency. + */ + +import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { isProcessAlive, type AgentEndpoint } from "./endpoint"; +import { defaultAgentHome, snapshotBaseName } from "./snapshot"; + +/** One live extension-host instance. */ +export interface AgentInstance extends AgentEndpoint { + /** Unique per window; also the file name stem. */ + instanceId: string; +} + +/** Discovery manifest derived from all live instances. */ +export interface AgentIndexManifest { + schemaVersion: number; + generatedAt: string; + instances: Array<{ + instanceId: string; + processId?: number; + url: string; + /** Tokens are intentionally omitted: the manifest is for discovery. */ + projects: string[]; + }>; + projects: string[]; +} + +export const INSTANCE_SCHEMA_VERSION = 1; + +/** Directory holding one file per live extension host. */ +export function instancesDir(agentHome = defaultAgentHome()): string { + return join(agentHome, "instances"); +} + +/** Path to the merged discovery manifest. */ +export function manifestPath(agentHome = defaultAgentHome()): string { + return join(agentHome, "index.json"); +} + +/** File name for one instance. */ +export function instanceFileName(instanceId: string): string { + return `vscode-${instanceId}.json`; +} + +/** + * Builds a process-unique instance id. Combining the PID with a random suffix + * keeps two windows of the same process id from colliding across restarts. + */ +export function makeInstanceId(pid = process.pid): string { + const rand = Math.random().toString(36).slice(2, 8); + return `${pid}-${rand}`; +} + +/** Writes this instance's own file. */ +export async function writeInstance( + instance: AgentInstance, + agentHome = defaultAgentHome(), +): Promise { + const file = join(instancesDir(agentHome), instanceFileName(instance.instanceId)); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, `${JSON.stringify(instance, null, 2)}\n`, "utf8"); + return file; +} + +/** Reads every instance file, skipping malformed ones. */ +export async function readInstances( + agentHome = defaultAgentHome(), +): Promise { + const dir = instancesDir(agentHome); + let names: string[]; + try { + names = await readdir(dir); + } catch { + return []; + } + const out: AgentInstance[] = []; + for (const name of names) { + if (!name.endsWith(".json")) continue; + try { + const parsed = JSON.parse( + await readFile(join(dir, name), "utf8"), + ) as AgentInstance; + if (parsed?.url && parsed?.token) { + // Fall back to the file name when an older file lacks the field. + parsed.instanceId ??= name.replace(/^vscode-/, "").replace(/\.json$/, ""); + out.push(parsed); + } + } catch { + // Skip unreadable/corrupt entries. + } + } + return out; +} + +/** Removes this instance's own file. */ +export async function clearInstance( + instanceId: string, + agentHome = defaultAgentHome(), +): Promise { + await rm(join(instancesDir(agentHome), instanceFileName(instanceId)), { + force: true, + }); +} + +export interface PruneResult { + removed: string[]; + kept: AgentInstance[]; +} + +/** + * Removes instance files whose recorded PID is no longer alive. + * + * This is how crashes are cleaned up without waiting for the same workspace to + * be reopened. Only clearly-dead PIDs are pruned: `isProcessAlive` treats an + * unknown or unparseable PID as alive, so an older file that predates the + * `processId` field is never deleted by mistake. + */ +export async function pruneInstances( + agentHome = defaultAgentHome(), +): Promise { + const instances = await readInstances(agentHome); + const removed: string[] = []; + const kept: AgentInstance[] = []; + for (const instance of instances) { + if (isProcessAlive(instance.processId)) { + kept.push(instance); + } else { + removed.push(instance.instanceId); + await clearInstance(instance.instanceId, agentHome).catch(() => undefined); + } + } + return { removed, kept }; +} + +/** Collects every project root across live instances. */ +export function projectsOf(instances: readonly AgentInstance[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const instance of instances) { + const candidates = [ + ...(instance.projects ?? []), + ...(instance.projectDir ? [instance.projectDir] : []), + ]; + for (const project of candidates) { + const key = project.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(project); + } + } + return out; +} + +/** + * Regenerates the merged read-only manifest from the live instances. + * Best-effort: failures are swallowed because the manifest is a convenience, + * not a correctness requirement (readers can always scan `instances/`). + */ +export async function writeManifest( + instances: readonly AgentInstance[], + agentHome = defaultAgentHome(), +): Promise { + const manifest: AgentIndexManifest = { + schemaVersion: INSTANCE_SCHEMA_VERSION, + generatedAt: new Date().toISOString(), + instances: instances.map((instance) => ({ + instanceId: instance.instanceId, + processId: instance.processId, + url: instance.url, + projects: [ + ...(instance.projects ?? []), + ...(instance.projectDir ? [instance.projectDir] : []), + ], + })), + projects: projectsOf(instances), + }; + try { + const file = manifestPath(agentHome); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + } catch { + // Discovery only: never fail the caller over this. + } + return manifest; +} + +/** Reads the discovery manifest, or null when absent/malformed. */ +export async function readManifest( + agentHome = defaultAgentHome(), +): Promise { + try { + return JSON.parse( + await readFile(manifestPath(agentHome), "utf8"), + ) as AgentIndexManifest; + } catch { + return null; + } +} + +/** Snapshot path for a project, re-exported for discovery convenience. */ +export function projectKey(projectDir: string): string { + return snapshotBaseName(projectDir); +} diff --git a/src/agent/liveClient.ts b/src/agent/liveClient.ts new file mode 100644 index 0000000..fcf50ea --- /dev/null +++ b/src/agent/liveClient.ts @@ -0,0 +1,272 @@ +/** + * Shared live-index client used by both the MCP server and the CLI. + * + * Keeping this in one place matters because the forwarding path carries safety + * logic that must not drift between entry points: + * + * - the requested project is always pinned on the request, so the server + * cannot answer from whichever project its active editor points at; + * - a response reporting a different `projectDir` is refused rather than + * shown, because a plausible wrong answer is worse than no answer; + * - a dead extension-host PID marks the instance file stale; + * - failed attempts are negatively cached so every tool call does not pay a + * connection timeout. + * + * Transport is currently loopback HTTP. Swapping it for a named pipe / Unix + * domain socket only requires changing this file. + * + * Pure TypeScript: no VS Code dependency. + */ + +import { + isProcessAlive, + readEndpoint, + readEndpointForProject, + sameProject, + type AgentEndpoint, +} from "./endpoint"; +import { readInstances } from "./instances"; + +/** Cooldown after a failed live attempt, to avoid a probe per tool call. */ +const LIVE_RETRY_COOLDOWN_MS = 5000; +/** Per-request timeout for a live query. */ +const LIVE_TIMEOUT_MS = 1500; + +export interface LiveQueryResult { + payload: unknown; + /** True when the live server answered but for a different project. */ + mismatched: boolean; + /** Which endpoint produced the answer (for diagnostics). */ + endpoint: AgentEndpoint; +} + +/** Normalizes a path for case/separator-insensitive comparison. */ +export function normalizePath(p: string): string { + return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); +} + +/** + * Rejects a live response that belongs to a different project than the one + * the client was started for. See docs/ai-agent-integration-plan.md §10 for + * why this guard exists: in a multi-window setup a client configured for + * project A could otherwise silently receive project B's index. + */ +export function responseProjectMismatch( + payload: unknown, + projectDir: string | null, +): boolean { + if (!projectDir) return false; + const index = (payload as { index?: { projectDir?: string } } | null)?.index; + const reported = index?.projectDir; + if (!reported) return false; + return normalizePath(reported) !== normalizePath(projectDir); +} + +/** Tools that can be answered by the live index and their HTTP paths. */ +const TOOL_PATHS: Record = { + get_status: "/status", + find_asset: "/find_asset", + find_references: "/find_references", + get_asset_references: "/get_asset_references", + list_assets_by_type: "/list_assets", + is_file_active: "/is_file_active", + find_define: "/find_define", + resolve_include: "/resolve_include", + list_projects: "/projects", +}; + +/** + * Builds the live request URL for a tool call, always pinning the project. + * Returns null for tools the live server does not serve. + */ +export function liveUrlForTool( + endpointUrl: string, + projectDir: string | null, + toolName: string, + args: Record, +): string | null { + const path = TOOL_PATHS[toolName]; + if (!path) return null; + const base = endpointUrl.replace(/\/$/, ""); + const q = new URLSearchParams(); + // Always pin the requested project. Without this the server would silently + // answer from whatever project its active editor points at. + if (projectDir) q.set("project", projectDir); + switch (toolName) { + case "get_status": + case "list_projects": + break; + case "find_asset": + case "find_references": + case "get_asset_references": + q.set("id", String(args.id ?? "")); + if (args.type != null) q.set("type", String(args.type)); + if (toolName === "get_asset_references") { + if (args.depth != null) q.set("depth", String(args.depth)); + if (Array.isArray(args.targetTypes)) { + q.set("targetTypes", (args.targetTypes as unknown[]).map(String).join(",")); + } + if (args.maxEdges != null) q.set("maxEdges", String(args.maxEdges)); + if (args.includeUnresolved != null) { + q.set("includeUnresolved", String(args.includeUnresolved)); + } + } + break; + case "list_assets_by_type": + q.set("type", String(args.type ?? "")); + if (args.prefix != null) q.set("prefix", String(args.prefix)); + if (args.limit != null) q.set("limit", String(args.limit)); + break; + case "is_file_active": + q.set("path", String(args.path ?? "")); + break; + case "find_define": + q.set("name", String(args.name ?? "")); + break; + case "resolve_include": + q.set("source", String(args.source ?? "")); + break; + default: + return null; + } + const query = q.toString(); + return query ? `${base}${path}?${query}` : `${base}${path}`; +} + +export interface LiveClientOptions { + /** Project the client was started for (pins every request). */ + projectDir?: string | null; + /** Override the agent home directory (used by tests). */ + agentHome?: string; + /** Clock injection for deterministic negative-cache tests. */ + now?: () => number; +} + +/** + * Finds a usable endpoint for this client's project. + * + * Order: per-project file, then any live instance whose `projects` list + * contains this project, then the legacy global pointer (only when its + * recorded project matches). Endpoints with a dead PID are skipped. + */ +export async function findEndpoint( + options: LiveClientOptions = {}, +): Promise { + const projectDir = options.projectDir ?? null; + const agentHome = options.agentHome; + + if (projectDir) { + const direct = await readEndpointForProject(projectDir, agentHome); + if (direct && isProcessAlive(direct.processId)) return direct; + } + + // Scan live instances: this is what makes a freshly started window work + // even if its per-project file has not been written yet. + try { + const instances = await readInstances(agentHome); + for (const instance of instances) { + if (!isProcessAlive(instance.processId)) continue; + if (projectDir && instance.projectDir && !sameProject(instance.projectDir, projectDir)) { + continue; + } + if ( + projectDir && + instance.projects?.length && + !instance.projects.some((p) => sameProject(p, projectDir)) + ) { + continue; + } + return instance; + } + } catch { + // Instance scanning is best-effort. + } + + const fallback = await readEndpoint(agentHome); + if (fallback && isProcessAlive(fallback.processId)) { + if ( + !projectDir || + !fallback.projectDir || + sameProject(fallback.projectDir, projectDir) + ) { + return fallback; + } + } + return null; +} + +/** + * Queries the live extension server. Returns null when no live index is + * reachable, so callers can fall back to the on-disk snapshot. + */ +export class LiveClient { + private unavailableUntil = 0; + private readonly now: () => number; + + constructor(private readonly options: LiveClientOptions = {}) { + this.now = options.now ?? Date.now; + } + + /** True while the negative cache is suppressing attempts. */ + get suppressed(): boolean { + return this.now() < this.unavailableUntil; + } + + /** Clears the negative cache (e.g. after the user re-enables access). */ + reset(): void { + this.unavailableUntil = 0; + } + + /** Marks the live path unavailable for the cooldown period. */ + markUnavailable(): void { + this.unavailableUntil = this.now() + LIVE_RETRY_COOLDOWN_MS; + } + + async query( + toolName: string, + args: Record = {}, + ): Promise { + if (this.suppressed) return null; + if (typeof fetch !== "function") return null; + const projectDir = this.options.projectDir ?? null; + const endpoint = await findEndpoint(this.options); + if (!endpoint) return null; + + const url = liveUrlForTool(endpoint.url, projectDir, toolName, args); + if (!url) return null; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), LIVE_TIMEOUT_MS); + try { + const res = await fetch(url, { + headers: { authorization: `Bearer ${endpoint.token}` }, + signal: controller.signal, + }); + if (!res.ok) { + // 401/404 mean the endpoint is stale rather than merely slow. + if (res.status === 401 || res.status === 404) this.markUnavailable(); + return null; + } + const payload: unknown = await res.json(); + return { + payload, + mismatched: responseProjectMismatch(payload, projectDir), + endpoint, + }; + } catch { + this.markUnavailable(); + return null; + } finally { + clearTimeout(timeout); + } + } +} + +/** Convenience one-shot query using a fresh client. */ +export async function queryLive( + toolName: string, + args: Record = {}, + options: LiveClientOptions = {}, +): Promise { + return new LiveClient(options).query(toolName, args); +} diff --git a/src/agent/mcpServer.ts b/src/agent/mcpServer.ts index e6db7e6..c80f1be 100644 --- a/src/agent/mcpServer.ts +++ b/src/agent/mcpServer.ts @@ -17,11 +17,8 @@ */ import { createInterface } from "node:readline"; -import { - isProcessAlive, - readEndpoint, - readEndpointForProject, -} from "./endpoint"; +import { LiveClient } from "./liveClient"; +import { pruneInstances } from "./instances"; import { readSnapshotFile, snapshotPathForProject } from "./snapshot"; import { findAssets, @@ -53,6 +50,7 @@ Use these tools instead of full-text grepping the XML tree when you need exact f - is_file_active(path) -> whether a file is part of an indexed include stream - find_define(name) -> $DEFINE definitions - resolve_include(source) -> candidate source file +- list_projects() -> project roots the live extension has indexed - get_status() -> current index state Tips: @@ -228,6 +226,17 @@ const TOOLS: McpTool[] = [ "get_asset_references requires a live index. Open the project in VS Code (with AI Agent access enabled) and retry.", }), }, + { + name: "list_projects", + description: + "Lists the project roots the live extension currently has indexed. Use it to discover which projects this server can answer for.", + inputSchema: { type: "object", properties: {} }, + handler: () => ({ + index: { state: "no_index" }, + error: + "list_projects requires a live index. Open the project in VS Code with AI Agent access enabled, then retry.", + }), + }, { name: "get_usage_guide", description: "Returns guidance for using the RA3 Mod XML index tools.", @@ -237,142 +246,7 @@ const TOOLS: McpTool[] = [ ]; /** Tools that can only be answered by the live extension server. */ -const LIVE_ONLY_TOOLS = new Set(["get_asset_references"]); - -export function liveUrlForTool( - endpointUrl: string, - projectDir: string | null, - toolName: string, - args: Record, -): string | null { - const base = endpointUrl.replace(/\/$/, ""); - const q = new URLSearchParams(); - // Always pin the requested project. Without this the server would silently - // answer from whatever project its active editor points at. - if (projectDir) q.set("project", projectDir); - switch (toolName) { case "get_status": - return q.toString() ? `${base}/status?${q}` : `${base}/status`; - case "find_asset": - q.set("id", String(args.id ?? "")); - if (args.type != null) q.set("type", String(args.type)); - return `${base}/find_asset?${q}`; - case "find_references": - q.set("id", String(args.id ?? "")); - if (args.type != null) q.set("type", String(args.type)); - return `${base}/find_references?${q}`; - case "get_asset_references": - q.set("id", String(args.id ?? "")); - if (args.type != null) q.set("type", String(args.type)); - if (args.depth != null) q.set("depth", String(args.depth)); - if (Array.isArray(args.targetTypes)) { - q.set("targetTypes", (args.targetTypes as unknown[]).map(String).join(",")); - } - if (args.maxEdges != null) q.set("maxEdges", String(args.maxEdges)); - if (args.includeUnresolved != null) { - q.set("includeUnresolved", String(args.includeUnresolved)); - } - return `${base}/get_asset_references?${q}`; - case "list_assets_by_type": - q.set("type", String(args.type ?? "")); - if (args.prefix != null) q.set("prefix", String(args.prefix)); - if (args.limit != null) q.set("limit", String(args.limit)); - return `${base}/list_assets?${q}`; - case "is_file_active": - q.set("path", String(args.path ?? "")); - return `${base}/is_file_active?${q}`; - case "find_define": - q.set("name", String(args.name ?? "")); - return `${base}/find_define?${q}`; - case "resolve_include": - q.set("source", String(args.source ?? "")); - return `${base}/resolve_include?${q}`; - default: - return null; - } -} - -export function normalizePath(p: string): string { - return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); -} - -/** - * Rejects a live response that belongs to a different project than the one - * this MCP client was started for. This is the guard against the - * multi-window cross-talk described in docs/ai-agent-integration-plan.md §10: - * returning another project's data silently would be worse than returning - * nothing, because the agent cannot tell the difference. - */ -export function responseProjectMismatch( - payload: unknown, - projectDir: string | null, -): boolean { - if (!projectDir) return false; - const index = (payload as { index?: { projectDir?: string } } | null)?.index; - const reported = index?.projectDir; - if (!reported) return false; - return normalizePath(reported) !== normalizePath(projectDir); -} - -/** Cooldown after a failed live attempt, to avoid a probe per tool call. */ -const LIVE_RETRY_COOLDOWN_MS = 5000; -let liveUnavailableUntil = 0; - -interface LiveResult { - payload: unknown; - /** True when the live server answered but for a different project. */ - mismatched: boolean; -} - -/** Tries the live extension server; returns null when unavailable. */ -async function tryLiveQuery( - toolName: string, - args: Record, - projectDir: string | null, - agentHome?: string, -): Promise { - if (Date.now() < liveUnavailableUntil) return null; - // Prefer the per-project endpoint so two open windows cannot shadow each - // other; fall back to the legacy global file only when it matches. - let endpoint = projectDir - ? await readEndpointForProject(projectDir, agentHome) - : null; - if (!endpoint) { - const fallback = await readEndpoint(agentHome); - if ( - fallback && - (!projectDir || - !fallback.projectDir || - normalizePath(fallback.projectDir) === normalizePath(projectDir)) - ) { - endpoint = fallback; - } - } - if (!endpoint) return null; - // A crashed VS Code can leave the file behind; a dead PID means stale. - if (!isProcessAlive(endpoint.processId)) return null; - - const url = liveUrlForTool(endpoint.url, projectDir, toolName, args); - if (!url) return null; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 1500); - try { - const res = await fetch(url, { - headers: { authorization: `Bearer ${endpoint.token}` }, - signal: controller.signal, - }); - if (!res.ok) { - if (res.status === 401 || res.status === 404) return null; - return null; - } - const payload: unknown = await res.json(); - return { payload, mismatched: responseProjectMismatch(payload, projectDir) }; - } catch { - liveUnavailableUntil = Date.now() + LIVE_RETRY_COOLDOWN_MS; - return null; - } finally { - clearTimeout(timeout); - } -} +const LIVE_ONLY_TOOLS = new Set(["get_asset_references", "list_projects"]); function sendMessage(message: unknown): void { process.stdout.write(`${JSON.stringify(message)}\n`); @@ -390,7 +264,7 @@ async function handleRequest( message: Record, snapshot: AgentIndexSnapshot | null, projectDir: string | null, - agentHome?: string, + live: LiveClient, ): Promise { const method = String(message.method ?? ""); const id = message.id; @@ -418,12 +292,12 @@ async function handleRequest( const tool = TOOLS.find((t) => t.name === toolName); if (!tool) return errorFor(id, -32602, `Unknown tool: ${toolName}`); const args = (params.arguments ?? {}) as Record; - const live = await tryLiveQuery(toolName, args, projectDir, agentHome); + const result = await live.query(toolName, args); - if (live?.mismatched) { + if (result?.mismatched) { // The server answered for another project. Refuse it: a plausible // wrong answer is worse than an explicit failure. - liveUnavailableUntil = Date.now() + LIVE_RETRY_COOLDOWN_MS; + live.markUnavailable(); return textResult(id, { index: { state: "error", projectDir: projectDir ?? undefined }, error: `The live server answered for a different project than "${projectDir}"; refusing the result. Re-run "RA3 Mod XML: Enable AI Agent access…" for this project.`, @@ -435,14 +309,14 @@ async function handleRequest( // not conclude "this asset has no references". return textResult( id, - live?.payload ?? { + result?.payload ?? { index: { state: "no_index", projectDir: projectDir ?? undefined }, error: `"${toolName}" requires a live index. Open the project in VS Code with AI Agent access enabled, then retry.`, }, ); } - const output = live?.payload ?? tool.handler(args, snapshot); + const output = result?.payload ?? tool.handler(args, snapshot); return textResult(id, output); } default: @@ -474,6 +348,12 @@ async function main(): Promise { let snapshot: AgentIndexSnapshot | null = null; if (resolvedSnapshotPath) snapshot = await readSnapshotFile(resolvedSnapshotPath); + // One-shot crash cleanup: a window that died without disposing leaves its + // instance file behind, and whichever instance starts next prunes it. + void pruneInstances(agentHome).catch(() => undefined); + + const live = new LiveClient({ projectDir, agentHome }); + const rl = createInterface({ input: process.stdin, crlfDelay: Infinity, @@ -486,7 +366,7 @@ async function main(): Promise { } catch { return; } - void handleRequest(message, snapshot, projectDir, agentHome).then((response) => { + void handleRequest(message, snapshot, projectDir, live).then((response) => { if (response != null) sendMessage(response); }); }); diff --git a/src/agent/runtime.ts b/src/agent/runtime.ts new file mode 100644 index 0000000..e2a136d --- /dev/null +++ b/src/agent/runtime.ts @@ -0,0 +1,256 @@ +/** + * Runtime resolution for the agent launcher and CLI. + * + * The extension must not assume the user has Node installed. VS Code ships an + * Electron binary that can run as a plain Node process when launched with + * `ELECTRON_RUN_AS_NODE=1`, which makes it a zero-dependency runtime that is + * already present wherever the extension is installed. + * + * Verified on Windows with VS Code 1.135.0 / Electron 42.8.1 (Node 24.18.1): + * `fs`, `fs/promises`, `path`, `os`, `http`, `readline`, `zlib`, `crypto`, + * `util`, `net`, `child_process` and even `node:test` are all available, and + * the bundled `dist/agent/mcpServer.js` completes a full stdio MCP session. + * + * Pure TypeScript: no VS Code dependency, so the same rules are used by the + * launcher generator, the CLI and the tests. + */ + +import { existsSync } from "node:fs"; + +const isWin = process.platform === "win32"; +const isMac = process.platform === "darwin"; + +export type AgentRuntimeKind = "electron" | "node"; + +export interface AgentRuntime { + kind: AgentRuntimeKind; + /** + * Executable to spawn. For `electron` this is the VS Code / Electron binary + * (or a bare product name when only a hint is known). + */ + executable: string; + /** Extra environment variables required to run the executable as Node. */ + env: Record; + /** + * True when the executable is a bare command name resolved through PATH + * rather than an absolute path. + */ + viaPath: boolean; +} + +/** + * True when the current process is a VS Code / Electron host. + * + * Desktop VS Code runs its extension host as the Electron binary with + * `ELECTRON_RUN_AS_NODE=1`, so `process.versions.electron` is set and + * `process.execPath` points at the VS Code executable. That is exactly the + * binary the launcher wants. + */ +export function isElectronHost(): boolean { + return typeof process.versions.electron === "string"; +} + +/** + * Resolves the runtime to bake into the launcher. + * + * Returns the Electron runtime when running inside VS Code (the normal case), + * and falls back to a PATH-resolved `node` otherwise (e.g. when the launcher + * is generated from a plain Node CLI or a unit test). + */ +export function resolveRuntime(): AgentRuntime { + if (isElectronHost()) { + return { + kind: "electron", + executable: process.execPath, + env: { ELECTRON_RUN_AS_NODE: "1" }, + viaPath: false, + }; + } + return { kind: "node", executable: "node", env: {}, viaPath: true }; +} + +/** + * Builds the runtime descriptor for an explicit Electron/VSCode executable. + * Used when the extension knows the host path but is not itself running as + * Electron, and by tests. + */ +export function electronRuntime(executable: string): AgentRuntime { + return { + kind: "electron", + executable, + env: { ELECTRON_RUN_AS_NODE: "1" }, + viaPath: false, + }; +} + +/** + * Product names that indicate a VS Code-family Electron binary. + * + * Covers both the branded Windows/Linux launchers (`Code.exe`, `code-oss`, + * `codium`, `cursor`) and the macOS bundle executable, which is literally + * named `Electron`. + */ +const ELECTRON_PRODUCT_NAMES = [ + "code", + "code-insiders", + "codium", + "cursor", + "electron", +]; + +/** + * Best-effort candidates for a VS Code / Electron binary when running under a + * plain Node process. These are only fallbacks: the authoritative path always + * comes from `process.execPath` inside the extension host. + */ +export function electronExecutableCandidates(): string[] { + const candidates: string[] = []; + if (isWin && process.env.LOCALAPPDATA) { + candidates.push( + `${process.env.LOCALAPPDATA}\\Programs\\Microsoft VS Code\\Code.exe`, + `${process.env.LOCALAPPDATA}\\Programs\\Microsoft VS Code Insiders\\Code - Insiders.exe`, + ); + } + if (isWin && process.env.PROGRAMFILES) { + candidates.push(`${process.env.PROGRAMFILES}\\Microsoft VS Code\\Code.exe`); + } + if (isMac) { + candidates.push("/Applications/Visual Studio Code.app/Contents/MacOS/Electron"); + } else if (!isWin) { + candidates.push("/usr/share/code/code", "/usr/bin/code"); + } + return candidates; +} + +/** The first existing Electron candidate, or null. */ +export function findElectronExecutable(): string | null { + for (const candidate of electronExecutableCandidates()) { + try { + if (existsSync(candidate)) return candidate; + } catch { + // ignore + } + } + return null; +} + +/** Renders a path for a POSIX shell single-quoted string. */ +function shQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** Renders a path for a Windows `cmd.exe` double-quoted `set` value. */ +function cmdValue(value: string): string { + // `set "VAR=value"` keeps quotes out of the value; embedded `"` would break + // it, so strip them rather than emit a broken script. + return value.replace(/"/g, ""); +} + +export interface LauncherScriptOptions { + runtime: AgentRuntime; + /** Absolute path to the bundled MCP server entry. */ + serverPath: string; + /** Project root passed to the MCP server. */ + projectDir: string; + /** + * Optional absolute path to a `node` executable used when the Electron + * binary is missing. When omitted the script falls back to `node` on PATH. + */ + nodeFallback?: string | null; +} + +/** + * Generates the stable launcher script. + * + * The script prefers the Electron runtime (works without Node installed) and + * falls back to Node only when that binary is gone, so a moved/uninstalled + * VS Code does not leave the user with a dead launcher. + */ +export function launcherScript( + options: LauncherScriptOptions, + platform: NodeJS.Platform = process.platform, +): string { + const { runtime, serverPath, projectDir } = options; + const node = options.nodeFallback ?? "node"; + const paths = { serverPath: cmdValue(serverPath), projectDir: cmdValue(projectDir) }; + + if (platform === "win32") { + // A `goto` jump is used instead of a parenthesised `if (...)` block: + // inside such a block `%errorlevel%` is expanded when the whole block is + // parsed, not when each command runs, so the exit code would be wrong. + const lines = ["@echo off", "setlocal"]; + if (runtime.kind === "node") { + // No Electron available: the launcher is Node-only. + lines.push( + `set "RA3_NODE=${cmdValue(node)}"`, + `set "RA3_SERVER=${paths.serverPath}"`, + `set "RA3_PROJECT=${paths.projectDir}"`, + '"%RA3_NODE%" "%RA3_SERVER%" --project "%RA3_PROJECT%"', + "exit /b %errorlevel%", + ); + return lines.join("\r\n") + "\r\n"; + } + lines.push( + `set "RA3_RUNTIME=${cmdValue(runtime.executable)}"`, + `set "RA3_SERVER=${paths.serverPath}"`, + `set "RA3_PROJECT=${paths.projectDir}"`, + `set "RA3_NODE=${cmdValue(node)}"`, + 'if not exist "%RA3_RUNTIME%" goto :ra3_node', + "set ELECTRON_RUN_AS_NODE=1", + '"%RA3_RUNTIME%" "%RA3_SERVER%" --project "%RA3_PROJECT%"', + "exit /b %errorlevel%", + ":ra3_node", + '"%RA3_NODE%" "%RA3_SERVER%" --project "%RA3_PROJECT%"', + "exit /b %errorlevel%", + ); + return lines.join("\r\n") + "\r\n"; + } + + const lines = ["#!/usr/bin/env sh"]; + if (runtime.kind === "node") { + lines.push( + `exec ${shQuote(node)} ${shQuote(serverPath)} --project ${shQuote(projectDir)}`, + ); + } else { + lines.push( + `RA3_RUNTIME=${shQuote(runtime.executable)}`, + `RA3_SERVER=${shQuote(serverPath)}`, + `RA3_PROJECT=${shQuote(projectDir)}`, + 'if [ -x "$RA3_RUNTIME" ]; then', + ' ELECTRON_RUN_AS_NODE=1 exec "$RA3_RUNTIME" "$RA3_SERVER" --project "$RA3_PROJECT"', + "fi", + `exec ${shQuote(node)} "$RA3_SERVER" --project "$RA3_PROJECT"`, + ); + } + return lines.join("\n") + "\n"; +} + +/** + * True when the launcher is able to run without a Node installation: the + * Electron runtime is used unconditionally (no PATH lookup of `node`). + * + * Used by tests and by the enable flow to warn when the launcher would depend + * on Node being installed. + */ +export function isNodeFreeLauncher(script: string, platform: NodeJS.Platform = process.platform): boolean { + if (platform === "win32") { + return ( + script.includes("ELECTRON_RUN_AS_NODE=1") && + script.includes('set "RA3_RUNTIME=') && + // The Node path must only be reachable through the guard jump. + script.includes('if not exist "%RA3_RUNTIME%" goto :ra3_node') && + !/^\s*"node"\s/m.test(script) + ); + } + return ( + script.includes("ELECTRON_RUN_AS_NODE=1") && + script.includes("RA3_RUNTIME=") && + !/^exec node /m.test(script) + ); +} + +/** Product names that indicate a VS Code-family Electron binary. */ +export function looksLikeElectronExecutable(path: string): boolean { + const base = path.replace(/\\/g, "/").split("/").pop()?.toLowerCase() ?? ""; + return ELECTRON_PRODUCT_NAMES.some((name) => base.includes(name)); +} diff --git a/src/agent/setup.ts b/src/agent/setup.ts index 284f064..47185fe 100644 --- a/src/agent/setup.ts +++ b/src/agent/setup.ts @@ -13,6 +13,11 @@ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { defaultAgentHome } from "./snapshot"; +import { + launcherScript, + resolveRuntime, + type AgentRuntime, +} from "./runtime"; export interface McpConfigTarget { id: string; @@ -20,6 +25,18 @@ export interface McpConfigTarget { path: string; } +/** Result of writing the stable launcher. */ +export interface LauncherResult { + path: string; + serverPath: string; + runtime: AgentRuntime; + /** + * True when the launcher runs on the VS Code Electron runtime and therefore + * does not need a Node installation. + */ + nodeFree: boolean; +} + /** File name of the stable launcher on the current platform. */ export function launcherFileName(): string { return process.platform === "win32" ? "ra3-mod-xml-mcp.cmd" : "ra3-mod-xml-mcp"; @@ -39,34 +56,38 @@ export function bundledMcpServerPath(extensionRoot: string): string { } /** - * Creates the stable launcher script. It points to the current extension's - * bundled MCP server and passes the project directory. + * Creates the stable launcher script. It prefers the VS Code Electron runtime + * (so no Node installation is required) and falls back to Node only when that + * binary is missing. + * + * Returns the launcher path plus the resolved runtime, so callers can warn + * when the launcher will depend on Node being on PATH. */ export async function writeLauncher( extensionRoot: string, projectDir: string, agentHome = defaultAgentHome(), -): Promise { + runtime?: AgentRuntime, +): Promise { const server = bundledMcpServerPath(extensionRoot); const launcher = launcherPath(agentHome); + const resolved = runtime ?? resolveRuntime(); + const script = launcherScript({ + runtime: resolved, + serverPath: server, + projectDir, + }); await mkdir(dirname(launcher), { recursive: true }); - if (process.platform === "win32") { - const content = [ - "@echo off", - `node "${server}" --project "${projectDir}"`, - "", - ].join("\r\n"); - await writeFile(launcher, content, "utf8"); - } else { - const content = [ - "#!/usr/bin/env sh", - `exec node "${server}" --project "${projectDir}"`, - "", - ].join("\n"); - await writeFile(launcher, content, "utf8"); + await writeFile(launcher, script, "utf8"); + if (process.platform !== "win32") { await chmod(launcher, 0o755); } - return launcher; + return { + path: launcher, + serverPath: server, + runtime: resolved, + nodeFree: resolved.kind === "electron", + }; } /** MCP client config entry for one project. */ diff --git a/src/agent/skill.ts b/src/agent/skill.ts index 2af5e08..848c090 100644 --- a/src/agent/skill.ts +++ b/src/agent/skill.ts @@ -73,6 +73,46 @@ Use this skill when you need any of the following: Do not use full-text search over the XML tree when one of the MCP query tools can answer the question directly. +## Reaching the index + +Work down this list and stop at the first step that works. + +1. **The query tools are already in your tool list** (names like + \`find_asset\`, \`get_status\`). Use them directly. You do not need to + configure anything. + +2. **The tools are not available, but you can run commands.** The index is + reachable without any MCP setup, because the MCP server speaks JSON-RPC over + stdio. Read \`~/.ra3modxml/index.json\` first: it is a small, stable + discovery manifest listing the live instances and their project roots. + + - If it does not exist, the extension has never been enabled for this + project. Tell the user to open the project in VS Code and run + "RA3 Mod XML: Enable AI Agent access…". Do not guess or search further. + - If it exists, use the launcher at \`~/.ra3modxml/ra3-mod-xml-mcp.cmd\` + (Windows) or \`~/.ra3modxml/ra3-mod-xml-mcp\` (elsewhere), or call the + bundled server directly through that instance's runtime. Feed it one + JSON-RPC request per line on stdin, for example: + + \`\`\` + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"find_asset","arguments":{"id":"AthenaCannon","type":"GameObject"}}} + \`\`\` + + A CLI with the same capabilities is bundled alongside the MCP server + (\`cli.js\` next to \`mcpServer.js\` in the extension's \`dist/agent/\`). + Run it with the same runtime as the launcher; \`cli.js help\` lists the + commands. It answers from the live index when VS Code is running and + falls back to the last exported snapshot otherwise. Commands that need + element context (\`outgoing\`, \`projects\`) require the live index and will + say so explicitly instead of returning an empty result. + +3. **You can write configuration, if the user agrees.** You may add the MCP + server to the your own harness' configuration. + Check what your own client supports (e.g. if a restart is required) + before promising otherwise. + +If none of the steps work, say the index is unavailable and read the XML files directly. + ## How to use 1. Call \`get_status\` first when you are unsure whether an index is available, @@ -83,8 +123,7 @@ can answer the question directly. - \`get_asset_references(id, type?, depth?, targetTypes?)\` for outgoing references (what this asset uses, and where that link is written). - \`list_assets_by_type(type, prefix?, limit?)\` for browsing assets. - - \`is_file_active(path)\` to determine whether a file is included in an indexed stream. - - \`find_define(name)\` for $DEFINE constants. + - \`is_file_active(path)\` to determine whether a file is included in an indexed stream. - \`find_define(name)\` for $DEFINE constants. - \`resolve_include(source)\` for Include source candidates. 3. Asset ids are case-insensitive. 4. If an id exists for multiple asset types, pass the type filter to avoid mixing definitions. diff --git a/src/extension.ts b/src/extension.ts index e93dc3a..5ff2b23 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -54,6 +54,13 @@ import { writeEndpoint, writeEndpointForProject, } from "./agent/endpoint"; +import { + clearInstance, + makeInstanceId, + pruneInstances, + writeInstance, + writeManifest, +} from "./agent/instances"; const XML_SELECTOR: vscode.DocumentSelector = [{ language: "xml" }]; /** Safety-net refresh interval while a rebuild is running. */ @@ -159,9 +166,25 @@ export function activate(context: vscode.ExtensionContext): void { let agentLocalServer: LocalServerHandle | null = null; /** Project roots whose per-project endpoint file this window wrote. */ const agentEndpointProjects = new Set(); + /** + * This window's own instance id. Each window writes only its own file under + * `instances/`, which is what removes the need for locking/merging between + * concurrently running VS Code windows. + */ + const agentInstanceId = makeInstanceId(); const startAgentLocalServer = async (): Promise => { if (agentLocalServer) return; try { + // Clean up instances left behind by crashed windows. Any instance can do + // this, so a crash does not have to wait for the same workspace to be + // reopened before its stale entry disappears. + const pruned = await pruneInstances().catch(() => ({ removed: [], kept: [] })); + if (pruned.removed.length) { + ws.log( + `[agent] pruned ${pruned.removed.length} stale instance(s): ${pruned.removed.join(", ")}`, + ); + } + const handle = await startLocalServer({ // Route by explicit project so a query can never be answered by // whichever project the active editor happens to point at. @@ -177,6 +200,7 @@ export function activate(context: vscode.ExtensionContext): void { const url = `http://127.0.0.1:${handle.port}`; const projects = ws.getProjectRoots(); const endpoint = { + instanceId: agentInstanceId, url, token: handle.token, projectDir: ws.projectRoot ?? undefined, @@ -193,8 +217,12 @@ export function activate(context: vscode.ExtensionContext): void { agentEndpointProjects.add(project); ws.log(`[agent-local-server] endpoint for ${project} -> ${file}`); } + // This window's own instance file (authoritative for liveness/pruning). + const instanceFile = await writeInstance(endpoint); + ws.log(`[agent-local-server] instance -> ${instanceFile}`); // Legacy/global pointer for tooling that does not know the project. await writeEndpoint(endpoint); + await writeManifest(await refreshInstanceFiles(url, handle.token, projects)); ws.log(`[agent-local-server] listening on ${url}`); } catch (err) { ws.log( @@ -202,24 +230,54 @@ export function activate(context: vscode.ExtensionContext): void { ); } }; + /** + * Re-reads every live instance file (including other windows') and rewrites + * the merged discovery manifest. + */ + const refreshInstanceFiles = async ( + url: string, + token: string, + projects: string[], + ) => { + const instance = { + instanceId: agentInstanceId, + url, + token, + projectDir: ws.projectRoot ?? undefined, + projects, + processId: process.pid, + updatedAt: new Date().toISOString(), + }; + await writeInstance(instance).catch(() => undefined); + const { kept } = await pruneInstances().catch(() => ({ + removed: [], + kept: [] as typeof instance[], + })); + // Ensure this window is present even if its file was just pruned/written. + const others = kept.filter((k) => k.instanceId !== agentInstanceId); + return [...others, instance]; + }; const stopAgentLocalServer = async (): Promise => { if (agentLocalServer) { const server = agentLocalServer; agentLocalServer = null; await server.close().catch(() => undefined); } - // Only remove the endpoint files this window wrote: another VS Code - // window may still be serving its own projects. + // Only remove what this window owns: another VS Code window may still be + // serving its own projects. for (const project of agentEndpointProjects) { await clearEndpointForProject(project).catch(() => undefined); } agentEndpointProjects.clear(); + await clearInstance(agentInstanceId).catch(() => undefined); await clearEndpoint().catch(() => undefined); + await writeManifest([]).catch(() => undefined); }; /** - * Publishes per-project endpoint files for every project this window now + * Republishes this window's endpoint/instance files for every project it now * knows about. Called on each index update so projects discovered later get - * an endpoint without restarting the server. + * an endpoint without restarting the server, and so the merged manifest is + * refreshed. */ const refreshAgentEndpoints = async (): Promise => { if (!agentLocalServer) return; @@ -243,6 +301,8 @@ export function activate(context: vscode.ExtensionContext): void { ); } } + const instances = await refreshInstanceFiles(url, token, projects); + await writeManifest(instances); }; context.subscriptions.push({ dispose: () => { @@ -487,7 +547,17 @@ export function activate(context: vscode.ExtensionContext): void { context.extensionUri.fsPath, projectDir, ); - const configJson = mcpConfigJson(launcher, projectDir); + const configJson = mcpConfigJson(launcher.path, projectDir); + ws.log( + `[agent] launcher runtime=${launcher.runtime.kind} (${launcher.runtime.executable}), nodeFree=${launcher.nodeFree}`, + ); + if (!launcher.nodeFree) { + void vscode.window.showWarningMessage( + t( + "RA3 Mod XML: the AI Agent launcher will use Node from PATH. Install Node, or run VS Code from a normal installation so the bundled runtime can be used.", + ), + ); + } agentAccessEnabled = true; await context.workspaceState.update("ra3modxml.agentAccessEnabled", true); void startAgentLocalServer(); @@ -538,17 +608,17 @@ export function activate(context: vscode.ExtensionContext): void { t("RA3 Mod XML Agent Skill installed to {0}", target), ); } else if (pick?.id === "claude") { - await addMcpServerToConfigFile(claudeDesktopConfigPath(), launcher, projectDir); + await addMcpServerToConfigFile(claudeDesktopConfigPath(), launcher.path, projectDir); void vscode.window.showInformationMessage( t("RA3 Mod XML MCP config written to {0}", claudeDesktopConfigPath()), ); } else if (pick?.id === "cursor-global") { - await addMcpServerToConfigFile(cursorGlobalConfigPath(), launcher, projectDir); + await addMcpServerToConfigFile(cursorGlobalConfigPath(), launcher.path, projectDir); void vscode.window.showInformationMessage( t("RA3 Mod XML MCP config written to {0}", cursorGlobalConfigPath()), ); } else if (pick?.id === "cursor-project") { - await addMcpServerToConfigFile(cursorProjectConfigPath(projectDir), launcher, projectDir); + await addMcpServerToConfigFile(cursorProjectConfigPath(projectDir), launcher.path, projectDir); void vscode.window.showInformationMessage( t("RA3 Mod XML MCP config written to {0}", cursorProjectConfigPath(projectDir)), ); @@ -638,11 +708,43 @@ export function activate(context: vscode.ExtensionContext): void { ), ); - if (agentAccessEnabled) void startAgentLocalServer(); + /** + * Refreshes the stable launcher for every known project. Called on + * activation for already-enabled workspaces so a VS Code update (or an + * extension update) repoints the launcher at the current runtime without + * the user having to re-run the enable command. + */ + const refreshLaunchers = async (): Promise => { + const projects = ws.getProjectRoots(); + if (!projects.length) return; + for (const project of projects) { + try { + const launcher = await writeLauncher( + context.extensionUri.fsPath, + project, + ); + ws.log( + `[agent] refreshed launcher for ${project}: runtime=${launcher.runtime.kind}, nodeFree=${launcher.nodeFree}`, + ); + } catch (err) { + ws.log( + `[agent] could not refresh launcher for ${project}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + }; + + if (agentAccessEnabled) { + void startAgentLocalServer(); + void refreshLaunchers(); + } void sdkSetup.evaluate(ws); void ws.initialize().then(() => { void sdkSetup.evaluate(ws); - if (agentAccessEnabled) void startAgentLocalServer(); + if (agentAccessEnabled) { + void startAgentLocalServer(); + void refreshLaunchers(); + } }); } diff --git a/test/agentCli.test.mjs b/test/agentCli.test.mjs new file mode 100644 index 0000000..49c751b --- /dev/null +++ b/test/agentCli.test.mjs @@ -0,0 +1,187 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + LIVE_ONLY, + liveArgsFor, + parseArgs, + toolNameFor, +} from "../out/agent/cli.js"; + +// ── Argument parsing and command mapping (pure) ─────────────────────── + +test("parseArgs reads the project, snapshot and command", () => { + const options = parseArgs(["--project", "D:/Mods/Alpha", "find", "AthenaCannon", "GameObject"]); + assert.equal(options.projectDir, "D:/Mods/Alpha"); + assert.equal(options.command, "find"); + assert.deepEqual(options.args, ["AthenaCannon", "GameObject"]); +}); + +test("parseArgs ignores unknown flags without failing the call", () => { + // An agent probing with a flag we do not know yet must not crash the CLI. + const withCommand = parseArgs(["--json", "status"]); + assert.equal(withCommand.command, "status"); + const trailing = parseArgs(["find", "X", "--future-flag"]); + assert.equal(trailing.command, "find"); + assert.deepEqual(trailing.args, ["X"]); + // A stray positional is still treated as the command name. + assert.equal(parseArgs([]).command, "status"); +}); + +test("parseArgs reads outgoing-specific options", () => { + const options = parseArgs([ + "outgoing", "AthenaCannon", "GameObject", + "--depth", "2", + "--target-types", "WeaponTemplate, GameObject", + "--max-edges", "25", + "--include-unresolved", + ]); + assert.equal(options.command, "outgoing"); + assert.equal(options.depth, 2); + assert.deepEqual(options.targetTypes, ["WeaponTemplate", "GameObject"]); + assert.equal(options.maxEdges, 25); + assert.equal(options.includeUnresolved, true); +}); + +test("toolNameFor maps every CLI command to a live tool", () => { + assert.equal(toolNameFor("find"), "find_asset"); + assert.equal(toolNameFor("refs"), "find_references"); + assert.equal(toolNameFor("outgoing"), "get_asset_references"); + assert.equal(toolNameFor("list"), "list_assets_by_type"); + assert.equal(toolNameFor("active"), "is_file_active"); + assert.equal(toolNameFor("define"), "find_define"); + assert.equal(toolNameFor("resolve"), "resolve_include"); + assert.equal(toolNameFor("projects"), "list_projects"); + assert.equal(toolNameFor("status"), "get_status"); +}); + +test("LIVE_ONLY covers exactly the commands needing DOM/project context", () => { + assert.equal(LIVE_ONLY.has("outgoing"), true); + assert.equal(LIVE_ONLY.has("projects"), true); + assert.equal(LIVE_ONLY.has("find"), false); + assert.equal(LIVE_ONLY.has("status"), false); +}); + +test("liveArgsFor builds the right payload per command", () => { + assert.deepEqual( + liveArgsFor({ command: "find", args: ["X", "GameObject"] }), + { id: "X", type: "GameObject" }, + ); + assert.deepEqual( + liveArgsFor({ command: "active", args: ["D:/f.xml"] }), + { path: "D:/f.xml" }, + ); + // Outgoing omits unset options so the server applies its own defaults. + const outgoing = liveArgsFor({ command: "outgoing", args: ["X"] }); + assert.equal(outgoing.id, "X"); + assert.equal(outgoing.depth, undefined); + assert.equal(outgoing.targetTypes, undefined); + assert.equal(outgoing.maxEdges, undefined); + // An explicit depth of 0 is falsy but must still be forwarded. + const zero = liveArgsFor({ command: "outgoing", args: ["X"], depth: 0 }); + assert.equal(zero.depth, 0); +}); + +// ── Project inference from the current directory ────────────────────── + +function makeModProject() { + const root = mkdtempSync(join(tmpdir(), "ra3-cli-proj-")); + mkdirSync(join(root, "Data"), { recursive: true }); + writeFileSync(join(root, "Data", "Mod.xml"), ""); + return root; +} + +test("the CLI finds the project root by walking up from the cwd", async () => { + const root = makeModProject(); + const nested = join(root, "Data", "Allied", "Units"); + mkdirSync(nested, { recursive: true }); + const previous = process.cwd(); + try { + process.chdir(nested); + const options = parseArgs([]); + const resolved = (await import("../out/agent/cli.js")).resolveProjectDir(options); + assert.equal(resolved?.toLowerCase(), root.toLowerCase()); + } finally { + process.chdir(previous); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("resolveProjectDir prefers an explicit --project over the cwd", async () => { + const { resolveProjectDir } = await import("../out/agent/cli.js"); + const options = parseArgs(["--project", "D:/Somewhere/Else", "status"]); + assert.equal(resolveProjectDir(options), join("D:\\Somewhere\\Else").replace(/\\/g, "\\")); +}); + +// ── End-to-end execution (skipped when the sandbox forbids spawning) ── + +function canSpawnShell() { + try { + if (process.platform === "win32") { + execFileSync("cmd.exe", ["/d", "/c", "exit 0"], { stdio: "ignore" }); + } else { + execFileSync("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" }); + } + return true; + } catch { + return false; + } +} + +const spawnable = canSpawnShell(); +const cliPath = join(process.cwd(), "dist", "agent", "cli.js"); + +test("CLI exits 3 with a clear message outside a project", { skip: !spawnable || !existsSync(cliPath) }, (t) => { + const empty = mkdtempSync(join(tmpdir(), "ra3-cli-empty-")); + try { + let code = 0; + let stderr = ""; + try { + execFileSync(process.execPath, [cliPath, "status"], { + cwd: empty, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (err) { + if (err?.code === "EPERM") { + t.skip("sandbox forbids spawning"); + return; + } + code = err.status; + stderr = err.stderr ?? ""; + } + assert.equal(code, 3); + assert.match(stderr, /No project found/); + } finally { + rmSync(empty, { recursive: true, force: true }); + } +}); + +test("CLI reports a live-only command as unavailable instead of empty", { skip: !spawnable || !existsSync(cliPath) }, (t) => { + const empty = mkdtempSync(join(tmpdir(), "ra3-cli-liveonly-")); + try { + let stdout = ""; + try { + stdout = execFileSync(process.execPath, [cliPath, "--project", "D:/Mods/Nope", "outgoing", "X"], { + cwd: empty, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (err) { + if (err?.code === "EPERM") { + t.skip("sandbox forbids spawning"); + return; + } + stdout = err.stdout ?? ""; + } + const payload = JSON.parse(stdout); + assert.equal(payload.source, "unavailable"); + // Must explain why, never look like "this asset has no references". + assert.match(payload.error, /requires a live index/); + } finally { + rmSync(empty, { recursive: true, force: true }); + } +}); diff --git a/test/agentInstances.test.mjs b/test/agentInstances.test.mjs new file mode 100644 index 0000000..b47f765 --- /dev/null +++ b/test/agentInstances.test.mjs @@ -0,0 +1,168 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + INSTANCE_SCHEMA_VERSION, + clearInstance, + instanceFileName, + instancesDir, + makeInstanceId, + manifestPath, + projectsOf, + pruneInstances, + readInstances, + readManifest, + writeInstance, + writeManifest, +} from "../out/agent/instances.js"; + +function instanceHome() { + return mkdtempSync(join(tmpdir(), "ra3-instances-")); +} + +function makeInstance(id, pid = process.pid) { + return { + instanceId: id, + url: `http://127.0.0.1:${10000 + (pid % 1000)}`, + token: `tok-${id}`, + projectDir: "D:/Mods/Alpha", + projects: ["D:/Mods/Alpha"], + processId: pid, + }; +} + +test("instances are written and read back", async () => { + const home = instanceHome(); + try { + const file = await writeInstance(makeInstance("a-1"), home); + assert.ok(existsSync(file)); + assert.ok(file.includes(instancesDir(home))); + assert.equal(file.endsWith(instanceFileName("a-1")), true); + + const all = await readInstances(home); + assert.equal(all.length, 1); + assert.equal(all[0].instanceId, "a-1"); + assert.equal(all[0].token, "tok-a-1"); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("concurrent windows do not overwrite each other", async () => { + const home = instanceHome(); + try { + // Two windows enabling agent access at the same time: separate files, so + // there is no read-modify-write race to guard. + await writeInstance(makeInstance("win-a"), home); + await writeInstance(makeInstance("win-b"), home); + const ids = (await readInstances(home)).map((i) => i.instanceId).sort(); + assert.deepEqual(ids, ["win-a", "win-b"]); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("pruneInstances removes entries whose PID is dead", async () => { + const home = instanceHome(); + try { + // 0x7fffffff is not a valid Windows PID, so it cannot be alive. + await writeInstance(makeInstance("alive", process.pid), home); + await writeInstance(makeInstance("dead", 0x7fffffff), home); + + const result = await pruneInstances(home); + assert.deepEqual(result.removed, ["dead"]); + assert.deepEqual( + result.kept.map((i) => i.instanceId), + ["alive"], + ); + // The dead file must actually be gone from disk. + assert.equal(existsSync(join(instancesDir(home), instanceFileName("dead"))), false); + assert.equal((await readInstances(home)).length, 1); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("pruneInstances keeps entries with an unknown PID", async () => { + const home = instanceHome(); + try { + // An older/simpler instance file without processId must never be pruned. + await writeInstance( + { instanceId: "no-pid", url: "http://127.0.0.1:1", token: "t" }, + home, + ); + const result = await pruneInstances(home); + assert.deepEqual(result.removed, []); + assert.equal(result.kept.length, 1); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("clearInstance only removes its own file", async () => { + const home = instanceHome(); + try { + await writeInstance(makeInstance("mine"), home); + await writeInstance(makeInstance("theirs"), home); + await clearInstance("mine", home); + const ids = (await readInstances(home)).map((i) => i.instanceId); + assert.deepEqual(ids, ["theirs"]); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("readInstances tolerates corrupt and unrelated files", async () => { + const home = instanceHome(); + try { + await writeInstance(makeInstance("good"), home); + writeFileSync(join(instancesDir(home), "broken.json"), "{ not json"); + writeFileSync(join(instancesDir(home), "notes.txt"), "ignore me"); + const all = await readInstances(home); + assert.deepEqual(all.map((i) => i.instanceId), ["good"]); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("projectsOf unions projects across instances without duplicates", () => { + const projects = projectsOf([ + { instanceId: "a", url: "u", token: "t", projectDir: "D:/Mods/Alpha", projects: ["D:/Mods/Alpha"] }, + { instanceId: "b", url: "u", token: "t", projectDir: "D:/Mods/Beta", projects: ["D:/Mods/beta", "D:/Mods/Gamma"] }, + ]); + // "beta" appears twice with different casing and must collapse to one entry, + // keeping the first spelling seen. + assert.deepEqual(projects, ["D:/Mods/Alpha", "D:/Mods/beta", "D:/Mods/Gamma"]); +}); + +test("writeManifest produces a discovery manifest without tokens", async () => { + const home = instanceHome(); + try { + const manifest = await writeManifest( + [makeInstance("a-1"), makeInstance("b-2")], + home, + ); + assert.equal(manifest.schemaVersion, INSTANCE_SCHEMA_VERSION); + assert.deepEqual(manifest.projects, ["D:/Mods/Alpha"]); + assert.equal(manifest.instances.length, 2); + + const raw = readFileSync(manifestPath(home), "utf8"); + // The manifest is for discovery; secrets must not leak into it. + assert.equal(raw.includes("tok-a-1"), false); + assert.equal(raw.includes('"token"'), false); + + const reread = await readManifest(home); + assert.equal(reread?.instances.length, 2); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("makeInstanceId is unique across rapid calls", () => { + const ids = new Set(); + for (let i = 0; i < 200; i++) ids.add(makeInstanceId(1234)); + assert.equal(ids.size, 200); + for (const id of ids) assert.ok(id.startsWith("1234-"), id); +}); diff --git a/test/agentLiveClient.test.mjs b/test/agentLiveClient.test.mjs new file mode 100644 index 0000000..e6568c4 --- /dev/null +++ b/test/agentLiveClient.test.mjs @@ -0,0 +1,205 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + LiveClient, + findEndpoint, + liveUrlForTool, + normalizePath, + queryLive, + responseProjectMismatch, +} from "../out/agent/liveClient.js"; +import { writeInstance } from "../out/agent/instances.js"; +import { writeEndpoint, writeEndpointForProject } from "../out/agent/endpoint.js"; + +const PROJECT_A = "D:/Mods/Alpha"; +const PROJECT_B = "D:/Mods/Beta"; + +function home() { + return mkdtempSync(join(tmpdir(), "ra3-liveclient-")); +} + +test("live URLs pin the requested project for every tool", () => { + const cases = [ + ["get_status", {}], + ["find_asset", { id: "X" }], + ["find_references", { id: "X" }], + ["list_assets_by_type", { type: "GameObject" }], + ["is_file_active", { path: "D:/f.xml" }], + ["find_define", { name: "D" }], + ["resolve_include", { source: "DATA:a.xml" }], + ["list_projects", {}], + ]; + for (const [tool, args] of cases) { + const url = liveUrlForTool("http://127.0.0.1:1234", PROJECT_A, tool, args); + assert.ok(url, `${tool} should have a live URL`); + assert.equal( + new URL(url).searchParams.get("project"), + PROJECT_A, + `${tool} must pin the project`, + ); + } +}); + +test("get_asset_references serialises all of its options", () => { + const url = liveUrlForTool("http://127.0.0.1:1234", PROJECT_A, "get_asset_references", { + id: "AthenaCannon", + type: "GameObject", + depth: 2, + targetTypes: ["WeaponTemplate", "GameObject"], + maxEdges: 25, + includeUnresolved: true, + }); + const q = new URL(url).searchParams; + assert.equal(q.get("id"), "AthenaCannon"); + assert.equal(q.get("type"), "GameObject"); + assert.equal(q.get("depth"), "2"); + assert.equal(q.get("targetTypes"), "WeaponTemplate,GameObject"); + assert.equal(q.get("maxEdges"), "25"); + assert.equal(q.get("includeUnresolved"), "true"); + assert.equal(q.get("project"), PROJECT_A); +}); + +test("unknown tools have no live URL", () => { + assert.equal(liveUrlForTool("http://127.0.0.1:1", PROJECT_A, "not_a_tool", {}), null); +}); + +test("responseProjectMismatch refuses another project's answer", () => { + assert.equal( + responseProjectMismatch({ index: { projectDir: "d:/mods/alpha" } }, PROJECT_A), + false, + ); + assert.equal( + responseProjectMismatch({ index: { projectDir: PROJECT_B } }, PROJECT_A), + true, + ); + // Nothing to compare against: not a mismatch. + assert.equal(responseProjectMismatch({ index: { state: "ready" } }, PROJECT_A), false); + assert.equal(responseProjectMismatch({ index: { projectDir: PROJECT_B } }, null), false); +}); + +test("normalizePath ignores case and trailing separators", () => { + assert.equal(normalizePath("D:\\Mods\\Alpha\\"), normalizePath("d:/mods/alpha")); +}); + +test("findEndpoint prefers the per-project endpoint", async () => { + const h = home(); + try { + await writeEndpointForProject( + PROJECT_A, + { url: "http://127.0.0.1:1111", token: "tok-a", processId: process.pid }, + h, + ); + const endpoint = await findEndpoint({ projectDir: PROJECT_A, agentHome: h }); + assert.equal(endpoint?.url, "http://127.0.0.1:1111"); + } finally { + rmSync(h, { recursive: true, force: true }); + } +}); + +test("findEndpoint finds a live instance when no per-project file exists", async () => { + const h = home(); + try { + // A window that has not yet written per-project endpoints, only its own + // instance file. Discovery must still find it. + await writeInstance( + { + instanceId: "w1", + url: "http://127.0.0.1:2222", + token: "tok-inst", + projects: [PROJECT_A, PROJECT_B], + processId: process.pid, + }, + h, + ); + const endpoint = await findEndpoint({ projectDir: PROJECT_B, agentHome: h }); + assert.equal(endpoint?.url, "http://127.0.0.1:2222"); + } finally { + rmSync(h, { recursive: true, force: true }); + } +}); + +test("findEndpoint ignores instances that do not serve the project", async () => { + const h = home(); + try { + await writeInstance( + { + instanceId: "other", + url: "http://127.0.0.1:3333", + token: "tok", + projects: ["D:/Mods/Unrelated"], + processId: process.pid, + }, + h, + ); + assert.equal(await findEndpoint({ projectDir: PROJECT_A, agentHome: h }), null); + } finally { + rmSync(h, { recursive: true, force: true }); + } +}); + +test("findEndpoint ignores dead instances", async () => { + const h = home(); + try { + await writeInstance( + { + instanceId: "dead", + url: "http://127.0.0.1:4444", + token: "tok", + projects: [PROJECT_A], + processId: 0x7fffffff, + }, + h, + ); + assert.equal(await findEndpoint({ projectDir: PROJECT_A, agentHome: h }), null); + } finally { + rmSync(h, { recursive: true, force: true }); + } +}); + +test("findEndpoint does not use a global endpoint recording another project", async () => { + const h = home(); + try { + await writeEndpoint( + { url: "http://127.0.0.1:5555", token: "tok", projectDir: PROJECT_B, processId: process.pid }, + h, + ); + assert.equal(await findEndpoint({ projectDir: PROJECT_A, agentHome: h }), null); + // It is still usable when asked for its own project. + assert.ok(await findEndpoint({ projectDir: PROJECT_B, agentHome: h })); + } finally { + rmSync(h, { recursive: true, force: true }); + } +}); + +test("a query with no reachable live instance returns null", async () => { + const h = home(); + try { + assert.equal( + await queryLive("get_status", {}, { projectDir: PROJECT_A, agentHome: h }), + null, + ); + } finally { + rmSync(h, { recursive: true, force: true }); + } +}); + +test("the negative cache suppresses repeated attempts and can be reset", async () => { + let now = 1_000_000; + const client = new LiveClient({ projectDir: PROJECT_A, now: () => now }); + assert.equal(client.suppressed, false); + client.markUnavailable(); + assert.equal(client.suppressed, true); + // Still suppressed just before the cooldown expires. + now += 4999; + assert.equal(client.suppressed, true); + // Expired afterwards. + now += 2; + assert.equal(client.suppressed, false); + + client.markUnavailable(); + client.reset(); + assert.equal(client.suppressed, false); +}); diff --git a/test/agentLiveE2E.test.mjs b/test/agentLiveE2E.test.mjs new file mode 100644 index 0000000..ad0c575 --- /dev/null +++ b/test/agentLiveE2E.test.mjs @@ -0,0 +1,297 @@ +/** + * End-to-end live-path test. + * + * Starts a real local HTTP live server (the same module the extension runs), + * registers it exactly the way the extension does (per-project endpoint + + * instance file + merged manifest), and then drives it through the shared + * `LiveClient` that both the MCP server and the CLI use. + * + * This is the in-process equivalent of "start the extension, then query it + * from the CLI/MCP", so it covers the transport, project pinning, project + * resolution and cross-project refusal without needing to spawn anything. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { startLocalServer } from "../out/agent/localServer.js"; +import { + clearInstance, + readManifest, + pruneInstances, + writeInstance, + writeManifest, +} from "../out/agent/instances.js"; +import { writeEndpointForProject } from "../out/agent/endpoint.js"; +import { LiveClient, findEndpoint } from "../out/agent/liveClient.js"; +import { parseLoadedXml } from "../out/agent/forwardRefs.js"; + +const PROJECT_A = "D:/Mods/Alpha"; +const PROJECT_B = "D:/Mods/Beta"; + +function def(type, id, file, line) { + return { type, id, file, line, origin: "project", stream: "static" }; +} + +const UNIT_FILE = `${PROJECT_A}/Data/AthenaCannon.xml`; +const BASE_FILE = `${PROJECT_A}/Data/BaseCannon.xml`; +const UNIT = def("GameObject", "AthenaCannon", UNIT_FILE, 2); +const BASE = def("GameObject", "BaseCannon", BASE_FILE, 2); +const WEAPON = def("WeaponTemplate", "AthenaCannonWeapon", `${PROJECT_A}/Data/Weapon.xml`, 88); + +const ATHENA_XML = ` + + AthenaCannon_Die + +`; + +const BASE_XML = ` + + + +`; + +const XML_FILES = { + [UNIT_FILE]: ATHENA_XML, + [BASE_FILE]: BASE_XML, +}; + +function makeIndex(projectDir) { + const assets = new Map([ + ["GameObject", new Map([["athenacannon", [UNIT]], ["basecannon", [BASE]]])], + ["WeaponTemplate", new Map([["athenacannonweapon", [WEAPON]]])], + ]); + return { + projectDir, + sdkDir: "", + complete: true, + phase: "art", + stale: false, + assets, + assetsById: new Map([ + ["athenacannon", [UNIT]], + ["basecannon", [BASE]], + ["athenacannonweapon", [WEAPON]], + ]), + defines: new Map([ + ["d", [{ name: "D", value: "1", file: UNIT_FILE, line: 1, origin: "project" }]], + ]), + files: new Map(), + streams: [ + { + name: "static", + entry: `${projectDir}/Data/Mod.xml`, + files: new Set([UNIT_FILE.toLowerCase().replace(/\\/g, "/")]), + }, + ], + manifests: new Map(), + sourceCandidates: [], + diagnostics: [], + references: new Map([ + [ + `GameObject\u0000athenacannon\u0000${UNIT_FILE}\u00002`, + [{ file: `${projectDir}/Data/Other.xml`, line: 3, start: 1, end: 2, kind: "attr" }], + ], + ]), + recordsHashes: new Map(), + stats: { + projectDir, sdkDir: "", phase: "art", complete: true, + indexedFiles: 2, parsedFiles: 2, shallowScannedFiles: 0, deferredArtFiles: 0, + shallowCacheHits: 0, recordsCacheHits: 0, resolveCacheHits: 0, resolveCalls: 0, + snapshotHits: 0, snapshotFallbacks: 0, candidatesMs: 0, walkMs: 0, artScanMs: 0, + assetCount: 3, referenceCount: 1, defineCount: 1, manifestFiles: 0, + manifestAssetCount: 0, streams: 1, sourceCandidates: 0, elapsedMs: 1, + }, + }; +} + +/** Boots a live server + registration files, and returns a cleanup handle. */ +async function bootLive() { + const agentHome = mkdtempSync(join(tmpdir(), "ra3-e2e-")); + const handle = await startLocalServer({ + getIndex: (projectDir) => + !projectDir || projectDir === PROJECT_A ? makeIndex(PROJECT_A) : null, + listProjects: () => [PROJECT_A, PROJECT_B], + loadFile: async (file) => { + const text = XML_FILES[file]; + return text ? parseLoadedXml(text) : null; + }, + }); + const endpoint = { + instanceId: "e2e-1", + url: `http://127.0.0.1:${handle.port}`, + token: handle.token, + projectDir: PROJECT_A, + projects: [PROJECT_A, PROJECT_B], + processId: process.pid, + updatedAt: new Date().toISOString(), + }; + await writeEndpointForProject(PROJECT_A, { ...endpoint, projectDir: PROJECT_A }, agentHome); + await writeInstance(endpoint, agentHome); + await writeManifest([endpoint], agentHome); + return { + agentHome, + endpoint, + async close() { + await handle.close(); + rmSync(agentHome, { recursive: true, force: true }); + }, + }; +} + +test("LiveClient reaches a real live server and answers every snapshot tool", async () => { + const live = await bootLive(); + try { + const client = new LiveClient({ projectDir: PROJECT_A, agentHome: live.agentHome }); + + const status = await client.query("get_status"); + assert.equal(status?.mismatched, false); + assert.equal(status.payload.state, "ready"); + assert.equal(status.payload.projectDir, PROJECT_A); + + const found = await client.query("find_asset", { id: "AthenaCannon", type: "GameObject" }); + assert.equal(found.payload.data.length, 1); + + const refs = await client.query("find_references", { id: "AthenaCannon" }); + assert.equal(refs.payload.data.length, 1); + + const active = await client.query("is_file_active", { path: UNIT_FILE }); + assert.equal(active.payload.data.active, true); + + const define = await client.query("find_define", { name: "D" }); + assert.equal(define.payload.data.length, 1); + + const list = await client.query("list_assets_by_type", { type: "GameObject" }); + assert.equal(list.payload.data.length, 2); + } finally { + await live.close(); + } +}); + +test("the live path answers get_asset_references with element provenance", async () => { + const live = await bootLive(); + try { + const client = new LiveClient({ projectDir: PROJECT_A, agentHome: live.agentHome }); + const result = await client.query("get_asset_references", { + id: "AthenaCannon", + type: "GameObject", + targetTypes: ["WeaponTemplate"], + }); + const data = result.payload.data; + assert.ok(data, "expected edge data"); + + // The weapon is written in BaseCannon's XML, reached through inheritFrom. + const weapon = data.edges.find((e) => e.to?.id === "AthenaCannonWeapon"); + assert.ok(weapon, "expected the inherited weapon edge"); + assert.equal(weapon.via.element, "Weapon"); + assert.equal(weapon.via.parent, "WeaponSlotHardpoint"); + assert.equal(weapon.definedIn.id, "BaseCannon"); + assert.equal(weapon.source.file, BASE_FILE); + } finally { + await live.close(); + } +}); + +test("list_projects reports the live project roots", async () => { + const live = await bootLive(); + try { + const client = new LiveClient({ projectDir: PROJECT_A, agentHome: live.agentHome }); + const result = await client.query("list_projects"); + assert.deepEqual(result.payload.data, [PROJECT_A, PROJECT_B]); + } finally { + await live.close(); + } +}); + +test("a client for an unknown project cannot use another project's answer", async () => { + const live = await bootLive(); + try { + // The registered instance only serves PROJECT_A/PROJECT_B, so a client + // pinned to a third project must find nothing at all rather than fall + // back to PROJECT_A's data. + const outsider = new LiveClient({ + projectDir: "D:/Mods/Unrelated", + agentHome: live.agentHome, + }); + assert.equal(await outsider.query("find_asset", { id: "AthenaCannon" }), null); + } finally { + await live.close(); + } +}); + +test("discovery works through the instance file alone (no per-project endpoint)", async () => { + const live = await bootLive(); + try { + // Simulate a window that registered its instance but whose per-project + // endpoint has not been written yet. + rmSync(join(live.agentHome, "endpoints"), { recursive: true, force: true }); + const endpoint = await findEndpoint({ + projectDir: PROJECT_A, + agentHome: live.agentHome, + }); + assert.equal(endpoint?.instanceId, "e2e-1"); + } finally { + await live.close(); + } +}); + +test("the merged manifest lists projects for discovery", async () => { + const live = await bootLive(); + try { + const manifest = await readManifest(live.agentHome); + assert.ok(manifest); + assert.deepEqual(new Set(manifest.projects), new Set([PROJECT_A, PROJECT_B])); + assert.equal(manifest.instances.length, 1); + } finally { + await live.close(); + } +}); + +test("a crashed instance is pruned by a later instance and then unreachable", async () => { + const live = await bootLive(); + try { + // Add a second instance that looks crashed. + await writeInstance( + { + instanceId: "dead-window", + url: "http://127.0.0.1:1", + token: "t", + projects: [PROJECT_A], + processId: 0x7fffffff, + }, + live.agentHome, + ); + const pruned = await pruneInstances(live.agentHome); + assert.ok(pruned.removed.includes("dead-window")); + // The surviving instance still works after the prune. + const client = new LiveClient({ projectDir: PROJECT_A, agentHome: live.agentHome }); + const status = await client.query("get_status"); + assert.equal(status.payload.state, "ready"); + } finally { + await live.close(); + } +}); + +test("clearing this window's instance leaves other windows untouched", async () => { + const live = await bootLive(); + try { + await writeInstance( + { + instanceId: "other-window", + url: "http://127.0.0.1:9999", + token: "t2", + projects: [PROJECT_A], + processId: process.pid, + }, + live.agentHome, + ); + await clearInstance("e2e-1", live.agentHome); + const { readInstances } = await import("../out/agent/instances.js"); + const ids = (await readInstances(live.agentHome)).map((i) => i.instanceId); + assert.deepEqual(ids, ["other-window"]); + } finally { + await live.close(); + } +}); diff --git a/test/agentMcpRouting.test.mjs b/test/agentMcpRouting.test.mjs index b485956..f013638 100644 --- a/test/agentMcpRouting.test.mjs +++ b/test/agentMcpRouting.test.mjs @@ -1,73 +1,22 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { - liveUrlForTool, - normalizePath, - responseProjectMismatch, -} from "../out/agent/mcpServer.js"; -const PROJECT_A = "D:/Mods/Example"; +// The URL building, project pinning and cross-project refusal now live in +// `liveClient` and are covered by agentLiveClient.test.mjs. What remains +// MCP-layer-specific is that the server module is *importable*: it must not +// start its stdio loop as a side effect of being imported, otherwise tests and +// any tool that merely inspects the module would hang waiting on stdin. -test("live URLs always pin the requested project", () => { - const url = liveUrlForTool("http://127.0.0.1:1234", PROJECT_A, "find_asset", { - id: "AthenaCannon", - type: "GameObject", - }); - assert.ok(url); - const parsed = new URL(url); - assert.equal(parsed.pathname, "/find_asset"); - assert.equal(parsed.searchParams.get("project"), PROJECT_A); - assert.equal(parsed.searchParams.get("id"), "AthenaCannon"); - assert.equal(parsed.searchParams.get("type"), "GameObject"); +test("the MCP server module imports without starting its stdio loop", async () => { + const mod = await import("../out/agent/mcpServer.js"); + assert.equal(typeof mod, "object"); + // Reaching this line proves main() did not run on import. }); -test("get_asset_references serialises its list and scalar options", () => { - const url = liveUrlForTool("http://127.0.0.1:1234/", PROJECT_A, "get_asset_references", { - id: "AthenaCannon", - depth: 2, - targetTypes: ["WeaponTemplate", "GameObject"], - maxEdges: 25, - includeUnresolved: true, - }); - assert.ok(url); - const parsed = new URL(url); - assert.equal(parsed.pathname, "/get_asset_references"); - assert.equal(parsed.searchParams.get("depth"), "2"); - assert.equal(parsed.searchParams.get("targetTypes"), "WeaponTemplate,GameObject"); - assert.equal(parsed.searchParams.get("maxEdges"), "25"); - assert.equal(parsed.searchParams.get("includeUnresolved"), "true"); - assert.equal(parsed.searchParams.get("project"), PROJECT_A); -}); - -test("live URLs omit the project selector when none is configured", () => { - const url = liveUrlForTool("http://127.0.0.1:1234", null, "get_status", {}); - assert.equal(url, "http://127.0.0.1:1234/status"); -}); - -test("unknown tools have no live URL", () => { - assert.equal(liveUrlForTool("http://127.0.0.1:1", PROJECT_A, "nope", {}), null); -}); - -test("responseProjectMismatch flags answers from another project", () => { - // Matching project (case/separator-insensitive) is accepted. - assert.equal( - responseProjectMismatch({ index: { projectDir: "d:/mods/example" } }, PROJECT_A), - false, - ); - // A different project must be rejected: this is the multi-window cross-talk guard. - assert.equal( - responseProjectMismatch({ index: { projectDir: "D:/Mods/Other" } }, PROJECT_A), - true, - ); - // No project configured, or no projectDir in the payload: nothing to check. - assert.equal(responseProjectMismatch({ index: { state: "ready" } }, PROJECT_A), false); - assert.equal( - responseProjectMismatch({ index: { projectDir: "D:/Mods/Other" } }, null), - false, - ); - assert.equal(responseProjectMismatch(null, PROJECT_A), false); -}); - -test("normalizePath is case- and separator-insensitive", () => { - assert.equal(normalizePath("D:\\Mods\\Example\\"), normalizePath("d:/mods/example")); +test("the CLI module imports without running", async () => { + const mod = await import("../out/agent/cli.js"); + assert.equal(typeof mod.parseArgs, "function"); + assert.equal(typeof mod.toolNameFor, "function"); + assert.equal(typeof mod.liveArgsFor, "function"); + assert.ok(mod.LIVE_ONLY instanceof Set); }); diff --git a/test/agentRuntime.test.mjs b/test/agentRuntime.test.mjs new file mode 100644 index 0000000..395e611 --- /dev/null +++ b/test/agentRuntime.test.mjs @@ -0,0 +1,241 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + electronExecutableCandidates, + electronRuntime, + findElectronExecutable, + isElectronHost, + isNodeFreeLauncher, + launcherScript, + looksLikeElectronExecutable, + resolveRuntime, +} from "../out/agent/runtime.js"; +import { writeLauncher } from "../out/agent/setup.js"; + +const WIN = "win32"; +const LINUX = "linux"; + +// ── Pure launcher generation ────────────────────────────────────────── + +test("electron runtime launcher runs without Node on PATH", () => { + const script = launcherScript( + { + runtime: electronRuntime("C:\\Apps\\VSCode\\Code.exe"), + serverPath: "C:\\ext\\dist\\agent\\mcpServer.js", + projectDir: "D:\\Mods\\Example", + }, + WIN, + ); + assert.ok(script.startsWith("@echo off")); + assert.ok(script.includes("set ELECTRON_RUN_AS_NODE=1")); + assert.ok(script.includes("C:\\Apps\\VSCode\\Code.exe")); + assert.ok(script.includes("C:\\ext\\dist\\agent\\mcpServer.js")); + // The Node path exists but must be guarded by the runtime-existence jump. + assert.ok(script.includes('if not exist "%RA3_RUNTIME%" goto :ra3_node')); + assert.ok(script.includes(":ra3_node")); + assert.equal(isNodeFreeLauncher(script, WIN), true); +}); + +test("Windows launcher avoids the parse-time %errorlevel% batch pitfall", () => { + const script = launcherScript( + { + runtime: electronRuntime("C:\\Apps\\VSCode\\Code.exe"), + serverPath: "C:\\ext\\mcpServer.js", + projectDir: "D:\\P", + }, + WIN, + ); + // Inside a parenthesised block %errorlevel% would expand at parse time. + assert.ok(!/^if exist .*\(\s*$/m.test(script), "must not use an if (...) block"); + assert.equal( + (script.match(/exit \/b %errorlevel%/g) ?? []).length, + 2, + "both Electron and Node endings should propagate the exit code", + ); +}); + +test("node runtime launcher is generated when Electron is unavailable", () => { + const script = launcherScript( + { + runtime: { kind: "node", executable: "node", env: {}, viaPath: true }, + serverPath: "C:\\ext\\mcpServer.js", + projectDir: "D:\\P", + }, + WIN, + ); + assert.ok(!script.includes("ELECTRON_RUN_AS_NODE")); + assert.ok(script.includes('set "RA3_NODE=node"')); + assert.equal(isNodeFreeLauncher(script, WIN), false); +}); + +test("POSIX launcher prefers Electron and falls back to Node", () => { + const script = launcherScript( + { + runtime: electronRuntime("/usr/share/code/code"), + serverPath: "/ext/mcpServer.js", + projectDir: "/mods/example", + }, + LINUX, + ); + assert.ok(script.startsWith("#!/usr/bin/env sh")); + assert.ok(script.includes("ELECTRON_RUN_AS_NODE=1 exec")); + assert.ok(script.includes('if [ -x "$RA3_RUNTIME" ]')); + assert.equal(isNodeFreeLauncher(script, LINUX), true); +}); + +test("launcher quotes paths safely", () => { + const win = launcherScript( + { + runtime: electronRuntime("C:\\Program Files\\VS Code\\Code.exe"), + serverPath: "C:\\my ext\\mcpServer.js", + projectDir: "D:\\My Mods\\Example", + }, + WIN, + ); + assert.ok(win.includes('set "RA3_RUNTIME=C:\\Program Files\\VS Code\\Code.exe"')); + assert.ok(win.includes('set "RA3_SERVER=C:\\my ext\\mcpServer.js"')); + + const sh = launcherScript( + { + runtime: electronRuntime("/opt/it's here/code"), + serverPath: "/tmp/server.js", + projectDir: "/tmp/proj", + }, + LINUX, + ); + // Single quotes inside a single-quoted POSIX string must be escaped. + assert.ok(sh.includes(`'/opt/it'\\''s here/code'`)); +}); + +test("looksLikeElectronExecutable recognises VS Code-family binaries", () => { + assert.equal(looksLikeElectronExecutable("C:\\...\\Microsoft VS Code\\Code.exe"), true); + assert.equal(looksLikeElectronExecutable("/usr/share/code/code"), true); + assert.equal(looksLikeElectronExecutable("/Applications/Visual Studio Code.app/Contents/MacOS/Electron"), true); + assert.equal(looksLikeElectronExecutable("C:\\Windows\\System32\\cmd.exe"), false); +}); + +test("runtime resolution falls back to Node outside an Electron host", () => { + // Tests run under plain Node, so the resolved runtime must be Node. + assert.equal(isElectronHost(), false); + const runtime = resolveRuntime(); + assert.equal(runtime.kind, "node"); + assert.equal(runtime.viaPath, true); + assert.deepEqual(runtime.env, {}); +}); + +test("electronRuntime carries the ELECTRON_RUN_AS_NODE env", () => { + const runtime = electronRuntime("C:\\Code.exe"); + assert.equal(runtime.kind, "electron"); + assert.equal(runtime.executable, "C:\\Code.exe"); + assert.deepEqual(runtime.env, { ELECTRON_RUN_AS_NODE: "1" }); + assert.equal(runtime.viaPath, false); +}); + +test("electronExecutableCandidates returns platform-appropriate probes", () => { + const candidates = electronExecutableCandidates(); + assert.ok(Array.isArray(candidates)); + if (process.platform === "win32") { + assert.ok(candidates.every((c) => c.endsWith(".exe"))); + } + // findElectronExecutable must never throw and returns a string or null. + const found = findElectronExecutable(); + assert.ok(found === null || typeof found === "string"); +}); + +// ── writeLauncher integration ───────────────────────────────────────── + +test("writeLauncher writes an executable launcher and reports node-freeness", async () => { + const home = mkdtempSync(join(tmpdir(), "ra3-runtime-test-")); + try { + const result = await writeLauncher("C:\\ext", "D:\\Mods\\Example", home, electronRuntime("C:\\Code.exe")); + assert.ok(existsSync(result.path)); + assert.equal(result.nodeFree, true); + assert.equal(result.runtime.kind, "electron"); + const text = readFileSync(result.path, "utf8"); + assert.ok(text.includes("ELECTRON_RUN_AS_NODE=1")); + assert.ok(text.includes("mcpServer.js")); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +// ── Live launcher execution (skipped when the sandbox forbids spawning) ── + +const electron = findElectronExecutable(); + +/** + * True when this process may launch the launcher, i.e. spawn a shell. + * + * Some sandboxes allow spawning `node` directly but deny `cmd.exe`/`sh`, so + * the probe must exercise the same capability the test needs instead of just + * spawning any child process. + */ +function canSpawnShell() { + try { + if (process.platform === "win32") { + execFileSync("cmd.exe", ["/d", "/c", "exit 0"], { stdio: "ignore" }); + } else { + execFileSync("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" }); + } + return true; + } catch { + return false; + } +} + +const spawnable = canSpawnShell(); + +test( + "generated launcher completes an MCP session on the Electron runtime", + { skip: !spawnable || !electron }, + (t) => { + const home = mkdtempSync(join(tmpdir(), "ra3-launcher-run-")); + try { + const serverPath = join(process.cwd(), "dist", "agent", "mcpServer.js"); + const launcher = join(home, process.platform === "win32" ? "launch.cmd" : "launch.sh"); + writeFileSync( + launcher, + launcherScript({ + runtime: electronRuntime(electron), + serverPath, + projectDir: "D:/Mods/Example", + // Point the Node fallback at a path that cannot exist, so a + // successful session proves the Electron branch was taken and the + // launcher really is Node-free. + nodeFallback: join(home, "no-such-node"), + }), + "utf8", + ); + const requests = [ + JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), + JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }), + ].join("\n"); + let stdout; + try { + stdout = execFileSync(launcher, [], { + input: requests, + encoding: "utf8", + timeout: 60000, + shell: process.platform === "win32", + }); + } catch (err) { + if (err?.code === "EPERM") { + t.skip("sandbox forbids spawning a shell"); + return; + } + throw err; + } + const lines = stdout.split(/\r?\n/).filter((l) => l.trim()); + const init = JSON.parse(lines[0]); + assert.equal(init.result.serverInfo.name, "ra3-mod-xml"); + const tools = JSON.parse(lines[1]); + assert.ok(tools.result.tools.length >= 9); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }, +); diff --git a/test/agentSkill.test.mjs b/test/agentSkill.test.mjs index e0ddccf..c31a85a 100644 --- a/test/agentSkill.test.mjs +++ b/test/agentSkill.test.mjs @@ -56,6 +56,32 @@ test("SKILL.md scopes itself to SAGE/RA3 projects and warns off others", async ( } }); +test("SKILL.md explains how to reach the index without MCP", async () => { + const dir = mkdtempSync(join(tmpdir(), "ra3-skill-reach-")); + try { + const skillDir = join(dir, "ra3-mod-xml"); + await writeSkillTo(skillDir, "0.1.25"); + const content = readFileSync(join(skillDir, "SKILL.md"), "utf8"); + + assert.match(content, /## Reaching the index/); + // The discovery manifest is the stable entry point. + assert.ok(content.includes("~/.ra3modxml/index.json")); + // The launcher and the bundled CLI are both mentioned. + assert.ok(content.includes("ra3-mod-xml-mcp")); + assert.ok(content.includes("cli.js")); + // The stdio escape hatch must be shown, since it needs no setup at all. + assert.ok(content.includes("tools/call")); + // It must not pretend configuring a client takes effect immediately. + assert.ok(content.includes("new session")); + // And it must refuse to invent results. + assert.match(content, /Never fabricate index results/); + // Instructions must not send it looking for project-specific docs. + assert.equal(content.includes("codebase-navigation-guide"), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("installSkillToDirectories records managed copies and uninstall removes them", async () => { const home = mkdtempSync(join(tmpdir(), "ra3-skill-home-")); const dir = mkdtempSync(join(tmpdir(), "ra3-skill-target-")); diff --git a/tools/probe-electron-node.cjs b/tools/probe-electron-node.cjs new file mode 100644 index 0000000..90ee339 --- /dev/null +++ b/tools/probe-electron-node.cjs @@ -0,0 +1,89 @@ +// Probe: which Node APIs does the VS Code Electron binary provide in +// ELECTRON_RUN_AS_NODE mode? Run with: +// ELECTRON_RUN_AS_NODE=1 "" probe.cjs +const out = {}; + +try { + out.nodeVersion = process.versions.node; + out.electronVersion = process.versions.electron ?? null; + out.isElectronRunAsNode = process.env.ELECTRON_RUN_AS_NODE === "1"; + out.execPath = process.execPath; + out.platform = process.platform; +} catch (e) { + out.baseError = String(e); +} + +const modules = [ + "node:fs", + "node:fs/promises", + "node:path", + "node:os", + "node:http", + "node:readline", + "node:zlib", + "node:crypto", + "node:util", + "node:net", + "node:child_process", + "node:test", + "node:assert", + "node:url", + "node:events", +]; +out.modules = {}; +for (const m of modules) { + try { + require(m); + out.modules[m] = "ok"; + } catch (e) { + out.modules[m] = "FAIL: " + (e && e.code ? e.code : String(e)); + } +} + +// The APIs the MCP server actually depends on. +try { + const { parseLoadedXml } = require("../out/agent/forwardRefs.js"); + const parsed = parseLoadedXml(''); + out.forwardRefs = "ok: elements=" + parsed.parse.elements.length; +} catch (e) { + out.forwardRefs = "FAIL: " + String(e).slice(0, 160); +} + +try { + const { startLocalServer } = require("../out/agent/localServer.js"); + out.localServer = typeof startLocalServer === "function" ? "loadable" : "missing"; +} catch (e) { + out.localServer = "FAIL: " + String(e).slice(0, 160); +} + +// Async smoke test: gzip round-trip + http listen (both used at runtime). +(async () => { + try { + const { gzip, gunzip } = require("node:zlib"); + const { promisify } = require("node:util"); + const buf = await promisify(gzip)(Buffer.from("hello")); + const back = await promisify(gunzip)(buf); + out.zlibRoundTrip = back.toString() === "hello" ? "ok" : "mismatch"; + } catch (e) { + out.zlibRoundTrip = "FAIL: " + String(e).slice(0, 120); + } + + try { + const { startLocalServer } = require("../out/agent/localServer.js"); + const handle = await startLocalServer({ + getIndex: () => null, + listProjects: () => [], + token: "probe", + }); + const res = await fetch(`http://127.0.0.1:${handle.port}/status`, { + headers: { authorization: "Bearer probe" }, + }); + const body = await res.json(); + out.httpServer = "ok: " + JSON.stringify(body); + await handle.close(); + } catch (e) { + out.httpServer = "FAIL: " + String(e).slice(0, 160); + } + + console.log(JSON.stringify(out, null, 2)); +})(); diff --git a/tools/serve-fake-index.cjs b/tools/serve-fake-index.cjs new file mode 100644 index 0000000..7c28e34 --- /dev/null +++ b/tools/serve-fake-index.cjs @@ -0,0 +1,89 @@ +/** + * Starts a real local live-index server with a fake in-memory index and + * registers it exactly like the extension does (instance file + per-project + * endpoint + merged manifest). Used by the integration smoke test. + * + * Prints the agent home as its first stdout line, then stays alive. + */ +const { startLocalServer } = require("../out/agent/localServer.js"); +const { writeInstance, writeManifest } = require("../out/agent/instances.js"); +const { writeEndpoint, writeEndpointForProject } = require("../out/agent/endpoint.js"); +const { mkdirSync, writeFileSync } = require("node:fs"); +const { join } = require("node:path"); + +const PROJECT = process.env.RA3_PROJECT || "D:/Mods/Alpha"; +const OTHER = "D:/Mods/Beta"; +// The agent home is passed in, so the caller knows it without reading stdout. +const home = process.argv[2]; +if (!home) { + console.error("usage: serve-fake-index.cjs "); + process.exit(2); +} + +function def(type, id, file, line) { + return { type, id, file, line, origin: "project", stream: "static" }; +} + +const unitFile = `${PROJECT}/Data/AthenaCannon.xml`; +const unit = def("GameObject", "AthenaCannon", unitFile, 2); +const weapon = def("WeaponTemplate", "AthenaCannonWeapon", `${PROJECT}/Data/Weapon.xml`, 88); + +const index = { + projectDir: PROJECT, + sdkDir: "", + complete: true, + phase: "art", + stale: false, + assets: new Map([ + ["GameObject", new Map([["athenacannon", [unit]]])], + ["WeaponTemplate", new Map([["athenacannonweapon", [weapon]]])], + ]), + assetsById: new Map([ + ["athenacannon", [unit]], + ["athenacannonweapon", [weapon]], + ]), + defines: new Map([["d", [{ name: "D", value: "1", file: unitFile, line: 1, origin: "project" }]]]), + files: new Map(), + streams: [{ name: "static", entry: `${PROJECT}/Data/Mod.xml`, files: new Set([unitFile.toLowerCase().replace(/\\/g, "/")]) }], + manifests: new Map(), + sourceCandidates: [], + diagnostics: [], + references: new Map([ + [`GameObject\u0000athenacannon\u0000${unitFile}\u00002`, [{ file: `${PROJECT}/Data/Other.xml`, line: 3, start: 1, end: 2, kind: "attr" }]], + ]), + recordsHashes: new Map(), + stats: { + projectDir: PROJECT, sdkDir: "", phase: "art", complete: true, + indexedFiles: 2, parsedFiles: 2, shallowScannedFiles: 0, deferredArtFiles: 0, + shallowCacheHits: 0, recordsCacheHits: 0, resolveCacheHits: 0, resolveCalls: 0, + snapshotHits: 0, snapshotFallbacks: 0, candidatesMs: 0, walkMs: 0, artScanMs: 0, + assetCount: 2, referenceCount: 1, defineCount: 1, manifestFiles: 0, + manifestAssetCount: 0, streams: 1, sourceCandidates: 0, elapsedMs: 1, + }, +}; + +(async () => { + mkdirSync(home, { recursive: true }); + const handle = await startLocalServer({ + getIndex: (projectDir) => (!projectDir || projectDir === PROJECT ? index : null), + listProjects: () => [PROJECT, OTHER], + loadFile: async () => null, + }); + const endpoint = { + instanceId: "smoke-1", + url: `http://127.0.0.1:${handle.port}`, + token: handle.token, + projectDir: PROJECT, + projects: [PROJECT, OTHER], + processId: process.pid, + updatedAt: new Date().toISOString(), + }; + await writeEndpointForProject(PROJECT, { ...endpoint, projectDir: PROJECT }, home); + await writeEndpoint(endpoint, home); + await writeInstance(endpoint, home); + await writeManifest([endpoint], home); + // Signal readiness through a file so the caller never has to parse stdout. + writeFileSync(join(home, "READY"), `${handle.port}\n`); + // Stay alive until killed. + setInterval(() => {}, 1 << 30); +})();