This commit is contained in:
2026-09-10 19:18:15 +02:00
parent 3a3d70efeb
commit 5993da4ce6
21 changed files with 2916 additions and 340 deletions
+16
View File
@@ -196,6 +196,22 @@ reports truncation instead of silently dropping results.
Discovery is per project (`~/.ra3modxml/endpoints/<project>.json`), so several Discovery is per project (`~/.ra3modxml/endpoints/<project>.json`), so several
VS Code windows can enable agent access at the same time without shadowing each 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. 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. See `docs/ai-agent-integration-plan.md` for the full design and progress.
+5 -1
View File
@@ -178,7 +178,11 @@ MCP Server 在扩展运行时优先查询内存中的实时索引;扩展关闭
`get_asset_references` 会沿 `inheritFrom` 祖先链遍历,并用 `definedIn` 标出条目实际写在哪个祖先的文件里,因此"这个单位自己没有 `WeaponSetUpdate`,但它继承的基础单位有"可以在一次调用里回答。它受 `depth`(默认 1,上限 3)、`targetTypes``maxEdges` 三重限制,并在截断时显式报告,而不是静默丢弃结果。 `get_asset_references` 会沿 `inheritFrom` 祖先链遍历,并用 `definedIn` 标出条目实际写在哪个祖先的文件里,因此"这个单位自己没有 `WeaponSetUpdate`,但它继承的基础单位有"可以在一次调用里回答。它受 `depth`(默认 1,上限 3)、`targetTypes``maxEdges` 三重限制,并在截断时显式报告,而不是静默丢弃结果。
Endpoint 按项目存放(`~/.ra3modxml/endpoints/<project>.json`),因此多个 VS Code 窗口可以同时启用 AI Agent 访问而不互相覆盖,客户端也不会被静默地用另一个项目的数据回答。 Endpoint 按项目存放(`~/.ra3modxml/endpoints/<project>.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` 完整设计与进度见 `docs/ai-agent-integration-plan.md`
+349
View File
@@ -35,6 +35,18 @@
| 二期 C2 | 本地 HTTP `/get_asset_references` 与 MCP 工具接入 | ✅ 完成 | | 二期 C2 | 本地 HTTP `/get_asset_references` 与 MCP 工具接入 | ✅ 完成 |
| 二期 C3 | 二期测试与文档 | ✅ 完成(264/264 测试通过) | | 二期 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. 背景与目标 ## 1. 背景与目标
@@ -770,4 +782,341 @@ VS Code 扩展进程内的 localServer ← 仅在"启用 AI Agent 访问"后启
验证方式为:逐个 `node test/*.test.mjs`、直接调用 esbuild CLI 构建 dist、 验证方式为:逐个 `node test/*.test.mjs`、直接调用 esbuild CLI 构建 dist、
用管道驱动 `dist/agent/mcpServer.js` 做端到端 smoke test。 用管道驱动 `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/<slug>-<sha1-12>.json``snapshots/<slug>-<sha1-12>.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 <name>`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-<pid>-<rand>.json ← 每个 VS Code 窗口一个,只有属主会写
index.json ← 只读合并视图,供 agent/人发现
snapshots/<slug>-<sha1-12>.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 "<server>" ...`,所以**没装 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" "<ext>\dist\agent\mcpServer.js" --project "D:\..."
```
**本机实测(2026-09-10VS 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/` 下路径稳定;
- 需要 fallbackElectron 二进制被移动/卸载时回退到 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-<pid>-<rand>.json 每窗口一个,只有属主写
index.json 只读合并视图(不含 token)
endpoints/<slug>-<sha1-12>.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.1Windows):
| 项目 | 结果 |
|---|---|
| `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 必死的实例,剪枝后另一个实例仍正常工作;
清理本窗口实例不影响其他窗口。
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ra3-mod-xml", "name": "ra3-mod-xml",
"displayName": "%ra3modxml.displayName%", "displayName": "%ra3modxml.displayName%",
"description": "%ra3modxml.description%", "description": "%ra3modxml.description%",
"version": "0.1.25", "version": "0.1.26",
"publisher": "lanyi", "publisher": "lanyi",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"icon": "images/icon.png", "icon": "images/icon.png",
+258 -94
View File
@@ -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, * Deliberately thin: it does **not** reimplement the query surface. It does
* debugging, and as a reference for MCP tool implementations. * exactly two things.
* *
* Usage examples: * 1. Forward the call to the live VS Code extension through the shared
* node out/agent/cli.js --project D:/Mods/Example status * `LiveClient` (same transport, project pinning and validation as the MCP
* node out/agent/cli.js --snapshot /path/to/snapshot.json.gz find ExampleUnit * server, so the two entry points cannot drift apart).
* node out/agent/cli.js --project D:/Mods/Example refs ExampleUnit GameObject * 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 <id> [type]
* node dist/agent/cli.js refs <id> [type]
* node dist/agent/cli.js outgoing <id> [type] [--depth N] [--target-types A,B]
* node dist/agent/cli.js list <type> [prefix]
* node dist/agent/cli.js active <file>
* node dist/agent/cli.js define <name>
* node dist/agent/cli.js resolve <source>
* 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 { import {
findAssets, findAssets,
findDefine, findDefine,
@@ -20,32 +41,63 @@ import {
resolveIncludeSource, resolveIncludeSource,
statusFromSnapshot, statusFromSnapshot,
} from "./query"; } from "./query";
import { readSnapshotFile, snapshotPathForProject } from "./snapshot";
import type { AgentIndexSnapshot } from "./types";
interface CliOptions { const EXIT_OK = 0;
snapshotPath: string | null; const EXIT_USAGE = 2;
const EXIT_UNAVAILABLE = 3;
export interface CliOptions {
projectDir: string | null; projectDir: string | null;
snapshotPath: string | null;
agentHome: string | undefined;
command: string; command: string;
args: 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 = { const options: CliOptions = {
snapshotPath: null,
projectDir: null, projectDir: null,
snapshotPath: null,
agentHome: undefined,
command: "status", command: "status",
args: [], args: [],
depth: undefined,
targetTypes: [],
maxEdges: undefined,
includeUnresolved: false,
}; };
const positional: string[] = []; const positional: string[] = [];
for (let i = 0; i < argv.length; i++) { for (let i = 0; i < argv.length; i++) {
const arg = argv[i]; const arg = argv[i];
if (arg === "--snapshot" || arg === "-s") { if (arg === "--project" || arg === "-p") {
options.snapshotPath = argv[++i] ?? null;
} else if (arg === "--project" || arg === "-p") {
options.projectDir = argv[++i] ?? null; 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") { } else if (arg === "--help" || arg === "-h") {
options.command = "help"; options.command = "help";
} else if (arg.startsWith("-")) { } else if (arg.startsWith("-")) {
// ignore unknown flags // Ignore unknown flags rather than failing an agent's probing call.
} else { } else {
positional.push(arg); positional.push(arg);
} }
@@ -58,102 +110,214 @@ function parseArgs(argv: string[]): CliOptions {
} }
function printHelp(): void { function printHelp(): void {
console.log(`RA3 Mod XML agent snapshot CLI process.stdout.write(`RA3 Mod XML agent CLI
Usage: Usage:
node out/agent/cli.js --project <dir> <command> [args...] node cli.js [--project <dir>] <command> [args...]
node out/agent/cli.js --snapshot <file> <command> [args...] node cli.js [--snapshot <file>] <command> [args...]
Project resolution order:
1. --project
2. --snapshot
3. nearest mod project root above the current directory
Commands: Commands:
status status index state (+ source: live or snapshot)
find <id> [type] find <id> [type] asset definitions
refs <id> [type] refs <id> [type] incoming semantic references
list <type> [prefix] outgoing <id> [type] outgoing reference edges (live only)
active <file> --depth N --target-types A,B --max-edges N --include-unresolved
define <name> list <type> [prefix] assets of one type
resolve <source> active <file> whether a file is in an indexed stream
define <name> $DEFINE constants
resolve <source> Include source candidate
projects indexed project roots (live only)
help help
`); `);
} }
async function main(): Promise<void> { /** Resolves the project directory, inferring it from cwd when not given. */
const options = parseArgs(process.argv.slice(2)); export function resolveProjectDir(options: CliOptions): string | null {
if (options.command === "help") { if (options.projectDir) return resolve(options.projectDir);
printHelp(); const inferred = findProjectRootUpward(process.cwd());
return; return inferred;
} }
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;
}
/** 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<string, unknown> {
const [a, b] = options.args; const [a, b] = options.args;
switch (options.command) { switch (options.command) {
case "status":
console.log(JSON.stringify(statusFromSnapshot(snapshot), null, 2));
break;
case "find": case "find":
if (!a) { return { id: a, type: b };
console.error("find requires an id.");
process.exitCode = 2;
return;
}
console.log(JSON.stringify(findAssets(snapshot, a, b), null, 2));
break;
case "refs": case "refs":
if (!a) { return { id: a, type: b };
console.error("refs requires an id."); case "outgoing":
process.exitCode = 2; return {
return; id: a,
} type: b,
console.log(JSON.stringify(findReferenceGroups(snapshot, a, b), null, 2)); depth: options.depth,
break; targetTypes: options.targetTypes?.length ? options.targetTypes : undefined,
maxEdges: options.maxEdges,
includeUnresolved: options.includeUnresolved ? true : undefined,
};
case "list": case "list":
if (!a) { return { type: a, prefix: b };
console.error("list requires a type.");
process.exitCode = 2;
return;
}
console.log(JSON.stringify(listAssetsByType(snapshot, a, b ?? ""), null, 2));
break;
case "active": case "active":
if (!a) { return { path: a };
console.error("active requires a file path.");
process.exitCode = 2;
return;
}
console.log(JSON.stringify({ active: isFileActive(snapshot, a) }, null, 2));
break;
case "define": case "define":
if (!a) { return { name: a };
console.error("define requires a name.");
process.exitCode = 2;
return;
}
console.log(JSON.stringify(findDefine(snapshot, a), null, 2));
break;
case "resolve": case "resolve":
if (!a) { return { source: a };
console.error("resolve requires a source.");
process.exitCode = 2;
return;
}
console.log(JSON.stringify(resolveIncludeSource(snapshot, a), null, 2));
break;
default: default:
console.error(`Unknown command: ${options.command}`); return {};
process.exitCode = 2;
} }
} }
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<number> {
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 <dir>, --snapshot <file>, 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;
},
);
}
+222
View File
@@ -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<string> {
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<AgentInstance[]> {
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<void> {
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<PruneResult> {
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<string>();
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<AgentIndexManifest> {
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<AgentIndexManifest | null> {
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);
}
+272
View File
@@ -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<string, string> = {
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, unknown>,
): 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<AgentEndpoint | null> {
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<string, unknown> = {},
): Promise<LiveQueryResult | null> {
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<string, unknown> = {},
options: LiveClientOptions = {},
): Promise<LiveQueryResult | null> {
return new LiveClient(options).query(toolName, args);
}
+28 -148
View File
@@ -17,11 +17,8 @@
*/ */
import { createInterface } from "node:readline"; import { createInterface } from "node:readline";
import { import { LiveClient } from "./liveClient";
isProcessAlive, import { pruneInstances } from "./instances";
readEndpoint,
readEndpointForProject,
} from "./endpoint";
import { readSnapshotFile, snapshotPathForProject } from "./snapshot"; import { readSnapshotFile, snapshotPathForProject } from "./snapshot";
import { import {
findAssets, 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 - is_file_active(path) -> whether a file is part of an indexed include stream
- find_define(name) -> $DEFINE definitions - find_define(name) -> $DEFINE definitions
- resolve_include(source) -> candidate source file - resolve_include(source) -> candidate source file
- list_projects() -> project roots the live extension has indexed
- get_status() -> current index state - get_status() -> current index state
Tips: 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.", "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", name: "get_usage_guide",
description: "Returns guidance for using the RA3 Mod XML index tools.", 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. */ /** Tools that can only be answered by the live extension server. */
const LIVE_ONLY_TOOLS = new Set(["get_asset_references"]); const LIVE_ONLY_TOOLS = new Set(["get_asset_references", "list_projects"]);
export function liveUrlForTool(
endpointUrl: string,
projectDir: string | null,
toolName: string,
args: Record<string, unknown>,
): 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<string, unknown>,
projectDir: string | null,
agentHome?: string,
): Promise<LiveResult | null> {
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);
}
}
function sendMessage(message: unknown): void { function sendMessage(message: unknown): void {
process.stdout.write(`${JSON.stringify(message)}\n`); process.stdout.write(`${JSON.stringify(message)}\n`);
@@ -390,7 +264,7 @@ async function handleRequest(
message: Record<string, unknown>, message: Record<string, unknown>,
snapshot: AgentIndexSnapshot | null, snapshot: AgentIndexSnapshot | null,
projectDir: string | null, projectDir: string | null,
agentHome?: string, live: LiveClient,
): Promise<unknown | null> { ): Promise<unknown | null> {
const method = String(message.method ?? ""); const method = String(message.method ?? "");
const id = message.id; const id = message.id;
@@ -418,12 +292,12 @@ async function handleRequest(
const tool = TOOLS.find((t) => t.name === toolName); const tool = TOOLS.find((t) => t.name === toolName);
if (!tool) return errorFor(id, -32602, `Unknown tool: ${toolName}`); if (!tool) return errorFor(id, -32602, `Unknown tool: ${toolName}`);
const args = (params.arguments ?? {}) as Record<string, unknown>; const args = (params.arguments ?? {}) as Record<string, unknown>;
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 // The server answered for another project. Refuse it: a plausible
// wrong answer is worse than an explicit failure. // wrong answer is worse than an explicit failure.
liveUnavailableUntil = Date.now() + LIVE_RETRY_COOLDOWN_MS; live.markUnavailable();
return textResult(id, { return textResult(id, {
index: { state: "error", projectDir: projectDir ?? undefined }, 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.`, 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". // not conclude "this asset has no references".
return textResult( return textResult(
id, id,
live?.payload ?? { result?.payload ?? {
index: { state: "no_index", projectDir: projectDir ?? undefined }, 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.`, 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); return textResult(id, output);
} }
default: default:
@@ -474,6 +348,12 @@ async function main(): Promise<void> {
let snapshot: AgentIndexSnapshot | null = null; let snapshot: AgentIndexSnapshot | null = null;
if (resolvedSnapshotPath) snapshot = await readSnapshotFile(resolvedSnapshotPath); 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({ const rl = createInterface({
input: process.stdin, input: process.stdin,
crlfDelay: Infinity, crlfDelay: Infinity,
@@ -486,7 +366,7 @@ async function main(): Promise<void> {
} catch { } catch {
return; return;
} }
void handleRequest(message, snapshot, projectDir, agentHome).then((response) => { void handleRequest(message, snapshot, projectDir, live).then((response) => {
if (response != null) sendMessage(response); if (response != null) sendMessage(response);
}); });
}); });
+256
View File
@@ -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<string, string>;
/**
* 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));
}
+39 -18
View File
@@ -13,6 +13,11 @@ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { defaultAgentHome } from "./snapshot"; import { defaultAgentHome } from "./snapshot";
import {
launcherScript,
resolveRuntime,
type AgentRuntime,
} from "./runtime";
export interface McpConfigTarget { export interface McpConfigTarget {
id: string; id: string;
@@ -20,6 +25,18 @@ export interface McpConfigTarget {
path: string; 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. */ /** File name of the stable launcher on the current platform. */
export function launcherFileName(): string { export function launcherFileName(): string {
return process.platform === "win32" ? "ra3-mod-xml-mcp.cmd" : "ra3-mod-xml-mcp"; 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 * Creates the stable launcher script. It prefers the VS Code Electron runtime
* bundled MCP server and passes the project directory. * (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( export async function writeLauncher(
extensionRoot: string, extensionRoot: string,
projectDir: string, projectDir: string,
agentHome = defaultAgentHome(), agentHome = defaultAgentHome(),
): Promise<string> { runtime?: AgentRuntime,
): Promise<LauncherResult> {
const server = bundledMcpServerPath(extensionRoot); const server = bundledMcpServerPath(extensionRoot);
const launcher = launcherPath(agentHome); const launcher = launcherPath(agentHome);
const resolved = runtime ?? resolveRuntime();
const script = launcherScript({
runtime: resolved,
serverPath: server,
projectDir,
});
await mkdir(dirname(launcher), { recursive: true }); await mkdir(dirname(launcher), { recursive: true });
if (process.platform === "win32") { await writeFile(launcher, script, "utf8");
const content = [ if (process.platform !== "win32") {
"@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 chmod(launcher, 0o755); await chmod(launcher, 0o755);
} }
return launcher; return {
path: launcher,
serverPath: server,
runtime: resolved,
nodeFree: resolved.kind === "electron",
};
} }
/** MCP client config entry for one project. */ /** MCP client config entry for one project. */
+41 -2
View File
@@ -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 Do not use full-text search over the XML tree when one of the MCP query tools
can answer the question directly. 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 ## How to use
1. Call \`get_status\` first when you are unsure whether an index is available, 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 - \`get_asset_references(id, type?, depth?, targetTypes?)\` for outgoing
references (what this asset uses, and where that link is written). references (what this asset uses, and where that link is written).
- \`list_assets_by_type(type, prefix?, limit?)\` for browsing assets. - \`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. - \`is_file_active(path)\` to determine whether a file is included in an indexed stream. - \`find_define(name)\` for $DEFINE constants.
- \`find_define(name)\` for $DEFINE constants.
- \`resolve_include(source)\` for Include source candidates. - \`resolve_include(source)\` for Include source candidates.
3. Asset ids are case-insensitive. 3. Asset ids are case-insensitive.
4. If an id exists for multiple asset types, pass the type filter to avoid mixing definitions. 4. If an id exists for multiple asset types, pass the type filter to avoid mixing definitions.
+112 -10
View File
@@ -54,6 +54,13 @@ import {
writeEndpoint, writeEndpoint,
writeEndpointForProject, writeEndpointForProject,
} from "./agent/endpoint"; } from "./agent/endpoint";
import {
clearInstance,
makeInstanceId,
pruneInstances,
writeInstance,
writeManifest,
} from "./agent/instances";
const XML_SELECTOR: vscode.DocumentSelector = [{ language: "xml" }]; const XML_SELECTOR: vscode.DocumentSelector = [{ language: "xml" }];
/** Safety-net refresh interval while a rebuild is running. */ /** 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; let agentLocalServer: LocalServerHandle | null = null;
/** Project roots whose per-project endpoint file this window wrote. */ /** Project roots whose per-project endpoint file this window wrote. */
const agentEndpointProjects = new Set<string>(); const agentEndpointProjects = new Set<string>();
/**
* 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<void> => { const startAgentLocalServer = async (): Promise<void> => {
if (agentLocalServer) return; if (agentLocalServer) return;
try { 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({ const handle = await startLocalServer({
// Route by explicit project so a query can never be answered by // Route by explicit project so a query can never be answered by
// whichever project the active editor happens to point at. // 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 url = `http://127.0.0.1:${handle.port}`;
const projects = ws.getProjectRoots(); const projects = ws.getProjectRoots();
const endpoint = { const endpoint = {
instanceId: agentInstanceId,
url, url,
token: handle.token, token: handle.token,
projectDir: ws.projectRoot ?? undefined, projectDir: ws.projectRoot ?? undefined,
@@ -193,8 +217,12 @@ export function activate(context: vscode.ExtensionContext): void {
agentEndpointProjects.add(project); agentEndpointProjects.add(project);
ws.log(`[agent-local-server] endpoint for ${project} -> ${file}`); 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. // Legacy/global pointer for tooling that does not know the project.
await writeEndpoint(endpoint); await writeEndpoint(endpoint);
await writeManifest(await refreshInstanceFiles(url, handle.token, projects));
ws.log(`[agent-local-server] listening on ${url}`); ws.log(`[agent-local-server] listening on ${url}`);
} catch (err) { } catch (err) {
ws.log( 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<void> => { const stopAgentLocalServer = async (): Promise<void> => {
if (agentLocalServer) { if (agentLocalServer) {
const server = agentLocalServer; const server = agentLocalServer;
agentLocalServer = null; agentLocalServer = null;
await server.close().catch(() => undefined); await server.close().catch(() => undefined);
} }
// Only remove the endpoint files this window wrote: another VS Code // Only remove what this window owns: another VS Code window may still be
// window may still be serving its own projects. // serving its own projects.
for (const project of agentEndpointProjects) { for (const project of agentEndpointProjects) {
await clearEndpointForProject(project).catch(() => undefined); await clearEndpointForProject(project).catch(() => undefined);
} }
agentEndpointProjects.clear(); agentEndpointProjects.clear();
await clearInstance(agentInstanceId).catch(() => undefined);
await clearEndpoint().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 * 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<void> => { const refreshAgentEndpoints = async (): Promise<void> => {
if (!agentLocalServer) return; 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({ context.subscriptions.push({
dispose: () => { dispose: () => {
@@ -487,7 +547,17 @@ export function activate(context: vscode.ExtensionContext): void {
context.extensionUri.fsPath, context.extensionUri.fsPath,
projectDir, 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; agentAccessEnabled = true;
await context.workspaceState.update("ra3modxml.agentAccessEnabled", true); await context.workspaceState.update("ra3modxml.agentAccessEnabled", true);
void startAgentLocalServer(); void startAgentLocalServer();
@@ -538,17 +608,17 @@ export function activate(context: vscode.ExtensionContext): void {
t("RA3 Mod XML Agent Skill installed to {0}", target), t("RA3 Mod XML Agent Skill installed to {0}", target),
); );
} else if (pick?.id === "claude") { } else if (pick?.id === "claude") {
await addMcpServerToConfigFile(claudeDesktopConfigPath(), launcher, projectDir); await addMcpServerToConfigFile(claudeDesktopConfigPath(), launcher.path, projectDir);
void vscode.window.showInformationMessage( void vscode.window.showInformationMessage(
t("RA3 Mod XML MCP config written to {0}", claudeDesktopConfigPath()), t("RA3 Mod XML MCP config written to {0}", claudeDesktopConfigPath()),
); );
} else if (pick?.id === "cursor-global") { } else if (pick?.id === "cursor-global") {
await addMcpServerToConfigFile(cursorGlobalConfigPath(), launcher, projectDir); await addMcpServerToConfigFile(cursorGlobalConfigPath(), launcher.path, projectDir);
void vscode.window.showInformationMessage( void vscode.window.showInformationMessage(
t("RA3 Mod XML MCP config written to {0}", cursorGlobalConfigPath()), t("RA3 Mod XML MCP config written to {0}", cursorGlobalConfigPath()),
); );
} else if (pick?.id === "cursor-project") { } else if (pick?.id === "cursor-project") {
await addMcpServerToConfigFile(cursorProjectConfigPath(projectDir), launcher, projectDir); await addMcpServerToConfigFile(cursorProjectConfigPath(projectDir), launcher.path, projectDir);
void vscode.window.showInformationMessage( void vscode.window.showInformationMessage(
t("RA3 Mod XML MCP config written to {0}", cursorProjectConfigPath(projectDir)), 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<void> => {
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 sdkSetup.evaluate(ws);
void ws.initialize().then(() => { void ws.initialize().then(() => {
void sdkSetup.evaluate(ws); void sdkSetup.evaluate(ws);
if (agentAccessEnabled) void startAgentLocalServer(); if (agentAccessEnabled) {
void startAgentLocalServer();
void refreshLaunchers();
}
}); });
} }
+187
View File
@@ -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"), "<AssetDeclaration/>");
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 });
}
});
+168
View File
@@ -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);
});
+205
View File
@@ -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);
});
+297
View File
@@ -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 = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="AthenaCannon" inheritFrom="BaseCannon">
<CreateObjectDie><CreateObject>AthenaCannon_Die</CreateObject></CreateObjectDie>
</GameObject>
</AssetDeclaration>`;
const BASE_XML = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="BaseCannon">
<WeaponSetUpdate><WeaponSlotHardpoint><Weapon Template="AthenaCannonWeapon" /></WeaponSlotHardpoint></WeaponSetUpdate>
</GameObject>
</AssetDeclaration>`;
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();
}
});
+15 -66
View File
@@ -1,73 +1,22 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; 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", () => { test("the MCP server module imports without starting its stdio loop", async () => {
const url = liveUrlForTool("http://127.0.0.1:1234", PROJECT_A, "find_asset", { const mod = await import("../out/agent/mcpServer.js");
id: "AthenaCannon", assert.equal(typeof mod, "object");
type: "GameObject", // Reaching this line proves main() did not run on import.
});
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("get_asset_references serialises its list and scalar options", () => { test("the CLI module imports without running", async () => {
const url = liveUrlForTool("http://127.0.0.1:1234/", PROJECT_A, "get_asset_references", { const mod = await import("../out/agent/cli.js");
id: "AthenaCannon", assert.equal(typeof mod.parseArgs, "function");
depth: 2, assert.equal(typeof mod.toolNameFor, "function");
targetTypes: ["WeaponTemplate", "GameObject"], assert.equal(typeof mod.liveArgsFor, "function");
maxEdges: 25, assert.ok(mod.LIVE_ONLY instanceof Set);
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"));
}); });
+241
View File
@@ -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 });
}
},
);
+26
View File
@@ -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 () => { test("installSkillToDirectories records managed copies and uninstall removes them", async () => {
const home = mkdtempSync(join(tmpdir(), "ra3-skill-home-")); const home = mkdtempSync(join(tmpdir(), "ra3-skill-home-"));
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-target-")); const dir = mkdtempSync(join(tmpdir(), "ra3-skill-target-"));
+89
View File
@@ -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 "<Code.exe>" 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('<AssetDeclaration xmlns="uri:ea.com:eala:asset"><GameObject id="X"/></AssetDeclaration>');
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));
})();
+89
View File
@@ -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 <agentHome>");
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);
})();