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
VS Code windows can enable agent access at the same time without shadowing each
other, and a client can never be silently answered from a different project.
Each window also registers itself under `~/.ra3modxml/instances/`, and a small
merged `~/.ra3modxml/index.json` lists the live instances and their project
roots. A window that crashes is cleaned up by whichever instance starts next —
no workspace has to be reopened first.
**No Node installation is required.** The launcher runs the bundled server on
VS Code's own Electron binary (`ELECTRON_RUN_AS_NODE=1`) and only falls back to
`node` from `PATH` if that binary is missing. The launcher is rewritten on every
activation, so updating or moving VS Code does not break an existing MCP config.
The MCP server is plain JSON-RPC over stdio, so an agent can also query the
index without any MCP setup at all by piping a request into the launcher. A
matching CLI (`cli.js`, next to `mcpServer.js` in `dist/agent/`) answers from
the live index when VS Code is running and from the exported snapshot
otherwise; commands that need element context say so explicitly rather than
returning an empty result.
See `docs/ai-agent-integration-plan.md` for the full design and progress.
+5 -1
View File
@@ -178,7 +178,11 @@ MCP Server 在扩展运行时优先查询内存中的实时索引;扩展关闭
`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`
+349
View File
@@ -35,6 +35,18 @@
| 二期 C2 | 本地 HTTP `/get_asset_references` 与 MCP 工具接入 | ✅ 完成 |
| 二期 C3 | 二期测试与文档 | ✅ 完成(264/264 测试通过) |
### 三期进度(2026-09-10
| 阶段 | 内容 | 状态 |
|---|---|---|
| 三期 0 | 讨论结论与进度落档 | ✅ 完成 |
| 三期 1 | 运行时解析:无 Node 时用 VS Code Electron`ELECTRON_RUN_AS_NODE` | ✅ 完成 |
| 三期 2 | 抽出 `src/agent/liveClient.ts`,MCP 与 CLI 共用转发/校验/负缓存 | ✅ 完成 |
| 三期 3 | `instances/` 注册表 + 跨实例剪枝 + 只读 `index.json` 发现面 | ✅ 完成 |
| 三期 4 | CLI 瘦身:live 优先、快照兜底、cwd 项目识别、补 `projects`/`get_asset_references` | ✅ 完成 |
| 三期 5 | 三期测试与文档 | ✅ 完成(302 用例,299 通过 / 3 因沙箱跳过) |
| 三期 6 | (可选)TCP → 命名管道 / UDS 传输 | ⬜ 未开始 |
---
## 1. 背景与目标
@@ -770,4 +782,341 @@ VS Code 扩展进程内的 localServer ← 仅在"启用 AI Agent 访问"后启
验证方式为:逐个 `node test/*.test.mjs`、直接调用 esbuild CLI 构建 dist、
用管道驱动 `dist/agent/mcpServer.js` 做端到端 smoke test。
---
---
# 第三部分:三期计划(讨论结论 + 实施)
> 讨论时间:2026-09-10
> 主题:Agent 自发现 MCP 的现实边界、崩溃残留清理、CLI 定位、无 Node 环境
---
## 13. 讨论结论:自操作 Agent 能否自行发现并配置 MCP
### 现状(核实过代码)
`SKILL_MD` 全文不含 `~/.ra3modxml`、launcher 路径、CLI 或任何"工具不存在时怎么办"
的说明。它的第一条指令是"调用 `get_status`"。因此当 skill 已加载但 MCP 工具
不在工具列表里时,agent 只能得到 unknown tool,然后没有下一步。
三个独立缺口:
1. **没有发现面。** 磁盘上的文件名带 `sha1-12` 哈希与 slug
`endpoints/<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",
"displayName": "%ra3modxml.displayName%",
"description": "%ra3modxml.description%",
"version": "0.1.25",
"version": "0.1.26",
"publisher": "lanyi",
"license": "SEE LICENSE IN LICENSE",
"icon": "images/icon.png",
+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,
* debugging, and as a reference for MCP tool implementations.
* Deliberately thin: it does **not** reimplement the query surface. It does
* exactly two things.
*
* Usage examples:
* node out/agent/cli.js --project D:/Mods/Example status
* node out/agent/cli.js --snapshot /path/to/snapshot.json.gz find ExampleUnit
* node out/agent/cli.js --project D:/Mods/Example refs ExampleUnit GameObject
* 1. Forward the call to the live VS Code extension through the shared
* `LiveClient` (same transport, project pinning and validation as the MCP
* server, so the two entry points cannot drift apart).
* 2. Fall back to the on-disk snapshot exported by the extension when no live
* instance is reachable.
*
* Features that need the DOM (element-level provenance) are live-only; the
* CLI reports that clearly instead of returning an empty result that would
* look like "no references exist".
*
* The CLI is not installed into PATH. It is reached through an absolute path,
* normally by an agent following the skill's discovery instructions.
*
* Usage:
* node dist/agent/cli.js status
* node dist/agent/cli.js find <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 {
findAssets,
findDefine,
@@ -20,32 +41,63 @@ import {
resolveIncludeSource,
statusFromSnapshot,
} from "./query";
import { readSnapshotFile, snapshotPathForProject } from "./snapshot";
import type { AgentIndexSnapshot } from "./types";
interface CliOptions {
snapshotPath: string | null;
const EXIT_OK = 0;
const EXIT_USAGE = 2;
const EXIT_UNAVAILABLE = 3;
export interface CliOptions {
projectDir: string | null;
snapshotPath: string | null;
agentHome: string | undefined;
command: string;
args: string[];
depth: number | undefined;
targetTypes: string[];
maxEdges: number | undefined;
includeUnresolved: boolean;
}
function parseArgs(argv: string[]): CliOptions {
export function parseArgs(argv: string[]): CliOptions {
const options: CliOptions = {
snapshotPath: null,
projectDir: null,
snapshotPath: null,
agentHome: undefined,
command: "status",
args: [],
depth: undefined,
targetTypes: [],
maxEdges: undefined,
includeUnresolved: false,
};
const positional: string[] = [];
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--snapshot" || arg === "-s") {
options.snapshotPath = argv[++i] ?? null;
} else if (arg === "--project" || arg === "-p") {
if (arg === "--project" || arg === "-p") {
options.projectDir = argv[++i] ?? null;
} else if (arg === "--snapshot" || arg === "-s") {
options.snapshotPath = argv[++i] ?? null;
} else if (arg === "--agent-home") {
options.agentHome = argv[++i] ?? undefined;
} else if (arg === "--depth" || arg === "-d") {
const raw = argv[++i];
options.depth = raw != null ? Number(raw) : undefined;
} else if (arg === "--target-types" || arg === "-t") {
options.targetTypes = (argv[++i] ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
} else if (arg === "--max-edges") {
const raw = argv[++i];
options.maxEdges = raw != null ? Number(raw) : undefined;
} else if (arg === "--include-unresolved") {
options.includeUnresolved = true;
} else if (arg === "--help" || arg === "-h") {
options.command = "help";
} else if (arg.startsWith("-")) {
// ignore unknown flags
// Ignore unknown flags rather than failing an agent's probing call.
} else {
positional.push(arg);
}
@@ -58,102 +110,214 @@ function parseArgs(argv: string[]): CliOptions {
}
function printHelp(): void {
console.log(`RA3 Mod XML agent snapshot CLI
process.stdout.write(`RA3 Mod XML agent CLI
Usage:
node out/agent/cli.js --project <dir> <command> [args...]
node out/agent/cli.js --snapshot <file> <command> [args...]
node cli.js [--project <dir>] <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:
status
find <id> [type]
refs <id> [type]
list <type> [prefix]
active <file>
define <name>
resolve <source>
status index state (+ source: live or snapshot)
find <id> [type] asset definitions
refs <id> [type] incoming semantic references
outgoing <id> [type] outgoing reference edges (live only)
--depth N --target-types A,B --max-edges N --include-unresolved
list <type> [prefix] assets of one type
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
`);
}
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2));
if (options.command === "help") {
printHelp();
return;
}
const snapshotPath =
options.snapshotPath ??
(options.projectDir ? snapshotPathForProject(options.projectDir) : null);
if (!snapshotPath) {
console.error("No --project or --snapshot provided.");
process.exitCode = 2;
return;
}
const snapshot = await readSnapshotFile(snapshotPath);
if (!snapshot) {
console.error(`Snapshot not found or unreadable: ${snapshotPath}`);
process.exitCode = 3;
return;
}
/** Resolves the project directory, inferring it from cwd when not given. */
export function resolveProjectDir(options: CliOptions): string | null {
if (options.projectDir) return resolve(options.projectDir);
const inferred = findProjectRootUpward(process.cwd());
return inferred;
}
/** Writes JSON to stdout and returns the exit code. */
function emit(value: unknown): number {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
return EXIT_OK;
}
function fail(message: string, code = EXIT_USAGE): number {
process.stderr.write(`${message}\n`);
return code;
}
/** Live-only commands, reported explicitly when live is unreachable. */
export const LIVE_ONLY = new Set(["outgoing", "projects"]);
export function liveArgsFor(options: CliOptions): Record<string, unknown> {
const [a, b] = options.args;
switch (options.command) {
case "status":
console.log(JSON.stringify(statusFromSnapshot(snapshot), null, 2));
break;
case "find":
if (!a) {
console.error("find requires an id.");
process.exitCode = 2;
return;
}
console.log(JSON.stringify(findAssets(snapshot, a, b), null, 2));
break;
return { id: a, type: b };
case "refs":
if (!a) {
console.error("refs requires an id.");
process.exitCode = 2;
return;
}
console.log(JSON.stringify(findReferenceGroups(snapshot, a, b), null, 2));
break;
return { id: a, type: b };
case "outgoing":
return {
id: a,
type: b,
depth: options.depth,
targetTypes: options.targetTypes?.length ? options.targetTypes : undefined,
maxEdges: options.maxEdges,
includeUnresolved: options.includeUnresolved ? true : undefined,
};
case "list":
if (!a) {
console.error("list requires a type.");
process.exitCode = 2;
return;
}
console.log(JSON.stringify(listAssetsByType(snapshot, a, b ?? ""), null, 2));
break;
return { type: a, prefix: b };
case "active":
if (!a) {
console.error("active requires a file path.");
process.exitCode = 2;
return;
}
console.log(JSON.stringify({ active: isFileActive(snapshot, a) }, null, 2));
break;
return { path: a };
case "define":
if (!a) {
console.error("define requires a name.");
process.exitCode = 2;
return;
}
console.log(JSON.stringify(findDefine(snapshot, a), null, 2));
break;
return { name: a };
case "resolve":
if (!a) {
console.error("resolve requires a source.");
process.exitCode = 2;
return;
}
console.log(JSON.stringify(resolveIncludeSource(snapshot, a), null, 2));
break;
return { source: a };
default:
console.error(`Unknown command: ${options.command}`);
process.exitCode = 2;
return {};
}
}
void main();
/** Maps a CLI command to the live/MCP tool name. */
export function toolNameFor(command: string): string {
switch (command) {
case "find":
return "find_asset";
case "refs":
return "find_references";
case "outgoing":
return "get_asset_references";
case "list":
return "list_assets_by_type";
case "active":
return "is_file_active";
case "define":
return "find_define";
case "resolve":
return "resolve_include";
case "projects":
return "list_projects";
default:
return "get_status";
}
}
/** Runs the command against the on-disk snapshot. */
function runSnapshotCommand(
options: CliOptions,
snapshot: AgentIndexSnapshot,
): number {
const [a, b] = options.args;
switch (options.command) {
case "status":
return emit({ source: "snapshot", index: statusFromSnapshot(snapshot) });
case "find":
if (!a) return fail("find requires an id.");
return emit(findAssets(snapshot, a, b));
case "refs":
if (!a) return fail("refs requires an id.");
return emit(findReferenceGroups(snapshot, a, b));
case "list":
if (!a) return fail("list requires a type.");
return emit(listAssetsByType(snapshot, a, b ?? ""));
case "active":
if (!a) return fail("active requires a file path.");
return emit({ active: isFileActive(snapshot, a) });
case "define":
if (!a) return fail("define requires a name.");
return emit(findDefine(snapshot, a.replace(/^\$/, "")));
case "resolve":
if (!a) return fail("resolve requires a source.");
return emit(resolveIncludeSource(snapshot, a));
default:
return fail(`Unknown command: ${options.command}`);
}
}
/** Explains why a live-only command could not run. */
function liveOnlyUnavailable(options: CliOptions): number {
return emit({
index: { state: "no_index" },
source: "unavailable",
error:
options.command === "outgoing"
? "get_asset_references requires a live index: the element context it reports is not stored in the on-disk snapshot. Open the project in VS Code with AI Agent access enabled, then retry."
: "list_projects requires a live index. Open the project in VS Code with AI Agent access enabled, then retry.",
});
}
async function main(): Promise<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 {
isProcessAlive,
readEndpoint,
readEndpointForProject,
} from "./endpoint";
import { LiveClient } from "./liveClient";
import { pruneInstances } from "./instances";
import { readSnapshotFile, snapshotPathForProject } from "./snapshot";
import {
findAssets,
@@ -53,6 +50,7 @@ Use these tools instead of full-text grepping the XML tree when you need exact f
- is_file_active(path) -> whether a file is part of an indexed include stream
- find_define(name) -> $DEFINE definitions
- resolve_include(source) -> candidate source file
- list_projects() -> project roots the live extension has indexed
- get_status() -> current index state
Tips:
@@ -228,6 +226,17 @@ const TOOLS: McpTool[] = [
"get_asset_references requires a live index. Open the project in VS Code (with AI Agent access enabled) and retry.",
}),
},
{
name: "list_projects",
description:
"Lists the project roots the live extension currently has indexed. Use it to discover which projects this server can answer for.",
inputSchema: { type: "object", properties: {} },
handler: () => ({
index: { state: "no_index" },
error:
"list_projects requires a live index. Open the project in VS Code with AI Agent access enabled, then retry.",
}),
},
{
name: "get_usage_guide",
description: "Returns guidance for using the RA3 Mod XML index tools.",
@@ -237,142 +246,7 @@ const TOOLS: McpTool[] = [
];
/** Tools that can only be answered by the live extension server. */
const LIVE_ONLY_TOOLS = new Set(["get_asset_references"]);
export function liveUrlForTool(
endpointUrl: string,
projectDir: string | null,
toolName: string,
args: Record<string, 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);
}
}
const LIVE_ONLY_TOOLS = new Set(["get_asset_references", "list_projects"]);
function sendMessage(message: unknown): void {
process.stdout.write(`${JSON.stringify(message)}\n`);
@@ -390,7 +264,7 @@ async function handleRequest(
message: Record<string, unknown>,
snapshot: AgentIndexSnapshot | null,
projectDir: string | null,
agentHome?: string,
live: LiveClient,
): Promise<unknown | null> {
const method = String(message.method ?? "");
const id = message.id;
@@ -418,12 +292,12 @@ async function handleRequest(
const tool = TOOLS.find((t) => t.name === toolName);
if (!tool) return errorFor(id, -32602, `Unknown tool: ${toolName}`);
const args = (params.arguments ?? {}) as Record<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
// wrong answer is worse than an explicit failure.
liveUnavailableUntil = Date.now() + LIVE_RETRY_COOLDOWN_MS;
live.markUnavailable();
return textResult(id, {
index: { state: "error", projectDir: projectDir ?? undefined },
error: `The live server answered for a different project than "${projectDir}"; refusing the result. Re-run "RA3 Mod XML: Enable AI Agent access…" for this project.`,
@@ -435,14 +309,14 @@ async function handleRequest(
// not conclude "this asset has no references".
return textResult(
id,
live?.payload ?? {
result?.payload ?? {
index: { state: "no_index", projectDir: projectDir ?? undefined },
error: `"${toolName}" requires a live index. Open the project in VS Code with AI Agent access enabled, then retry.`,
},
);
}
const output = live?.payload ?? tool.handler(args, snapshot);
const output = result?.payload ?? tool.handler(args, snapshot);
return textResult(id, output);
}
default:
@@ -474,6 +348,12 @@ async function main(): Promise<void> {
let snapshot: AgentIndexSnapshot | null = null;
if (resolvedSnapshotPath) snapshot = await readSnapshotFile(resolvedSnapshotPath);
// One-shot crash cleanup: a window that died without disposing leaves its
// instance file behind, and whichever instance starts next prunes it.
void pruneInstances(agentHome).catch(() => undefined);
const live = new LiveClient({ projectDir, agentHome });
const rl = createInterface({
input: process.stdin,
crlfDelay: Infinity,
@@ -486,7 +366,7 @@ async function main(): Promise<void> {
} catch {
return;
}
void handleRequest(message, snapshot, projectDir, agentHome).then((response) => {
void handleRequest(message, snapshot, projectDir, live).then((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 { dirname, join } from "node:path";
import { defaultAgentHome } from "./snapshot";
import {
launcherScript,
resolveRuntime,
type AgentRuntime,
} from "./runtime";
export interface McpConfigTarget {
id: string;
@@ -20,6 +25,18 @@ export interface McpConfigTarget {
path: string;
}
/** Result of writing the stable launcher. */
export interface LauncherResult {
path: string;
serverPath: string;
runtime: AgentRuntime;
/**
* True when the launcher runs on the VS Code Electron runtime and therefore
* does not need a Node installation.
*/
nodeFree: boolean;
}
/** File name of the stable launcher on the current platform. */
export function launcherFileName(): string {
return process.platform === "win32" ? "ra3-mod-xml-mcp.cmd" : "ra3-mod-xml-mcp";
@@ -39,34 +56,38 @@ export function bundledMcpServerPath(extensionRoot: string): string {
}
/**
* Creates the stable launcher script. It points to the current extension's
* bundled MCP server and passes the project directory.
* Creates the stable launcher script. It prefers the VS Code Electron runtime
* (so no Node installation is required) and falls back to Node only when that
* binary is missing.
*
* Returns the launcher path plus the resolved runtime, so callers can warn
* when the launcher will depend on Node being on PATH.
*/
export async function writeLauncher(
extensionRoot: string,
projectDir: string,
agentHome = defaultAgentHome(),
): Promise<string> {
runtime?: AgentRuntime,
): Promise<LauncherResult> {
const server = bundledMcpServerPath(extensionRoot);
const launcher = launcherPath(agentHome);
const resolved = runtime ?? resolveRuntime();
const script = launcherScript({
runtime: resolved,
serverPath: server,
projectDir,
});
await mkdir(dirname(launcher), { recursive: true });
if (process.platform === "win32") {
const content = [
"@echo off",
`node "${server}" --project "${projectDir}"`,
"",
].join("\r\n");
await writeFile(launcher, content, "utf8");
} else {
const content = [
"#!/usr/bin/env sh",
`exec node "${server}" --project "${projectDir}"`,
"",
].join("\n");
await writeFile(launcher, content, "utf8");
await writeFile(launcher, script, "utf8");
if (process.platform !== "win32") {
await chmod(launcher, 0o755);
}
return launcher;
return {
path: launcher,
serverPath: server,
runtime: resolved,
nodeFree: resolved.kind === "electron",
};
}
/** MCP client config entry for one project. */
+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
can answer the question directly.
## Reaching the index
Work down this list and stop at the first step that works.
1. **The query tools are already in your tool list** (names like
\`find_asset\`, \`get_status\`). Use them directly. You do not need to
configure anything.
2. **The tools are not available, but you can run commands.** The index is
reachable without any MCP setup, because the MCP server speaks JSON-RPC over
stdio. Read \`~/.ra3modxml/index.json\` first: it is a small, stable
discovery manifest listing the live instances and their project roots.
- If it does not exist, the extension has never been enabled for this
project. Tell the user to open the project in VS Code and run
"RA3 Mod XML: Enable AI Agent access…". Do not guess or search further.
- If it exists, use the launcher at \`~/.ra3modxml/ra3-mod-xml-mcp.cmd\`
(Windows) or \`~/.ra3modxml/ra3-mod-xml-mcp\` (elsewhere), or call the
bundled server directly through that instance's runtime. Feed it one
JSON-RPC request per line on stdin, for example:
\`\`\`
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"find_asset","arguments":{"id":"AthenaCannon","type":"GameObject"}}}
\`\`\`
A CLI with the same capabilities is bundled alongside the MCP server
(\`cli.js\` next to \`mcpServer.js\` in the extension's \`dist/agent/\`).
Run it with the same runtime as the launcher; \`cli.js help\` lists the
commands. It answers from the live index when VS Code is running and
falls back to the last exported snapshot otherwise. Commands that need
element context (\`outgoing\`, \`projects\`) require the live index and will
say so explicitly instead of returning an empty result.
3. **You can write configuration, if the user agrees.** You may add the MCP
server to the your own harness' configuration.
Check what your own client supports (e.g. if a restart is required)
before promising otherwise.
If none of the steps work, say the index is unavailable and read the XML files directly.
## How to use
1. Call \`get_status\` first when you are unsure whether an index is available,
@@ -83,8 +123,7 @@ can answer the question directly.
- \`get_asset_references(id, type?, depth?, targetTypes?)\` for outgoing
references (what this asset uses, and where that link is written).
- \`list_assets_by_type(type, prefix?, limit?)\` for browsing assets.
- \`is_file_active(path)\` to determine whether a file is included in an indexed stream.
- \`find_define(name)\` for $DEFINE constants.
- \`is_file_active(path)\` to determine whether a file is included in an indexed stream. - \`find_define(name)\` for $DEFINE constants.
- \`resolve_include(source)\` for Include source candidates.
3. Asset ids are case-insensitive.
4. If an id exists for multiple asset types, pass the type filter to avoid mixing definitions.
+112 -10
View File
@@ -54,6 +54,13 @@ import {
writeEndpoint,
writeEndpointForProject,
} from "./agent/endpoint";
import {
clearInstance,
makeInstanceId,
pruneInstances,
writeInstance,
writeManifest,
} from "./agent/instances";
const XML_SELECTOR: vscode.DocumentSelector = [{ language: "xml" }];
/** Safety-net refresh interval while a rebuild is running. */
@@ -159,9 +166,25 @@ export function activate(context: vscode.ExtensionContext): void {
let agentLocalServer: LocalServerHandle | null = null;
/** Project roots whose per-project endpoint file this window wrote. */
const agentEndpointProjects = new Set<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> => {
if (agentLocalServer) return;
try {
// Clean up instances left behind by crashed windows. Any instance can do
// this, so a crash does not have to wait for the same workspace to be
// reopened before its stale entry disappears.
const pruned = await pruneInstances().catch(() => ({ removed: [], kept: [] }));
if (pruned.removed.length) {
ws.log(
`[agent] pruned ${pruned.removed.length} stale instance(s): ${pruned.removed.join(", ")}`,
);
}
const handle = await startLocalServer({
// Route by explicit project so a query can never be answered by
// whichever project the active editor happens to point at.
@@ -177,6 +200,7 @@ export function activate(context: vscode.ExtensionContext): void {
const url = `http://127.0.0.1:${handle.port}`;
const projects = ws.getProjectRoots();
const endpoint = {
instanceId: agentInstanceId,
url,
token: handle.token,
projectDir: ws.projectRoot ?? undefined,
@@ -193,8 +217,12 @@ export function activate(context: vscode.ExtensionContext): void {
agentEndpointProjects.add(project);
ws.log(`[agent-local-server] endpoint for ${project} -> ${file}`);
}
// This window's own instance file (authoritative for liveness/pruning).
const instanceFile = await writeInstance(endpoint);
ws.log(`[agent-local-server] instance -> ${instanceFile}`);
// Legacy/global pointer for tooling that does not know the project.
await writeEndpoint(endpoint);
await writeManifest(await refreshInstanceFiles(url, handle.token, projects));
ws.log(`[agent-local-server] listening on ${url}`);
} catch (err) {
ws.log(
@@ -202,24 +230,54 @@ export function activate(context: vscode.ExtensionContext): void {
);
}
};
/**
* Re-reads every live instance file (including other windows') and rewrites
* the merged discovery manifest.
*/
const refreshInstanceFiles = async (
url: string,
token: string,
projects: string[],
) => {
const instance = {
instanceId: agentInstanceId,
url,
token,
projectDir: ws.projectRoot ?? undefined,
projects,
processId: process.pid,
updatedAt: new Date().toISOString(),
};
await writeInstance(instance).catch(() => undefined);
const { kept } = await pruneInstances().catch(() => ({
removed: [],
kept: [] as typeof instance[],
}));
// Ensure this window is present even if its file was just pruned/written.
const others = kept.filter((k) => k.instanceId !== agentInstanceId);
return [...others, instance];
};
const stopAgentLocalServer = async (): Promise<void> => {
if (agentLocalServer) {
const server = agentLocalServer;
agentLocalServer = null;
await server.close().catch(() => undefined);
}
// Only remove the endpoint files this window wrote: another VS Code
// window may still be serving its own projects.
// Only remove what this window owns: another VS Code window may still be
// serving its own projects.
for (const project of agentEndpointProjects) {
await clearEndpointForProject(project).catch(() => undefined);
}
agentEndpointProjects.clear();
await clearInstance(agentInstanceId).catch(() => undefined);
await clearEndpoint().catch(() => undefined);
await writeManifest([]).catch(() => undefined);
};
/**
* Publishes per-project endpoint files for every project this window now
* Republishes this window's endpoint/instance files for every project it now
* knows about. Called on each index update so projects discovered later get
* an endpoint without restarting the server.
* an endpoint without restarting the server, and so the merged manifest is
* refreshed.
*/
const refreshAgentEndpoints = async (): Promise<void> => {
if (!agentLocalServer) return;
@@ -243,6 +301,8 @@ export function activate(context: vscode.ExtensionContext): void {
);
}
}
const instances = await refreshInstanceFiles(url, token, projects);
await writeManifest(instances);
};
context.subscriptions.push({
dispose: () => {
@@ -487,7 +547,17 @@ export function activate(context: vscode.ExtensionContext): void {
context.extensionUri.fsPath,
projectDir,
);
const configJson = mcpConfigJson(launcher, projectDir);
const configJson = mcpConfigJson(launcher.path, projectDir);
ws.log(
`[agent] launcher runtime=${launcher.runtime.kind} (${launcher.runtime.executable}), nodeFree=${launcher.nodeFree}`,
);
if (!launcher.nodeFree) {
void vscode.window.showWarningMessage(
t(
"RA3 Mod XML: the AI Agent launcher will use Node from PATH. Install Node, or run VS Code from a normal installation so the bundled runtime can be used.",
),
);
}
agentAccessEnabled = true;
await context.workspaceState.update("ra3modxml.agentAccessEnabled", true);
void startAgentLocalServer();
@@ -538,17 +608,17 @@ export function activate(context: vscode.ExtensionContext): void {
t("RA3 Mod XML Agent Skill installed to {0}", target),
);
} else if (pick?.id === "claude") {
await addMcpServerToConfigFile(claudeDesktopConfigPath(), launcher, projectDir);
await addMcpServerToConfigFile(claudeDesktopConfigPath(), launcher.path, projectDir);
void vscode.window.showInformationMessage(
t("RA3 Mod XML MCP config written to {0}", claudeDesktopConfigPath()),
);
} else if (pick?.id === "cursor-global") {
await addMcpServerToConfigFile(cursorGlobalConfigPath(), launcher, projectDir);
await addMcpServerToConfigFile(cursorGlobalConfigPath(), launcher.path, projectDir);
void vscode.window.showInformationMessage(
t("RA3 Mod XML MCP config written to {0}", cursorGlobalConfigPath()),
);
} else if (pick?.id === "cursor-project") {
await addMcpServerToConfigFile(cursorProjectConfigPath(projectDir), launcher, projectDir);
await addMcpServerToConfigFile(cursorProjectConfigPath(projectDir), launcher.path, projectDir);
void vscode.window.showInformationMessage(
t("RA3 Mod XML MCP config written to {0}", cursorProjectConfigPath(projectDir)),
);
@@ -638,11 +708,43 @@ export function activate(context: vscode.ExtensionContext): void {
),
);
if (agentAccessEnabled) void startAgentLocalServer();
/**
* Refreshes the stable launcher for every known project. Called on
* activation for already-enabled workspaces so a VS Code update (or an
* extension update) repoints the launcher at the current runtime without
* the user having to re-run the enable command.
*/
const refreshLaunchers = async (): Promise<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 ws.initialize().then(() => {
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 assert from "node:assert/strict";
import {
liveUrlForTool,
normalizePath,
responseProjectMismatch,
} from "../out/agent/mcpServer.js";
const PROJECT_A = "D:/Mods/Example";
// The URL building, project pinning and cross-project refusal now live in
// `liveClient` and are covered by agentLiveClient.test.mjs. What remains
// MCP-layer-specific is that the server module is *importable*: it must not
// start its stdio loop as a side effect of being imported, otherwise tests and
// any tool that merely inspects the module would hang waiting on stdin.
test("live URLs always pin the requested project", () => {
const url = liveUrlForTool("http://127.0.0.1:1234", PROJECT_A, "find_asset", {
id: "AthenaCannon",
type: "GameObject",
});
assert.ok(url);
const parsed = new URL(url);
assert.equal(parsed.pathname, "/find_asset");
assert.equal(parsed.searchParams.get("project"), PROJECT_A);
assert.equal(parsed.searchParams.get("id"), "AthenaCannon");
assert.equal(parsed.searchParams.get("type"), "GameObject");
test("the MCP server module imports without starting its stdio loop", async () => {
const mod = await import("../out/agent/mcpServer.js");
assert.equal(typeof mod, "object");
// Reaching this line proves main() did not run on import.
});
test("get_asset_references serialises its list and scalar options", () => {
const url = liveUrlForTool("http://127.0.0.1:1234/", PROJECT_A, "get_asset_references", {
id: "AthenaCannon",
depth: 2,
targetTypes: ["WeaponTemplate", "GameObject"],
maxEdges: 25,
includeUnresolved: true,
});
assert.ok(url);
const parsed = new URL(url);
assert.equal(parsed.pathname, "/get_asset_references");
assert.equal(parsed.searchParams.get("depth"), "2");
assert.equal(parsed.searchParams.get("targetTypes"), "WeaponTemplate,GameObject");
assert.equal(parsed.searchParams.get("maxEdges"), "25");
assert.equal(parsed.searchParams.get("includeUnresolved"), "true");
assert.equal(parsed.searchParams.get("project"), PROJECT_A);
});
test("live URLs omit the project selector when none is configured", () => {
const url = liveUrlForTool("http://127.0.0.1:1234", null, "get_status", {});
assert.equal(url, "http://127.0.0.1:1234/status");
});
test("unknown tools have no live URL", () => {
assert.equal(liveUrlForTool("http://127.0.0.1:1", PROJECT_A, "nope", {}), null);
});
test("responseProjectMismatch flags answers from another project", () => {
// Matching project (case/separator-insensitive) is accepted.
assert.equal(
responseProjectMismatch({ index: { projectDir: "d:/mods/example" } }, PROJECT_A),
false,
);
// A different project must be rejected: this is the multi-window cross-talk guard.
assert.equal(
responseProjectMismatch({ index: { projectDir: "D:/Mods/Other" } }, PROJECT_A),
true,
);
// No project configured, or no projectDir in the payload: nothing to check.
assert.equal(responseProjectMismatch({ index: { state: "ready" } }, PROJECT_A), false);
assert.equal(
responseProjectMismatch({ index: { projectDir: "D:/Mods/Other" } }, null),
false,
);
assert.equal(responseProjectMismatch(null, PROJECT_A), false);
});
test("normalizePath is case- and separator-insensitive", () => {
assert.equal(normalizePath("D:\\Mods\\Example\\"), normalizePath("d:/mods/example"));
test("the CLI module imports without running", async () => {
const mod = await import("../out/agent/cli.js");
assert.equal(typeof mod.parseArgs, "function");
assert.equal(typeof mod.toolNameFor, "function");
assert.equal(typeof mod.liveArgsFor, "function");
assert.ok(mod.LIVE_ONLY instanceof Set);
});
+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 () => {
const home = mkdtempSync(join(tmpdir(), "ra3-skill-home-"));
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);
})();