4 Commits
Author SHA1 Message Date
lanyi 4f422a748e find asset 2026-09-10 23:16:36 +02:00
lanyi a25adacaee finalize 2026-09-10 21:39:32 +02:00
lanyi 5993da4ce6 ai agent 2026-09-10 19:18:15 +02:00
lanyi 3a3d70efeb ai agent 2026-09-10 17:03:10 +02:00
46 changed files with 9914 additions and 17 deletions
+54
View File
@@ -1,5 +1,42 @@
# Changelog
## 0.1.26 — 2026-09-10
### Added
- **AI Agent access (MCP).** `RA3 Mod XML: Enable AI Agent access…` exposes the
semantic index to AI agent clients through a local, read-only MCP (Model
Context Protocol) server. It exports a stable snapshot, writes a launcher
under `~/.ra3modxml/`, starts a loopback query server while VS Code is
running, and offers to install the Agent Skill and/or write the MCP client
configuration (Claude Desktop, Cursor global/project, or a copied generic
block). `Disable AI Agent access` stops the live server for the workspace;
`Uninstall AI Agent integration…` removes MCP config entries, the launcher,
and Skill copies after a confirmation prompt.
- **Agent Skill (`ra3-mod-xml`).** `RA3 Mod XML: Install Agent Skill…` installs
`SKILL.md` plus a query-tool reference to `~/.agents/skills/`, and optionally
to Claude Code or project-local skill directories. It explains when the index
applies and how to reach it with or without MCP. `Uninstall Agent Skill…`
removes only directories that still carry the extension's marker.
- **`RA3 Mod XML: Export AI Agent index snapshot`** writes the gzipped,
versioned snapshot that the MCP server and CLI fall back to when VS Code is
closed.
- **Agent CLI** (`dist/agent/cli.js`): live-index first, exported-snapshot
fallback. `outgoing` and `projects` need element context and report that
explicitly instead of returning an empty result.
- **First-run / upgrade introduction.** After the first index, the extension
offers AI Agent access once (per machine). Upgrades from a build that already
had the feature stay silent, and "Don't show again" is remembered.
- **Live discovery and multi-window safety.** Per-project endpoint files, one
`instances/` entry per VS Code window, and a merged `~/.ra3modxml/index.json`.
Queries are pinned to a project and a mismatched answer is refused. A crashed
window's instance file is pruned by the next activation.
- **No Node installation required.** The MCP launcher for AI Agent Access runs
the bundled server on VS Code's own Electron binary (`ELECTRON_RUN_AS_NODE=1`)
and falls back to `node` on `PATH` only when that binary is missing.
Launchers are refreshed on activation, so moving or updating VS Code does not
break an existing MCP configuration.
## 0.1.25 — 2026-08-11
### Changed
@@ -7,6 +44,23 @@
- Repository and homepage links now point to the GitHub mirror; README links to both GitHub and Gitea.
- Extension now activates in untrusted (Restricted Mode) workspaces; workspace-defined `ra3modxml.sdkPath` and `ra3modxml.additionalDataSearchPaths` are ignored until the workspace is trusted.
- **Find asset.** `RA3 Mod XML: Find asset (id or Type:Id)…` opens a search
picker over the whole index: type `Type:Id`, an exact id or a partial id and
the result list updates as you type (`Type:` alone lists every asset of that
type). Rows show the asset type, origin (project / SDK / manifest), reference
count and defining file; accepting one jumps to the definition. When a
`type:id` is defined in several places (mod override + vanilla source +
compiled manifest) a second picker chooses which definition to open. The
query can also be prefilled from the current selection through the editor
context menu entry.
### Fixed
- The editor context-menu entries were declared under a top-level `menus` key
instead of `contributes.menus`, so VS Code never showed them. Adding the new
search entry moved the block where it belongs; `Find unreferenced assets of
this type` now appears in the editor context menu as documented.
## 0.1.24 — 2026-08-11
### Fixed
+97
View File
@@ -86,6 +86,14 @@ Catch common modding mistakes while you edit.
The extension can analyze the entire workspace rather than only the file currently open.
**Find asset** searches the index for an asset by `Type:Id`, by exact id, or by a partial id — typed directly into the picker, with results updating as you type. The result list shows each asset's type, origin (project / SDK / manifest), reference count and defining file; accepting a result jumps to its definition. When the same `type:id` exists in several places (for example a mod override of a vanilla asset), a second picker asks which definition to open.
Run:
`RA3 Mod XML: Find asset (id or Type:Id)…`
You can also select an id in the editor and use the context menu entry to search for it.
**Find unreferenced assets** lists project assets that are not referenced anywhere in the workspace, helping identify obsolete or accidentally unused definitions.
Run:
@@ -153,8 +161,97 @@ to an empty string opts out of SDK features permanently.
* `RA3 Mod XML: Clear caches and rebuild`
* `RA3 Mod XML: Configure SDK path…`
* `RA3 Mod XML: Show cache report`
* `RA3 Mod XML: Find asset (id or Type:Id)…`
* `RA3 Mod XML: Find unreferenced assets…`
* `RA3 Mod XML: Find unreferenced assets of this type`
* `RA3 Mod XML: Enable AI Agent access…`
* `RA3 Mod XML: Disable AI Agent access`
* `RA3 Mod XML: Install Agent Skill…`
* `RA3 Mod XML: Uninstall Agent Skill…`
* `RA3 Mod XML: Uninstall AI Agent integration…`
* `RA3 Mod XML: Export AI Agent index snapshot`
## AI Agent Access
The extension can expose its semantic asset index to AI Agent clients through
a local, read-only MCP (Model Context Protocol) server. This is optional and
does not modify `PATH` or install global commands.
Run:
`RA3 Mod XML: Enable AI Agent access…`
The command:
1. Exports a stable index snapshot for the active project.
2. Creates a stable launcher under `~/.ra3modxml/`.
3. Starts a local read-only query server while VS Code is running.
4. Offers to:
* install the `ra3-mod-xml` Agent Skill to `~/.agents/skills/` (and
optionally to Claude Code or project-local skill directories),
* write the MCP client configuration for Claude Desktop or Cursor,
* copy a generic MCP configuration block.
The MCP server prefers the live in-memory index while the extension is
running and falls back to the last exported snapshot when VS Code is closed.
The exposed tools include asset lookup, incoming semantic references,
**outgoing reference edges** (`get_asset_references`: which assets an asset
uses, through which element/attribute, and where in the XML), active-file
checks, `$DEFINE` lookup, and Include source resolution.
`get_asset_references` follows `inheritFrom` ancestors and marks the
ancestor's entries with `definedIn`, so "this unit has no `WeaponSetUpdate`,
but the base unit it inherits from does" is answerable in a single call. It is
bounded by `depth` (default 1, max 3), `targetTypes` and `maxEdges`, and
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.
### Installing and removing the integration
* `RA3 Mod XML: Install Agent Skill…` works with or without an indexed project.
The default target is the cross-agent `~/.agents/skills/ra3-mod-xml/`
convention; Claude Code (`~/.claude/skills/`), project-local `.agents/skills/`
and `.claude/skills/`, and any custom folder can be selected too.
* `RA3 Mod XML: Uninstall Agent Skill…` lists the recorded copies and removes
only directories that still carry the extension's marker. A folder the user
replaced or created by hand is never deleted.
* `RA3 Mod XML: Disable AI Agent access` stops the live query server for the
current workspace (project discovery files for this window are cleaned up).
Installed skills and MCP client configurations are kept.
* `RA3 Mod XML: Uninstall AI Agent integration…` is the cleanup wizard. It can
stop live access, remove installed Agent Skills, delete the MCP entries this
extension wrote (from the recorded files and the conventional Claude
Desktop / Cursor paths, leaving other servers untouched), and delete the
stable launcher. Nothing is removed before a confirmation prompt.
After an extension upgrade, recorded Skill copies are rewritten automatically
to the new version; directories without the extension's marker are left alone.
The first time an AI Agent feature becomes available (fresh install, or an
upgrade from an older version), the extension offers to enable it after the
first index. It never asks twice: choosing "Don't show again" — or simply
ignoring the message — is remembered, and later upgrades stay silent.
See `docs/ai-agent-integration-plan.md` for the full design and progress.
## Requirements
+56
View File
@@ -86,6 +86,14 @@
扩展可以分析整个工作区,而不仅仅是当前打开的文件。
**查找资产** 可以在索引中按 `类型:ID`、精确 ID 或部分 ID 搜索资产——直接在输入框中输入,结果随输入实时更新。结果列表显示资产的类型、来源(项目 / SDK / manifest)、引用次数与所在文件;选中后会跳转到其定义。当同一个 `类型:ID` 存在多处定义时(例如 mod 覆盖了原版资产),会再弹出一次选择框让你选择打开哪一处定义。
运行:
`RA3 Mod XML: Find asset (id or Type:Id)…`
你也可以在编辑器中选中一个 ID,然后用右键菜单直接搜索它。
**查找未引用的资产** 会列出工作区中任何地方都未被引用的项目资产,帮助识别过时或意外未使用的定义。
运行:
@@ -150,8 +158,56 @@
* `RA3 Mod XML: Clear caches and rebuild`(清除缓存并重建)
* `RA3 Mod XML: Configure SDK path…`(配置 SDK 路径)
* `RA3 Mod XML: Show cache report`(显示缓存报告)
* `RA3 Mod XML: Find asset (id or Type:Id)…`(查找资产(ID 或 类型:ID)…)
* `RA3 Mod XML: Find unreferenced assets…`(查找未引用的资产…)
* `RA3 Mod XML: Find unreferenced assets of this type`(查找此类型的未引用资产)
* `RA3 Mod XML: Enable AI Agent access…`(启用 AI Agent 访问…)
* `RA3 Mod XML: Disable AI Agent access`(禁用 AI Agent 访问)
* `RA3 Mod XML: Install Agent Skill…`(安装 Agent Skill…)
* `RA3 Mod XML: Uninstall Agent Skill…`(卸载 Agent Skill…)
* `RA3 Mod XML: Uninstall AI Agent integration…`(卸载 AI Agent 集成…)
* `RA3 Mod XML: Export AI Agent index snapshot`(导出 AI Agent 索引快照)
## AI Agent 访问
扩展可以通过本地只读 MCP(Model Context Protocol)Server,把语义索引提供给 AI Agent 使用。该功能可选,不会修改 `PATH`,也不会注册全局命令。
运行:
`RA3 Mod XML: Enable AI Agent access…`
该命令会:
1. 为当前项目导出稳定的索引快照;
2. 在 `~/.ra3modxml/` 下创建稳定 launcher;
3. 在 VS Code 运行期间启动本地只读查询服务;
4. 让用户选择:
* 安装 `ra3-mod-xml` Agent Skill 到 `~/.agents/skills/`(也可选择 Claude Code 或项目级目录);
* 写入 Claude Desktop / Cursor 的 MCP 配置;
* 复制通用 MCP 配置。
MCP Server 在扩展运行时优先查询内存中的实时索引;扩展关闭后回退到最近一次导出的快照。暴露的工具包括资产定义查询、语义引用查询、**正向引用边**(`get_asset_references`:该资产用了哪些资产、通过哪个元素/属性、写在 XML 的哪一行)、文件是否有效 include、`$DEFINE` 查询以及 Include source 解析。
`get_asset_references` 会沿 `inheritFrom` 祖先链遍历,并用 `definedIn` 标出条目实际写在哪个祖先的文件里,因此"这个单位自己没有 `WeaponSetUpdate`,但它继承的基础单位有"可以在一次调用里回答。它受 `depth`(默认 1,上限 3)、`targetTypes` 与 `maxEdges` 三重限制,并在截断时显式报告,而不是静默丢弃结果。
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 运行时走实时索引,否则读导出的快照;需要元素上下文的命令会明确说明,而不是返回空结果。
### 安装与移除
* `RA3 Mod XML: Install Agent Skill…` 不要求项目已建立索引。默认安装到跨 agent 的 `~/.agents/skills/ra3-mod-xml/`,也可选择 Claude Code(`~/.claude/skills/`)、项目级 `.agents/skills/` 与 `.claude/skills/`,或任意自定义文件夹。
* `RA3 Mod XML: Uninstall Agent Skill…` 列出已记录的安装位置,只删除仍带有本扩展标记的目录;被用户替换或手工创建的目录不会被删除。
* `RA3 Mod XML: Disable AI Agent access` 停止当前工作区的实时查询服务并清理本窗口的发现文件;已安装的 Skill 与 MCP 客户端配置会保留。
* `RA3 Mod XML: Uninstall AI Agent integration…` 是清理向导:可停止实时访问、移除已安装的 Agent Skill、删除本扩展写入的 MCP 配置项(来自记录文件与常规的 Claude Desktop / Cursor 路径,不会动其他 server),以及删除稳定 launcher。确认之前不会删除任何内容。
扩展升级后,记录中的 Skill 副本会自动重写到新版本;不带本扩展标记的目录不会被改写。
当 AI Agent 功能首次可用时(全新安装,或从旧版本升级),扩展会在第一次索引完成后询问是否启用。它不会反复打扰:选择"不再提示"——或者干脆忽略——都会被记住,之后的升级保持静默。
完整设计与进度见 `docs/ai-agent-integration-plan.md`。
## 环境要求
File diff suppressed because it is too large Load Diff
+14 -2
View File
@@ -1,11 +1,23 @@
import * as esbuild from "esbuild";
import { rm } from "node:fs/promises";
const watch = process.argv.includes("--watch");
// Remove previous outputs first. esbuild only overwrites the files its current
// entry points produce, so without this a packaged VSIX can ship stale
// artifacts from an older layout (e.g. the pre-`outbase` dist/mcpServer.js
// next to the current dist/agent/mcpServer.js).
await rm("dist", { recursive: true, force: true });
const ctx = await esbuild.context({
entryPoints: ["./src/extension.ts"],
entryPoints: [
"./src/extension.ts",
"./src/agent/mcpServer.ts",
"./src/agent/cli.ts",
],
bundle: true,
outfile: "dist/extension.js",
outdir: "dist",
outbase: "src",
external: ["vscode"],
format: "cjs",
platform: "node",
+72 -1
View File
@@ -153,5 +153,76 @@
"Reference \"{0}\" has no definition of type `{1}` (ids with the same name exist for other types)": "Reference \"{0}\" has no definition of type `{1}` (ids with the same name exist for other types)",
"Reference \"{0}\" has no definition of the expected declared type (ids with the same name exist for other types)": "Reference \"{0}\" has no definition of the expected declared type (ids with the same name exist for other types)",
"Reference \"{0}\" has no matching definition (ids with the same name exist for other types)": "Reference \"{0}\" has no matching definition (ids with the same name exist for other types)",
"Unresolved reference \"{0}\" (not found in the current index)": "Unresolved reference \"{0}\" (not found in the current index)"
"Unresolved reference \"{0}\" (not found in the current index)": "Unresolved reference \"{0}\" (not found in the current index)",
"RA3 Mod XML: no index available yet. Wait for indexing to finish before exporting an AI Agent snapshot.": "RA3 Mod XML: no index available yet. Wait for indexing to finish before exporting an AI Agent snapshot.",
"RA3 Mod XML: exported AI Agent index snapshot to {0}": "RA3 Mod XML: exported AI Agent index snapshot to {0}",
"Reveal in Explorer": "Reveal in Explorer",
"RA3 Mod XML: failed to export AI Agent index snapshot: {0}": "RA3 Mod XML: failed to export AI Agent index snapshot: {0}",
"RA3 Mod XML: no index available yet. Wait for indexing to finish before enabling AI Agent access.": "RA3 Mod XML: no index available yet. Wait for indexing to finish before enabling AI Agent access.",
"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.": "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.",
"Install Agent Skill (recommended)": "Install Agent Skill (recommended)",
"Write MCP config to Claude Desktop": "Write MCP config to Claude Desktop",
"Write MCP config to Cursor (global)": "Write MCP config to Cursor (global)",
"Write MCP config to Cursor (project)": "Write MCP config to Cursor (project)",
"Copy MCP config": "Copy MCP config",
"RA3 Mod XML AI Agent access enabled. Choose an optional next step.": "RA3 Mod XML AI Agent access enabled. Choose an optional next step.",
"RA3 Mod XML Agent Skill installed to {0}": "RA3 Mod XML Agent Skill installed to {0}",
"RA3 Mod XML: could not write the Agent Skill to {0}": "RA3 Mod XML: could not write the Agent Skill to {0}",
"RA3 Mod XML MCP config written to {0}": "RA3 Mod XML MCP config written to {0}",
"RA3 Mod XML MCP config copied to clipboard.": "RA3 Mod XML MCP config copied to clipboard.",
"RA3 Mod XML: failed to enable AI Agent access: {0}": "RA3 Mod XML: failed to enable AI Agent access: {0}",
"Default (~/.agents/skills)": "Default (~/.agents/skills)",
"Claude Code (~/.claude/skills)": "Claude Code (~/.claude/skills)",
"Current project .agents/skills": "Current project .agents/skills",
"Current project .claude/skills": "Current project .claude/skills",
"Choose a custom folder…": "Choose a custom folder…",
"The skill is installed as <folder>/{0}": "The skill is installed as <folder>/{0}",
"Select Agent Skill install locations": "Select Agent Skill install locations",
"Choose a skill folder": "Choose a skill folder",
"Choose the folder that should contain the {0} skill": "Choose the folder that should contain the {0} skill",
"RA3 Mod XML: could not write the Agent Skill (0 of {0} locations).": "RA3 Mod XML: could not write the Agent Skill (0 of {0} locations).",
"RA3 Mod XML Agent Skill installed to {0} location(s); {1} failed.": "RA3 Mod XML Agent Skill installed to {0} location(s); {1} failed.",
"RA3 Mod XML Agent Skill installed to {0} location(s).": "RA3 Mod XML Agent Skill installed to {0} location(s).",
"Show installed skills": "Show installed skills",
"RA3 Mod XML: no recorded Agent Skill installation to remove.": "RA3 Mod XML: no recorded Agent Skill installation to remove.",
"missing — will be dropped from the record": "missing — will be dropped from the record",
"installed by this extension (v{0})": "installed by this extension (v{0})",
"not managed by this extension — will be skipped": "not managed by this extension — will be skipped",
"No RA3 Mod XML skill marker found; remove it manually if you want it gone.": "No RA3 Mod XML skill marker found; remove it manually if you want it gone.",
"Select Agent Skill installations to remove": "Select Agent Skill installations to remove",
"RA3 Mod XML: removed {0} Agent Skill installation(s); {1} skipped (not managed by this extension).": "RA3 Mod XML: removed {0} Agent Skill installation(s); {1} skipped (not managed by this extension).",
"RA3 Mod XML: removed {0} Agent Skill installation(s).": "RA3 Mod XML: removed {0} Agent Skill installation(s).",
"RA3 Mod XML: AI Agent access disabled for this workspace. Installed skills and MCP client configurations were kept.": "RA3 Mod XML: AI Agent access disabled for this workspace. Installed skills and MCP client configurations were kept.",
"Uninstall AI Agent integration…": "Uninstall AI Agent integration…",
"Stop live AI Agent access in this window": "Stop live AI Agent access in this window",
"Remove installed Agent Skills": "Remove installed Agent Skills",
"{0} location(s)": "{0} location(s)",
"Remove MCP client configuration entries": "Remove MCP client configuration entries",
"Claude Desktop / Cursor and files recorded by this extension": "Claude Desktop / Cursor and files recorded by this extension",
"Remove the stable MCP launcher": "Remove the stable MCP launcher",
"Select what to remove (nothing is removed until you confirm)": "Select what to remove (nothing is removed until you confirm)",
"Remove the selected AI Agent components?": "Remove the selected AI Agent components?",
"Remove": "Remove",
"live access stopped": "live access stopped",
"{0} skill installation(s) removed": "{0} skill installation(s) removed",
"{0} unmanaged skill folder(s) skipped": "{0} unmanaged skill folder(s) skipped",
"MCP config removed from {0} file(s)": "MCP config removed from {0} file(s)",
"MCP launcher removed": "MCP launcher removed",
"RA3 Mod XML AI Agent cleanup: {0}.": "RA3 Mod XML AI Agent cleanup: {0}.",
"RA3 Mod XML: this version can expose the project's asset index to AI agents (MCP + Agent Skill). Enable it?": "RA3 Mod XML: this version can expose the project's asset index to AI agents (MCP + Agent Skill). Enable it?",
"Enable AI Agent access": "Enable AI Agent access",
"Learn more": "Learn more",
"Don't show again": "Don't show again",
"RA3 Mod XML: Find asset": "RA3 Mod XML: Find asset",
"Id, partial id or Type:Id (e.g. WeaponTemplate:AssaultRifle)": "Id, partial id or Type:Id (e.g. WeaponTemplate:AssaultRifle)",
"Type an id, a partial id or Type:Id to search.": "Type an id, a partial id or Type:Id to search.",
"No asset matches \"{0}\".": "No asset matches \"{0}\".",
"{0} of {1} matches — keep typing to narrow the list.": "{0} of {1} matches — keep typing to narrow the list.",
"1 asset found.": "1 asset found.",
"{0} assets found.": "{0} assets found.",
"{0} definitions": "{0} definitions",
"Select the definition to open": "Select the definition to open",
"RA3 Mod XML: no XML source location for {0} ({1}).": "RA3 Mod XML: no XML source location for {0} ({1}).",
"RA3 Mod XML: the index has no assets yet — wait for indexing to finish.": "RA3 Mod XML: the index has no assets yet — wait for indexing to finish.",
"local": "local"
}
+72 -1
View File
@@ -153,5 +153,76 @@
"Reference \"{0}\" has no definition of type `{1}` (ids with the same name exist for other types)": "引用 \"{0}\" 没有类型为 `{1}` 的定义(其他类型存在同名 id)",
"Reference \"{0}\" has no definition of the expected declared type (ids with the same name exist for other types)": "引用 \"{0}\" 没有符合声明类型的定义(其他类型存在同名 id)",
"Reference \"{0}\" has no matching definition (ids with the same name exist for other types)": "引用 \"{0}\" 没有匹配的定义(其他类型存在同名 id)",
"Unresolved reference \"{0}\" (not found in the current index)": "无法解析的引用 \"{0}\"(当前索引中未找到)"
"Unresolved reference \"{0}\" (not found in the current index)": "无法解析的引用 \"{0}\"(当前索引中未找到)",
"RA3 Mod XML: no index available yet. Wait for indexing to finish before exporting an AI Agent snapshot.": "RA3 Mod XML:暂无可用索引。请等待索引完成后再导出 AI Agent 快照。",
"RA3 Mod XML: exported AI Agent index snapshot to {0}": "RA3 Mod XML:已导出 AI Agent 索引快照到 {0}",
"Reveal in Explorer": "在资源管理器中显示",
"RA3 Mod XML: failed to export AI Agent index snapshot: {0}": "RA3 Mod XML:导出 AI Agent 索引快照失败:{0}",
"RA3 Mod XML: no index available yet. Wait for indexing to finish before enabling AI Agent access.": "RA3 Mod XML:暂无可用索引。请等待索引完成后再启用 AI Agent 访问。",
"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.": "RA3 Mod XML:AI Agent 启动器将使用 PATH 中的 Node。请安装 Node,或从正常安装的 VS Code 启动,以使用内置运行时。",
"Install Agent Skill (recommended)": "安装 Agent Skill(推荐)",
"Write MCP config to Claude Desktop": "将 MCP 配置写入 Claude Desktop",
"Write MCP config to Cursor (global)": "将 MCP 配置写入 Cursor(全局)",
"Write MCP config to Cursor (project)": "将 MCP 配置写入 Cursor(项目)",
"Copy MCP config": "复制 MCP 配置",
"RA3 Mod XML AI Agent access enabled. Choose an optional next step.": "RA3 Mod XML AI Agent 访问已启用。请选择可选的后续操作。",
"RA3 Mod XML Agent Skill installed to {0}": "RA3 Mod XML Agent Skill 已安装到 {0}",
"RA3 Mod XML: could not write the Agent Skill to {0}": "RA3 Mod XML:无法将 Agent Skill 写入 {0}",
"RA3 Mod XML MCP config written to {0}": "RA3 Mod XML MCP 配置已写入 {0}",
"RA3 Mod XML MCP config copied to clipboard.": "RA3 Mod XML MCP 配置已复制到剪贴板。",
"RA3 Mod XML: failed to enable AI Agent access: {0}": "RA3 Mod XML:启用 AI Agent 访问失败:{0}",
"Default (~/.agents/skills)": "默认(~/.agents/skills)",
"Claude Code (~/.claude/skills)": "Claude Code(~/.claude/skills)",
"Current project .agents/skills": "当前项目 .agents/skills",
"Current project .claude/skills": "当前项目 .claude/skills",
"Choose a custom folder…": "选择自定义文件夹…",
"The skill is installed as <folder>/{0}": "Skill 将安装到 <文件夹>/{0}",
"Select Agent Skill install locations": "选择 Agent Skill 安装位置",
"Choose a skill folder": "选择 Skill 文件夹",
"Choose the folder that should contain the {0} skill": "选择用于存放 {0} Skill 的文件夹",
"RA3 Mod XML: could not write the Agent Skill (0 of {0} locations).": "RA3 Mod XML:无法写入 Agent Skill({0} 个位置全部失败)。",
"RA3 Mod XML Agent Skill installed to {0} location(s); {1} failed.": "RA3 Mod XML Agent Skill 已安装到 {0} 个位置;{1} 个失败。",
"RA3 Mod XML Agent Skill installed to {0} location(s).": "RA3 Mod XML Agent Skill 已安装到 {0} 个位置。",
"Show installed skills": "查看已安装的 Skill",
"RA3 Mod XML: no recorded Agent Skill installation to remove.": "RA3 Mod XML:没有可移除的 Agent Skill 安装记录。",
"missing — will be dropped from the record": "已缺失——将从记录中删除",
"installed by this extension (v{0})": "由本扩展安装(v{0})",
"not managed by this extension — will be skipped": "不由本扩展管理——将跳过",
"No RA3 Mod XML skill marker found; remove it manually if you want it gone.": "未找到 RA3 Mod XML Skill 标记;如需删除请手动处理。",
"Select Agent Skill installations to remove": "选择要移除的 Agent Skill 安装",
"RA3 Mod XML: removed {0} Agent Skill installation(s); {1} skipped (not managed by this extension).": "RA3 Mod XML:已移除 {0} 个 Agent Skill 安装;跳过 {1} 个(不由本扩展管理)。",
"RA3 Mod XML: removed {0} Agent Skill installation(s).": "RA3 Mod XML:已移除 {0} 个 Agent Skill 安装。",
"RA3 Mod XML: AI Agent access disabled for this workspace. Installed skills and MCP client configurations were kept.": "RA3 Mod XML:已禁用此工作区的 AI Agent 访问。已安装的 Skill 和 MCP 客户端配置已保留。",
"Uninstall AI Agent integration…": "卸载 AI Agent 集成…",
"Stop live AI Agent access in this window": "在此窗口中停止实时 AI Agent 访问",
"Remove installed Agent Skills": "移除已安装的 Agent Skill",
"{0} location(s)": "{0} 个位置",
"Remove MCP client configuration entries": "移除 MCP 客户端配置项",
"Claude Desktop / Cursor and files recorded by this extension": "Claude Desktop / Cursor 以及本扩展记录过的配置文件",
"Remove the stable MCP launcher": "移除稳定的 MCP 启动器",
"Select what to remove (nothing is removed until you confirm)": "选择要移除的内容(确认前不会删除任何内容)",
"Remove the selected AI Agent components?": "要移除选中的 AI Agent 组件吗?",
"Remove": "移除",
"live access stopped": "已停止实时访问",
"{0} skill installation(s) removed": "已移除 {0} 个 Skill 安装",
"{0} unmanaged skill folder(s) skipped": "已跳过 {0} 个非本扩展管理的 Skill 目录",
"MCP config removed from {0} file(s)": "已从 {0} 个配置文件中移除 MCP 配置",
"MCP launcher removed": "已移除 MCP 启动器",
"RA3 Mod XML AI Agent cleanup: {0}.": "RA3 Mod XML AI Agent 清理完成:{0}。",
"RA3 Mod XML: this version can expose the project's asset index to AI agents (MCP + Agent Skill). Enable it?": "RA3 Mod XML:此版本可以把项目资产索引提供给 AI Agent(MCP + Agent Skill)。要启用吗?",
"Enable AI Agent access": "启用 AI Agent 访问",
"Learn more": "了解更多",
"Don't show again": "不再提示",
"RA3 Mod XML: Find asset": "RA3 Mod XML:查找资产",
"Id, partial id or Type:Id (e.g. WeaponTemplate:AssaultRifle)": "ID、部分 ID 或 类型:ID(例如 WeaponTemplate:AssaultRifle)",
"Type an id, a partial id or Type:Id to search.": "输入 ID、部分 ID 或 类型:ID 进行搜索。",
"No asset matches \"{0}\".": "没有匹配 \"{0}\" 的资产。",
"{0} of {1} matches — keep typing to narrow the list.": "{0}/{1} 个匹配——继续输入可缩小范围。",
"1 asset found.": "找到 1 个资产。",
"{0} assets found.": "找到 {0} 个资产。",
"{0} definitions": "{0} 处定义",
"Select the definition to open": "选择要打开的定义",
"RA3 Mod XML: no XML source location for {0} ({1}).": "RA3 Mod XML:{0}({1})没有可打开的 XML 源位置。",
"RA3 Mod XML: the index has no assets yet — wait for indexing to finish.": "RA3 Mod XML:索引中还没有资产——请等待索引完成。",
"local": "本地"
}
+44 -11
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",
@@ -126,6 +126,34 @@
"command": "ra3modxml.showCacheReport",
"title": "%ra3modxml.command.showCacheReport.title%"
},
{
"command": "ra3modxml.enableAgentAccess",
"title": "%ra3modxml.command.enableAgentAccess.title%"
},
{
"command": "ra3modxml.disableAgentAccess",
"title": "%ra3modxml.command.disableAgentAccess.title%"
},
{
"command": "ra3modxml.installAgentSkill",
"title": "%ra3modxml.command.installAgentSkill.title%"
},
{
"command": "ra3modxml.uninstallAgentSkill",
"title": "%ra3modxml.command.uninstallAgentSkill.title%"
},
{
"command": "ra3modxml.uninstallAgentIntegration",
"title": "%ra3modxml.command.uninstallAgentIntegration.title%"
},
{
"command": "ra3modxml.exportIndexSnapshot",
"title": "%ra3modxml.command.exportIndexSnapshot.title%"
},
{
"command": "ra3modxml.findAsset",
"title": "%ra3modxml.command.findAsset.title%"
},
{
"command": "ra3modxml.findUnreferencedAssets",
"title": "%ra3modxml.command.findUnreferencedAssets.title%"
@@ -134,16 +162,21 @@
"command": "ra3modxml.findUnreferencedAssetsOfType",
"title": "%ra3modxml.command.findUnreferencedAssetsOfType.title%"
}
]
},
"menus": {
"editor/context": [
{
"command": "ra3modxml.findUnreferencedAssetsOfType",
"when": "editorLangId == xml && ra3modxml.active",
"group": "navigation@50"
}
]
],
"menus": {
"editor/context": [
{
"command": "ra3modxml.findAsset",
"when": "editorLangId == xml && ra3modxml.active",
"group": "navigation@45"
},
{
"command": "ra3modxml.findUnreferencedAssetsOfType",
"when": "editorLangId == xml && ra3modxml.active",
"group": "navigation@50"
}
]
}
},
"scripts": {
"build": "node esbuild.mjs",
+7
View File
@@ -13,6 +13,13 @@
"ra3modxml.command.clearCache.title": "RA3 Mod XML: Clear caches and rebuild",
"ra3modxml.command.configureSdkPath.title": "RA3 Mod XML: Configure SDK path…",
"ra3modxml.command.showCacheReport.title": "RA3 Mod XML: Show cache report",
"ra3modxml.command.enableAgentAccess.title": "RA3 Mod XML: Enable AI Agent access…",
"ra3modxml.command.disableAgentAccess.title": "RA3 Mod XML: Disable AI Agent access",
"ra3modxml.command.installAgentSkill.title": "RA3 Mod XML: Install Agent Skill…",
"ra3modxml.command.uninstallAgentSkill.title": "RA3 Mod XML: Uninstall Agent Skill…",
"ra3modxml.command.uninstallAgentIntegration.title": "RA3 Mod XML: Uninstall AI Agent integration…",
"ra3modxml.command.exportIndexSnapshot.title": "RA3 Mod XML: Export AI Agent index snapshot",
"ra3modxml.command.findAsset.title": "RA3 Mod XML: Find asset (id or Type:Id)…",
"ra3modxml.command.findUnreferencedAssets.title": "RA3 Mod XML: Find unreferenced assets…",
"ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: Find unreferenced assets of this type"
}
+7
View File
@@ -13,6 +13,13 @@
"ra3modxml.command.clearCache.title": "RA3 Mod XML: 清空缓存并重建",
"ra3modxml.command.configureSdkPath.title": "RA3 Mod XML: 配置 SDK 路径…",
"ra3modxml.command.showCacheReport.title": "RA3 Mod XML: 显示缓存报告",
"ra3modxml.command.enableAgentAccess.title": "RA3 Mod XML: 启用 AI Agent 访问…",
"ra3modxml.command.disableAgentAccess.title": "RA3 Mod XML: 禁用 AI Agent 访问",
"ra3modxml.command.installAgentSkill.title": "RA3 Mod XML: 安装 Agent Skill…",
"ra3modxml.command.uninstallAgentSkill.title": "RA3 Mod XML: 卸载 Agent Skill…",
"ra3modxml.command.uninstallAgentIntegration.title": "RA3 Mod XML: 卸载 AI Agent 集成…",
"ra3modxml.command.exportIndexSnapshot.title": "RA3 Mod XML: 导出 AI Agent 索引快照",
"ra3modxml.command.findAsset.title": "RA3 Mod XML: 查找资产(ID 或 类型:ID)…",
"ra3modxml.command.findUnreferencedAssets.title": "RA3 Mod XML: 查找未引用的资产…",
"ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: 查找该类型的未引用资产"
}
+323
View File
@@ -0,0 +1,323 @@
/**
* Thin CLI for RA3 Mod XML index queries.
*
* Deliberately thin: it does **not** reimplement the query surface. It does
* exactly two things.
*
* 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 { resolve } from "node:path";
import { findProjectRootUpward } from "../projectRoot";
import { LiveClient, type LiveClientOptions } from "./liveClient";
import {
findAssets,
findDefine,
findReferenceGroups,
isFileActive,
listAssetsByType,
resolveIncludeSource,
statusFromSnapshot,
} from "./query";
import { readSnapshotFile, snapshotPathForProject } from "./snapshot";
import type { AgentIndexSnapshot } from "./types";
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;
}
export function parseArgs(argv: string[]): CliOptions {
const options: CliOptions = {
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 === "--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 rather than failing an agent's probing call.
} else {
positional.push(arg);
}
}
if (positional.length > 0) {
options.command = positional[0];
options.args = positional.slice(1);
}
return options;
}
function printHelp(): void {
process.stdout.write(`RA3 Mod XML agent CLI
Usage:
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 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
`);
}
/** 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 "find":
return { id: a, type: b };
case "refs":
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":
return { type: a, prefix: b };
case "active":
return { path: a };
case "define":
return { name: a };
case "resolve":
return { source: a };
default:
return {};
}
}
/** 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;
},
);
}
+153
View File
@@ -0,0 +1,153 @@
/**
* Read/write helpers for the local endpoint files used by MCP/CLI tools to
* find a live RA3 Mod XML query server.
*
* Discovery is **per project** (`endpoints/<slug>-<hash>.json`) because a
* single global `endpoint.json` breaks as soon as more than one VS Code
* window (or more than one project) has AI Agent access enabled: the last
* window to start would overwrite the file, and a client started for project
* A could silently receive project B's index.
*
* A global `endpoint.json` is still written as a legacy/"most recent" pointer
* so tooling that does not know the project can find something, but readers
* that do know the project must prefer the per-project file.
*
* Pure TypeScript: no VS Code dependency.
*/
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { defaultAgentHome, snapshotBaseName } from "./snapshot";
export interface AgentEndpoint {
/** Base URL of the local server, e.g. http://127.0.0.1:54321 */
url: string;
token: string;
/** Absolute project root this endpoint serves (may serve several). */
projectDir?: string;
/** Every project root the server can answer for. */
projects?: string[];
/** PID of the VS Code extension host, used to detect stale files. */
processId?: number;
/** ISO timestamp of the last write, useful for diagnostics. */
updatedAt?: string;
}
/** Path to the legacy global endpoint file. */
export function endpointPath(agentHome = defaultAgentHome()): string {
return join(agentHome, "endpoint.json");
}
/** Directory holding one endpoint file per project. */
export function endpointDir(agentHome = defaultAgentHome()): string {
return join(agentHome, "endpoints");
}
/** Path to the per-project endpoint file. */
export function endpointPathForProject(
projectDir: string,
agentHome = defaultAgentHome(),
): string {
return join(endpointDir(agentHome), `${snapshotBaseName(projectDir)}.json`);
}
async function writeJson(file: string, value: unknown): Promise<void> {
await mkdir(dirname(file), { recursive: true });
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
async function readJson<T>(file: string): Promise<T | null> {
try {
return JSON.parse(await readFile(file, "utf8")) as T;
} catch {
return null;
}
}
/** Writes the legacy global endpoint file. */
export async function writeEndpoint(
endpoint: AgentEndpoint,
agentHome = defaultAgentHome(),
): Promise<string> {
const file = endpointPath(agentHome);
await writeJson(file, endpoint);
return file;
}
/** Writes one project's endpoint file (does not touch the global pointer). */
export async function writeEndpointForProject(
projectDir: string,
endpoint: AgentEndpoint,
agentHome = defaultAgentHome(),
): Promise<string> {
const file = endpointPathForProject(projectDir, agentHome);
await writeJson(file, { ...endpoint, projectDir: resolve(projectDir) });
return file;
}
/** Reads the legacy global endpoint file. */
export async function readEndpoint(
agentHome = defaultAgentHome(),
): Promise<AgentEndpoint | null> {
const parsed = await readJson<AgentEndpoint>(endpointPath(agentHome));
if (!parsed?.url || !parsed.token) return null;
return parsed;
}
/**
* Reads one project's endpoint. Returns null when the file is missing or the
* recorded project does not match the requested one (defence in depth: the
* file name already encodes the project, but a stale/hand-edited file must
* never be able to redirect a client to another project).
*/
export async function readEndpointForProject(
projectDir: string,
agentHome = defaultAgentHome(),
): Promise<AgentEndpoint | null> {
const parsed = await readJson<AgentEndpoint>(
endpointPathForProject(projectDir, agentHome),
);
if (!parsed?.url || !parsed.token) return null;
if (parsed.projectDir && !sameProject(parsed.projectDir, projectDir)) {
return null;
}
return parsed;
}
/** Removes the legacy global endpoint file. */
export async function clearEndpoint(
agentHome = defaultAgentHome(),
): Promise<void> {
await rm(endpointPath(agentHome), { force: true });
}
/** Removes one project's endpoint file. */
export async function clearEndpointForProject(
projectDir: string,
agentHome = defaultAgentHome(),
): Promise<void> {
await rm(endpointPathForProject(projectDir, agentHome), { force: true });
}
/** True when both paths resolve to the same project root. */
export function sameProject(a: string, b: string): boolean {
return resolve(a).toLowerCase() === resolve(b).toLowerCase();
}
/**
* True when a recorded extension-host PID is still running.
*
* `process.kill(pid, 0)` only probes existence: EPERM means the process
* exists but we may not signal it, which still counts as alive. Unknown PIDs
* are treated as alive so that a missing/older endpoint file does not disable
* the live path.
*/
export function isProcessAlive(pid: number | undefined | null): boolean {
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return true;
try {
process.kill(pid, 0);
return true;
} catch (err) {
return (err as NodeJS.ErrnoException).code === "EPERM";
}
}
+503
View File
@@ -0,0 +1,503 @@
/**
* Bounded, provenance-carrying forward-reference (outgoing edge) queries.
*
* The reverse index answers "who references this asset". This module answers
* the opposite, which the index does not store: "which assets does this asset
* reference, and through which XML element/attribute?".
*
* Design decisions (see docs/ai-agent-integration-plan.md §9):
*
* - It returns **edges**, not a flat node set. Each edge carries the element
* name, parent element name and attribute that produced it, plus the exact
* file/line of the XML text. A node list without that context forces the
* agent back into reading source, which is exactly what this should avoid.
* - It is **bounded three ways**: `depth` (1 default, 3 max), `targetTypes`
* (assignability filter) and `maxEdges`. Truncation is reported together
* with a per-type summary of what was dropped, so the caller can narrow the
* query instead of silently receiving a partial answer.
* - `inheritFrom` does **not** consume depth. Inheritance is part of an
* asset's own effective definition, so the ancestor's XML is walked at the
* same level and tagged with `definedIn`. This is what makes "AthenaCannon
* has no WeaponSetUpdate, but BaseCannon does" answerable in one call.
* - It is DOM-based and therefore **live-only**: resolving element context
* needs the parse tree, and storing element names on all ~98k reference
* records would measurably inflate the persistent cache.
* - It deliberately does **not** compute merged/inherited effective values:
* `xai:joinAction` Replace/Remove semantics are too easy to get wrong.
*
* Pure TypeScript: no VS Code dependency.
*/
import type { AssetDef, ModIndex } from "../indexer/types";
import { localName } from "../indexer/xpointer";
import {
isReferenceAttributeOfType,
isReferenceContentType,
resolveContentReferenceTargets,
resolveReferenceTargetsForType,
} from "../indexer/refs";
import {
LineMap,
parseXml,
type XmlDocument,
type XmlElement,
} from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import { isAssignableTo } from "../model/schemaModel";
export interface ForwardRefVia {
kind: "attribute" | "content" | "inheritFrom";
/** Element carrying the reference (e.g. `Weapon`). */
element: string;
/** Parent element giving the element its context (e.g. `WeaponSlotHardpoint`). */
parent: string | null;
/** Attribute name for `attribute` kind; null for `content`/`inheritFrom`. */
attribute: string | null;
}
/** A resolved definition location. */
export interface ForwardRefTarget {
type: string;
id: string;
file: string;
line: number;
}
export interface ForwardRefEdge {
/** 1-based hop number from the queried asset. */
depth: number;
/** The asset the edge is attributed to (the queried asset for inherited XML). */
from: { type: string; id: string };
/** Resolved definition, or null for an unresolved reference. */
to: ForwardRefTarget | null;
via: ForwardRefVia;
/**
* Present only when the XML text lives in an `inheritFrom` ancestor rather
* than in `from` itself.
*/
definedIn?: { type: string; id: string };
/** Exact source position of the reference value. */
source: { file: string; line: number; character: number };
/** Raw value, present for unresolved references and `inheritFrom` edges. */
value?: string;
}
export interface ForwardRefNode extends ForwardRefTarget {
/** Shallowest depth at which this node was reached. */
depth: number;
}
export interface ForwardRefOptions {
/** Levels of assets to expand. 1 = only the queried asset (default). Max 3. */
depth?: number;
/** Only keep edges/nodes whose target is assignable to one of these types. */
targetTypes?: string[];
/** Hard cap on returned edges (default 200). */
maxEdges?: number;
/** Include edges whose reference value could not be resolved. */
includeUnresolved?: boolean;
/** Walk `inheritFrom` ancestors (default true). */
includeInheritance?: boolean;
}
export interface ForwardRefResult {
roots: AssetDef[];
edges: ForwardRefEdge[];
nodes: ForwardRefNode[];
truncated: boolean;
/** Target types dropped because `maxEdges` was reached. */
omittedByTargetType: Record<string, number>;
/** Non-fatal warnings (missing files, missing definitions, capped chains). */
warnings: string[];
}
/** A parsed XML file plus the raw text, as needed by the DOM walk. */
export interface LoadedXmlFile {
parse: XmlDocument;
lineMap: LineMap;
text: string;
}
export type XmlFileLoader = (file: string) => Promise<LoadedXmlFile | null>;
const DEFAULT_MAX_EDGES = 200;
const MAX_DEPTH_LIMIT = 3;
/**
* Inheritance is walked at the same depth, so the chain length is capped
* separately to keep pathological hierarchies from expanding without bound.
*/
const MAX_INHERIT_CHAIN = 8;
/** Parses a file's text into the shape this module needs. */
export function parseLoadedXml(text: string): LoadedXmlFile {
return { parse: parseXml(text), lineMap: new LineMap(text), text };
}
/**
* Resolves the definitions to start from, then walks their outgoing
* references. Returns an empty result (with a warning) when nothing matches.
*/
export async function collectAssetReferences(
index: ModIndex,
id: string,
type: string | null,
loadFile: XmlFileLoader,
options: ForwardRefOptions = {},
): Promise<ForwardRefResult> {
const wanted = id.toLowerCase();
const wantedType = type?.toLowerCase() ?? null;
const roots = (index.assetsById.get(wanted) ?? []).filter(
(d) => !wantedType || d.type.toLowerCase() === wantedType,
);
if (!roots.length) {
return {
roots: [],
edges: [],
nodes: [],
truncated: false,
omittedByTargetType: {},
warnings: [
`No definition found for id "${id}"${type ? ` of type "${type}"` : ""}.`,
],
};
}
const result = await collectForwardReferences(index, roots, loadFile, options);
return { ...result, roots };
}
/**
* Walks the outgoing references of `roots` and returns bounded edges.
*/
export async function collectForwardReferences(
index: ModIndex,
roots: readonly AssetDef[],
loadFile: XmlFileLoader,
options: ForwardRefOptions = {},
): Promise<ForwardRefResult> {
const depthLimit = clampDepth(options.depth);
const maxEdges = Math.max(1, Math.floor(options.maxEdges ?? DEFAULT_MAX_EDGES));
const targetTypes = (options.targetTypes ?? []).filter(Boolean);
const includeUnresolved = options.includeUnresolved ?? false;
const includeInheritance = options.includeInheritance ?? true;
const edges: ForwardRefEdge[] = [];
const nodes = new Map<string, ForwardRefNode>();
const omitted = new Map<string, number>();
const warnings: string[] = [];
let truncated = false;
let edgeCount = 0;
const fileCache = new Map<string, LoadedXmlFile | null>();
const load = async (file: string): Promise<LoadedXmlFile | null> => {
const key = file.toLowerCase();
if (!fileCache.has(key)) {
try {
fileCache.set(key, await loadFile(file));
} catch {
fileCache.set(key, null);
}
}
return fileCache.get(key) ?? null;
};
interface WorkItem {
/** Asset whose XML is walked. */
def: AssetDef;
/** 0-based expansion level. */
level: number;
/** Asset the edges are attributed to (the queried asset). */
effective: { type: string; id: string };
/** Chain from `effective` (exclusive) down to `def` (inclusive). */
trail: { type: string; id: string }[];
}
const queue: WorkItem[] = roots.map((def) => ({
def,
level: 0,
effective: { type: def.type, id: def.id },
trail: [],
}));
const visited = new Set<string>();
const matchesTargetType = (typeName: string): boolean =>
targetTypes.length === 0 ||
targetTypes.some((wanted) => isAssignableTo(typeName, wanted));
const addEdge = (
level: number,
effective: WorkItem["effective"],
trail: WorkItem["trail"],
via: ForwardRefVia,
target: ForwardRefTarget | null,
source: ForwardRefEdge["source"],
value?: string,
): void => {
// `targetTypes` drops edges the caller did not ask for. `inheritFrom`
// edges always survive: they explain where the remaining edges came from
// (an inherited weapon lives in the ancestor's file, and editing the
// derived asset would be wrong). This filter is applied before the edge
// cap so `omittedByTargetType` only reports cap-driven truncation.
if (target && via.kind !== "inheritFrom" && !matchesTargetType(target.type)) {
return;
}
if (edgeCount >= maxEdges) {
truncated = true;
const bucket = target?.type ?? "#unresolved";
omitted.set(bucket, (omitted.get(bucket) ?? 0) + 1);
return;
}
edgeCount++;
const edge: ForwardRefEdge = {
depth: level + 1,
from: { type: effective.type, id: effective.id },
to: target,
via,
source,
};
if (trail.length) {
const owner = trail[trail.length - 1];
edge.definedIn = { type: owner.type, id: owner.id };
}
if (value !== undefined) edge.value = value;
edges.push(edge);
if (target) {
const key = nodeKey(target);
const existing = nodes.get(key);
if (!existing || level + 1 < existing.depth) {
nodes.set(key, { ...target, depth: level + 1 });
}
}
};
while (queue.length) {
const item = queue.shift()!;
const { def, level, effective, trail } = item;
const visitKey = [
effective.type,
effective.id.toLowerCase(),
def.type,
def.id.toLowerCase(),
def.file.toLowerCase(),
def.line,
].join("\u0000");
if (visited.has(visitKey)) continue;
visited.add(visitKey);
const loaded = await load(def.file);
if (!loaded) {
warnings.push(`Could not read ${def.file} (definition of ${def.type}:${def.id}).`);
continue;
}
const ownerEl = findDefinitionElement(loaded, def);
if (!ownerEl) {
warnings.push(
`Could not locate <${def.type} id="${def.id}"> inside ${def.file}; the index line may be stale.`,
);
continue;
}
const sourceAt = (offset: number): ForwardRefEdge["source"] => {
const pos = loaded.lineMap.positionAt(offset);
return { file: def.file, line: pos.line + 1, character: pos.character };
};
for (const el of subtreeInDocumentOrder(ownerEl)) {
const elType = resolveElementType(el);
const elLocal = localName(el.name);
const parentLocal = el.parent ? localName(el.parent.name) : null;
for (const attr of el.attrs) {
if (!attr.hasValue) continue;
const nameLower = attr.name.toLowerCase();
// inheritFrom belongs to the asset element itself and is handled
// separately below so it can also expand the ancestor chain.
if (nameLower === "inheritfrom") continue;
const value = attr.value;
if (!value || value.startsWith("$") || value.startsWith("=")) continue;
if (!isReferenceAttributeOfType(elType, attr.name)) continue;
const via: ForwardRefVia = {
kind: "attribute",
element: elLocal,
parent: parentLocal,
attribute: attr.name,
};
const source = sourceAt(attr.valueStart);
const targets = resolveReferenceTargetsForType(index, elType, attr.name, value);
if (!targets.length) {
if (includeUnresolved) addEdge(level, effective, trail, via, null, source, value);
continue;
}
for (const target of targets) {
const to = targetOf(target.def);
addEdge(level, effective, trail, via, to, source);
if (level + 1 < depthLimit) {
queue.push({
def: target.def,
level: level + 1,
effective: { type: target.def.type, id: target.def.id },
trail: [],
});
}
}
}
// Simple-content references (e.g. <CreateObject>temp_id</CreateObject>).
if (elType && isReferenceContentType(elType) && !el.selfClosing && el.closeTagStart >= 0) {
const raw = loaded.text.slice(el.startTagEnd, el.closeTagStart);
const value = raw.trim();
if (
!value ||
value.startsWith("$") ||
value.startsWith("=") ||
value.includes("<")
) {
continue;
}
const start = el.startTagEnd + raw.indexOf(value);
const via: ForwardRefVia = {
kind: "content",
element: elLocal,
parent: parentLocal,
attribute: null,
};
const source = sourceAt(start);
const targets = resolveContentReferenceTargets(index, elType, value);
if (!targets.length) {
if (includeUnresolved) addEdge(level, effective, trail, via, null, source, value);
continue;
}
for (const target of targets) {
const to = targetOf(target.def);
addEdge(level, effective, trail, via, to, source);
if (level + 1 < depthLimit) {
queue.push({
def: target.def,
level: level + 1,
effective: { type: target.def.type, id: target.def.id },
trail: [],
});
}
}
}
}
// inheritFrom on the definition element: walk the ancestor at the SAME
// level so its XML contributes to the queried asset's effective definition.
if (includeInheritance) {
const inheritAttr = ownerEl.attrs.find(
(a) => a.name.toLowerCase() === "inheritfrom",
);
const value = inheritAttr?.value;
if (inheritAttr?.hasValue && value && !value.startsWith("$") && !value.startsWith("=")) {
const via: ForwardRefVia = {
kind: "inheritFrom",
element: localName(ownerEl.name),
parent: ownerEl.parent ? localName(ownerEl.parent.name) : null,
attribute: inheritAttr.name,
};
const source = sourceAt(inheritAttr.valueStart);
const targets = resolveReferenceTargetsForType(
index,
def.type,
"inheritFrom",
value,
);
if (!targets.length) {
if (includeUnresolved) addEdge(level, effective, trail, via, null, source, value);
} else {
for (const target of targets) {
const to = targetOf(target.def);
addEdge(level, effective, trail, via, to, source, value);
// The trail records the asset whose XML we are about to walk, so
// edges found there report the correct `definedIn`.
const nextTrail = [
...trail,
{ type: target.def.type, id: target.def.id },
];
if (nextTrail.length < MAX_INHERIT_CHAIN) {
queue.push({ def: target.def, level, effective, trail: nextTrail });
} else {
warnings.push(
`Inheritance chain for ${effective.type}:${effective.id} exceeded ${MAX_INHERIT_CHAIN} levels; deeper ancestors were not walked.`,
);
}
}
}
}
}
}
return {
roots: [...roots],
edges,
nodes: [...nodes.values()].sort(
(a, b) => a.depth - b.depth || a.type.localeCompare(b.type) || a.id.localeCompare(b.id),
),
truncated,
omittedByTargetType: Object.fromEntries(omitted),
warnings,
};
}
function clampDepth(depth: number | undefined): number {
if (depth == null || !Number.isFinite(depth)) return 1;
return Math.max(1, Math.min(MAX_DEPTH_LIMIT, Math.floor(depth)));
}
function targetOf(def: AssetDef): ForwardRefTarget {
return { type: def.type, id: def.id, file: def.file, line: def.line };
}
function nodeKey(target: ForwardRefTarget): string {
return `${target.type}\u0000${target.id.toLowerCase()}\u0000${target.file.toLowerCase()}\u0000${target.line}`;
}
/**
* Finds the element defining `def` inside a parsed file.
*
* The index stores a line number for the `id` attribute, so an exact line
* match is the strongest signal; top-level placement is the next best. This
* matters when a nested element reuses the same id.
*/
export function findDefinitionElement(
loaded: LoadedXmlFile,
def: Pick<AssetDef, "id" | "line">,
): XmlElement | null {
const wanted = def.id.toLowerCase();
const root = loaded.parse.root;
let best: XmlElement | null = null;
let bestScore = -1;
for (const el of loaded.parse.elements) {
const idAttr = el.attrs.find((a) => a.name.toLowerCase() === "id");
if (!idAttr?.hasValue) continue;
if (idAttr.value.toLowerCase() !== wanted) continue;
let score = 0;
const isTopLevel = el.parent === root || (root == null && el.parent == null);
if (isTopLevel) score += 2;
if (def.line > 0) {
const line = loaded.lineMap.positionAt(idAttr.valueStart).line + 1;
if (line === def.line) score += 4;
}
if (score > bestScore) {
bestScore = score;
best = el;
}
}
return best;
}
/** Depth-first, document-order element list for a subtree (no recursion). */
export function subtreeInDocumentOrder(root: XmlElement): XmlElement[] {
const out: XmlElement[] = [];
const stack: XmlElement[] = [root];
while (stack.length) {
const el = stack.pop()!;
out.push(el);
for (let i = el.children.length - 1; i >= 0; i--) {
stack.push(el.children[i]);
}
}
return out;
}
+268
View File
@@ -0,0 +1,268 @@
/**
* 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 { clearEndpoint, isProcessAlive, writeEndpoint, 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;
}
}
/**
* Re-derives the legacy global endpoint pointer and the merged discovery
* manifest from the instance files that still exist and whose recorded
* process is alive.
*
* Called after an instance file is removed (window closed, or pruned as
* crashed). Closing one window must never make another still-running window
* undiscoverable: whichever writer changed the instance set has to rebuild
* both derived files from the survivors instead of clearing them.
*
* Best-effort: the pointer/manifest are discovery conveniences, and readers
* can always scan `instances/`, so failures never propagate to the caller.
*/
export async function refreshDiscovery(
agentHome = defaultAgentHome(),
): Promise<AgentIndexManifest> {
const { kept } = await pruneInstances(agentHome);
const first = kept[0];
try {
if (first) {
await writeEndpoint(endpointOf(first), agentHome);
} else {
await clearEndpoint(agentHome);
}
} catch {
// Discovery pointer only; the manifest below is still worth writing.
}
return writeManifest(kept, agentHome);
}
/**
* The endpoint fields of an instance file, without the registry-only
* `instanceId` (endpoint.json predates the instance registry and readers do
* not expect that field).
*/
function endpointOf(instance: AgentInstance): AgentEndpoint {
return {
url: instance.url,
token: instance.token,
projectDir: instance.projectDir,
projects: instance.projects,
processId: instance.processId,
updatedAt: instance.updatedAt,
};
}
/** 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);
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Query helpers that operate directly on the live in-memory ModIndex.
*
* These are useful for local HTTP/live services where converting the full
* index to an external snapshot on every query would be wasteful.
*
* Pure TypeScript: no VS Code dependency.
*/
import { resolve } from "node:path";
import type { AssetDef, DefineDef, ModIndex, ReferenceSite } from "../indexer/types";
import { statusFromIndex } from "./snapshot";
import type { AgentIndexStatus, AgentReferenceGroup } from "./types";
function normalizePath(p: string): string {
return resolve(p).replace(/\\/g, "/").toLowerCase();
}
/** Finds asset definitions by id in the live index. */
export function findAssetsLive(
index: ModIndex,
id: string,
type?: string | null,
): AssetDef[] {
const wanted = id.toLowerCase();
const wantedType = type?.toLowerCase();
const candidates = index.assetsById.get(wanted) ?? [];
if (!wantedType) return candidates;
return candidates.filter((a) => a.type.toLowerCase() === wantedType);
}
/** Lists assets of one type in the live index. */
export function listAssetsByTypeLive(
index: ModIndex,
type: string,
idPrefix = "",
limit?: number,
): AssetDef[] {
const wantedType = type.toLowerCase();
const wantedPrefix = idPrefix.toLowerCase();
const byType = index.assets.get(type);
if (!byType) return [];
const out: AssetDef[] = [];
for (const [id, defs] of byType) {
if (!id.startsWith(wantedPrefix)) continue;
for (const def of defs) {
if (def.type.toLowerCase() !== wantedType) continue;
out.push(def);
if (limit != null && out.length >= limit) return out;
}
}
return out;
}
/**
* Converts the reverse reference map into groups for one asset id.
* Reference map keys are `type\0id\0file\0line`.
*/
export function findReferenceGroupsLive(
index: ModIndex,
id: string,
type?: string | null,
): AgentReferenceGroup[] {
const wanted = id.toLowerCase();
const wantedType = type?.toLowerCase();
const groups: AgentReferenceGroup[] = [];
for (const [key, sites] of index.references) {
const parts = key.split("\u0000");
if (parts.length !== 4) continue;
const [typeName, defId, file, lineText] = parts;
if (defId.toLowerCase() !== wanted) continue;
if (wantedType && typeName.toLowerCase() !== wantedType) continue;
groups.push({
type: typeName,
id: defId,
file,
line: Number(lineText) || 0,
sites,
});
}
return groups;
}
/** Flattens live reference groups into sites. */
export function findReferenceSitesLive(
index: ModIndex,
id: string,
type?: string | null,
): ReferenceSite[] {
return findReferenceGroupsLive(index, id, type).flatMap((g) => g.sites);
}
/** Returns streams containing a file in the live index. */
export function streamsForFileLive(
index: ModIndex,
file: string,
): ModIndex["streams"] {
const key = normalizePath(file);
return index.streams.filter((s) =>
[...s.files].some((candidate) => normalizePath(candidate) === key),
);
}
/** True when a file belongs to a live stream. */
export function isFileActiveLive(index: ModIndex, file: string): boolean {
return streamsForFileLive(index, file).length > 0;
}
/** Finds defines by name in the live index. */
export function findDefineLive(
index: ModIndex,
name: string,
): DefineDef[] {
const wanted = name.toLowerCase();
return index.defines.get(wanted) ?? [];
}
/** Resolves Include source using the live source-candidate list. */
export function resolveIncludeLive(
index: ModIndex,
source: string,
): { source: string; path: string } | null {
const wanted = source.toLowerCase();
const hit = index.sourceCandidates.find(
(c) => c.source.toLowerCase() === wanted,
);
return hit ? { source: hit.source, path: hit.path } : null;
}
/**
* Returns a status object for the live index.
*
* `requestedProjectDir` is only echoed when no index is available, so callers
* can still verify which server they reached. It is never used to fake a
* `projectDir` when an index exists — the index is the source of truth.
*/
export function liveStatus(
index: ModIndex | null | undefined,
requestedProjectDir?: string,
): AgentIndexStatus {
return statusFromIndex(index, requestedProjectDir);
}
/** Every project root this index belongs to (for `/projects` listing). */
export function knownProjectDirs(index: ModIndex | null | undefined): string[] {
return index ? [index.projectDir] : [];
}
+268
View File
@@ -0,0 +1,268 @@
/**
* Local read-only HTTP server for live RA3 Mod XML index queries.
*
* The server runs inside the VS Code extension host when the user has enabled
* AI Agent access. It listens only on 127.0.0.1 and requires a bearer token so
* unrelated local processes cannot query it by accident.
*
* Requests may carry `?project=<dir>` to select which project's index answers
* the query. When omitted, the server falls back to the active project. Every
* response echoes `index.projectDir` so the caller can verify it reached the
* server/project it asked for — see docs/ai-agent-integration-plan.md §10 for
* why that check is required in multi-window setups.
*
* Pure TypeScript: no VS Code dependency.
*/
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import type { ModIndex } from "../indexer/types";
import {
collectAssetReferences,
type ForwardRefOptions,
type XmlFileLoader,
} from "./forwardRefs";
import {
findAssetsLive,
findDefineLive,
findReferenceGroupsLive,
isFileActiveLive,
listAssetsByTypeLive,
liveStatus,
resolveIncludeLive,
} from "./liveQuery";
const USAGE_GUIDE = `RA3 Mod XML live index query API
Endpoints (all require Authorization: Bearer <token>):
GET /status
GET /projects
GET /find_asset?id=...&type=...
GET /find_references?id=...&type=...
GET /get_asset_references?id=...&type=...&depth=1&targetTypes=A,B&maxEdges=200
GET /list_assets?type=...&prefix=...&limit=...
GET /is_file_active?path=...
GET /find_define?name=...
GET /resolve_include?source=...
GET /get_usage_guide
All endpoints accept an optional ?project=<absolute dir> selector. Responses
echo index.projectDir; treat the result as belonging to a different project
when it does not match what you asked for.
`;
export interface LocalServerHandle {
port: number;
token: string;
close(): Promise<void>;
}
export interface LocalServerOptions {
/** Returns the current in-memory index for a project (null when unknown). */
getIndex: (projectDir?: string) => ModIndex | null;
/** Every project root the live workspace currently knows about. */
listProjects?: () => string[];
/**
* Reads + parses one XML file. Required by /get_asset_references, which
* needs element context that the index does not store.
*/
loadFile?: XmlFileLoader;
token?: string;
/** Defaults to an OS-assigned port on 127.0.0.1. */
port?: number;
}
function sendJson(res: ServerResponse, status: number, value: unknown): void {
const body = JSON.stringify(value);
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(body),
});
res.end(body);
}
function sendText(res: ServerResponse, status: number, text: string): void {
res.writeHead(status, {
"content-type": "text/plain; charset=utf-8",
"content-length": Buffer.byteLength(text),
});
res.end(text);
}
function isAuthorized(req: IncomingMessage, token: string): boolean {
const header = req.headers.authorization ?? "";
return header === `Bearer ${token}`;
}
/** Parses a comma-separated `targetTypes` parameter. */
function parseList(raw: string | null): string[] {
if (!raw) return [];
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
function parseNumber(raw: string | null): number | undefined {
if (raw == null || raw === "") return undefined;
const value = Number(raw);
return Number.isFinite(value) ? value : undefined;
}
function forwardRefOptionsFrom(q: URLSearchParams): ForwardRefOptions {
return {
depth: parseNumber(q.get("depth")),
targetTypes: parseList(q.get("targetTypes")),
maxEdges: parseNumber(q.get("maxEdges")),
includeUnresolved: q.get("includeUnresolved") === "true",
};
}
async function handle(
options: LocalServerOptions,
token: string,
req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
if (!isAuthorized(req, token)) {
sendJson(res, 401, { error: "Unauthorized" });
return;
}
const url = new URL(req.url ?? "/", "http://127.0.0.1");
const q = url.searchParams;
const projectDir = q.get("project") ?? undefined;
const index = options.getIndex(projectDir);
// When an index exists its own projectDir is authoritative; the selector is
// only echoed for no-index responses so callers can still verify the server.
const status = liveStatus(index, projectDir);
switch (url.pathname) {
case "/status":
sendJson(res, 200, status);
return;
case "/projects":
sendJson(res, 200, {
index: status,
data: options.listProjects?.() ?? [],
});
return;
case "/find_asset":
sendJson(res, 200, {
index: status,
data: index ? findAssetsLive(index, q.get("id") ?? "", q.get("type")) : [],
});
return;
case "/find_references":
sendJson(res, 200, {
index: status,
data: index
? findReferenceGroupsLive(index, q.get("id") ?? "", q.get("type"))
: [],
});
return;
case "/list_assets": {
const limit = parseNumber(q.get("limit"));
sendJson(res, 200, {
index: status,
data: index
? listAssetsByTypeLive(index, q.get("type") ?? "", q.get("prefix") ?? "", limit)
: [],
});
return;
}
case "/is_file_active":
sendJson(res, 200, {
index: status,
data: { active: index ? isFileActiveLive(index, q.get("path") ?? "") : false },
});
return;
case "/find_define":
sendJson(res, 200, {
index: status,
data: index
? findDefineLive(index, (q.get("name") ?? "").replace(/^\$/, ""))
: [],
});
return;
case "/resolve_include":
sendJson(res, 200, {
index: status,
data: index ? resolveIncludeLive(index, q.get("source") ?? "") : null,
});
return;
case "/get_asset_references": {
if (!index) {
sendJson(res, 200, {
index: status,
data: null,
error:
"get_asset_references requires a live index (VS Code must be open with the project indexed).",
});
return;
}
if (!options.loadFile) {
sendJson(res, 200, {
index: status,
data: null,
error: "The live server was started without XML file access.",
});
return;
}
const result = await collectAssetReferences(
index,
q.get("id") ?? "",
q.get("type"),
options.loadFile,
forwardRefOptionsFrom(q),
);
sendJson(res, 200, { index: status, data: result });
return;
}
case "/get_usage_guide":
sendText(res, 200, USAGE_GUIDE);
return;
default:
sendJson(res, 404, { error: `Not found: ${url.pathname}` });
}
}
/** Starts a local HTTP server; resolves once it is listening. */
export async function startLocalServer(
options: LocalServerOptions,
): Promise<LocalServerHandle> {
const token = options.token ?? randomToken();
const server = createServer((req, res) => {
void handle(options, token, req, res).catch((err) => {
sendJson(res, 500, {
error: err instanceof Error ? err.message : String(err),
});
});
});
await new Promise<void>((resolveListen, reject) => {
server.once("error", reject);
server.listen(options.port ?? 0, "127.0.0.1", () => resolveListen());
});
const address = server.address() as AddressInfo;
return {
port: address.port,
token,
close: () =>
new Promise<void>((resolveClose, rejectClose) => {
server.close((err) => (err ? rejectClose(err) : resolveClose()));
}),
};
}
function randomToken(): string {
return `ra3-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
+379
View File
@@ -0,0 +1,379 @@
/**
* Minimal MCP (Model Context Protocol) stdio server exposing the RA3 Mod XML
* agent snapshot query API.
*
* This is intentionally dependency-free. It speaks the JSON-RPC-over-stdio
* subset used by MCP clients:
*
* initialize
* notifications/initialized
* ping
* tools/list
* tools/call
*
* Usage:
* node out/agent/mcpServer.js --project D:/Mods/Example
* node out/agent/mcpServer.js --snapshot /path/to/snapshot.json.gz
*/
import { createInterface } from "node:readline";
import { LiveClient } from "./liveClient";
import { pruneInstances } from "./instances";
import { readSnapshotFile, snapshotPathForProject } from "./snapshot";
import {
findAssets,
findDefine,
findReferenceGroups,
isFileActive,
listAssetsByType,
resolveIncludeSource,
statusFromSnapshot,
} from "./query";
import type { AgentIndexSnapshot } from "./types";
interface McpTool {
name: string;
description: string;
inputSchema: Record<string, unknown>;
handler: (args: Record<string, unknown>, snapshot: AgentIndexSnapshot | null) => unknown;
}
const USAGE_GUIDE = `RA3 Mod XML index query tools
This MCP server exposes the semantic index built by the RA3 Mod XML VS Code extension.
Use these tools instead of full-text grepping the XML tree when you need exact facts:
- find_asset(id, type?) -> definition sites (file/line/origin/stream)
- find_references(id, type?) -> semantic reference sites
- get_asset_references(id, type?, depth?, targetTypes?) -> outgoing reference EDGES with element context
- list_assets_by_type(type, prefix?, limit?) -> assets of a type
- 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:
- Asset ids are case-insensitive.
- Prefer passing type when the same id exists for multiple asset types.
- Always check the returned index state; if it is stale/incomplete, treat results as provisional.
- Use get_asset_references to follow "which weapon/model/upgrade does this asset use" chains.
It returns edges annotated with the element name, parent element and attribute that produced
them, plus the exact XML file/line, so you do not have to read source to find the link.
Start with depth 1 (the default) and pass targetTypes (e.g. ["WeaponTemplate"]) to cut noise.
Edges with a "definedIn" field come from an inheritFrom ancestor's XML.
When "truncated" is true, read "omittedByTargetType" and narrow the query instead of retrying.
- Do not attempt to read the entire snapshot file; query narrowly.`;
const TOOLS: McpTool[] = [
{
name: "get_status",
description: "Returns the current index state and basic statistics.",
inputSchema: { type: "object", properties: {} },
handler: (_args, snapshot) => statusFromSnapshot(snapshot),
},
{
name: "find_asset",
description: "Finds asset definitions by id, optionally filtered by asset type.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "Asset id to find" },
type: { type: "string", description: "Optional asset type filter" },
},
required: ["id"],
},
handler: (args, snapshot) => {
const id = String(args.id ?? "");
if (!snapshot) return statusFromSnapshot(snapshot);
return {
index: statusFromSnapshot(snapshot),
data: findAssets(snapshot, id, args.type ? String(args.type) : null),
};
},
},
{
name: "find_references",
description: "Finds semantic reference sites pointing to an asset id, optionally filtered by type.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "Asset id whose references to find" },
type: { type: "string", description: "Optional asset type filter" },
},
required: ["id"],
},
handler: (args, snapshot) => {
const id = String(args.id ?? "");
if (!snapshot) return statusFromSnapshot(snapshot);
return {
index: statusFromSnapshot(snapshot),
data: findReferenceGroups(snapshot, id, args.type ? String(args.type) : null),
};
},
},
{
name: "list_assets_by_type",
description: "Lists asset definitions of one type, optionally filtered by id prefix.",
inputSchema: {
type: "object",
properties: {
type: { type: "string", description: "Asset type" },
prefix: { type: "string", description: "Optional id prefix" },
limit: { type: "number", description: "Maximum number of results" },
},
required: ["type"],
},
handler: (args, snapshot) => {
const type = String(args.type ?? "");
if (!snapshot) return statusFromSnapshot(snapshot);
const limit = typeof args.limit === "number" ? args.limit : undefined;
return {
index: statusFromSnapshot(snapshot),
data: listAssetsByType(snapshot, type, args.prefix ? String(args.prefix) : "", limit),
};
},
},
{
name: "is_file_active",
description: "Returns whether a file belongs to an indexed include stream (i.e. is not a dead file).",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Absolute file path" },
},
required: ["path"],
},
handler: (args, snapshot) => {
const path = String(args.path ?? "");
if (!snapshot) return statusFromSnapshot(snapshot);
return {
index: statusFromSnapshot(snapshot),
data: { active: isFileActive(snapshot, path) },
};
},
},
{
name: "find_define",
description: "Finds $DEFINE constants by name.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Define name (with or without leading $)" },
},
required: ["name"],
},
handler: (args, snapshot) => {
const name = String(args.name ?? "").replace(/^\$/, "");
if (!snapshot) return statusFromSnapshot(snapshot);
return {
index: statusFromSnapshot(snapshot),
data: findDefine(snapshot, name),
};
},
},
{
name: "resolve_include",
description: "Resolves an Include source string from the snapshot's candidate list.",
inputSchema: {
type: "object",
properties: {
source: { type: "string", description: "Include source, e.g. DATA:Units/Example.xml" },
},
required: ["source"],
},
handler: (args, snapshot) => {
const source = String(args.source ?? "");
if (!snapshot) return statusFromSnapshot(snapshot);
return {
index: statusFromSnapshot(snapshot),
data: resolveIncludeSource(snapshot, source),
};
},
},
{
name: "get_asset_references",
description:
"Returns the outgoing references (edges) of an asset: which assets it references, through which element/attribute, and at which file/line. Also follows inheritFrom ancestors (marked with definedIn). Live index required.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "Asset id whose outgoing references to return" },
type: { type: "string", description: "Optional asset type filter" },
depth: {
type: "number",
description:
"Levels of assets to expand: 1 (default) = the asset itself, including inherited XML; max 3.",
},
targetTypes: {
type: "array",
items: { type: "string" },
description:
"Only keep edges whose target is assignable to one of these types, e.g. [\"WeaponTemplate\"].",
},
maxEdges: { type: "number", description: "Hard cap on returned edges (default 200)." },
includeUnresolved: {
type: "boolean",
description: "Also return edges whose reference value could not be resolved.",
},
},
required: ["id"],
},
// Live-only: element context is not stored in the on-disk snapshot.
handler: () => ({
index: { state: "no_index" },
error:
"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.",
inputSchema: { type: "object", properties: {} },
handler: () => ({ text: USAGE_GUIDE }),
},
];
/** Tools that can only be answered by the live extension server. */
const LIVE_ONLY_TOOLS = new Set(["get_asset_references", "list_projects"]);
function sendMessage(message: unknown): void {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function resultFor(id: unknown, result: unknown): unknown {
return { jsonrpc: "2.0", id, result };
}
function errorFor(id: unknown, code: number, message: string): unknown {
return { jsonrpc: "2.0", id, error: { code, message } };
}
async function handleRequest(
message: Record<string, unknown>,
snapshot: AgentIndexSnapshot | null,
projectDir: string | null,
live: LiveClient,
): Promise<unknown | null> {
const method = String(message.method ?? "");
const id = message.id;
const params = (message.params ?? {}) as Record<string, unknown>;
switch (method) {
case "initialize":
return resultFor(id, {
protocolVersion: params.protocolVersion ?? "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "ra3-mod-xml", version: "0.1.0" },
});
case "ping":
return resultFor(id, {});
case "tools/list":
return resultFor(id, {
tools: TOOLS.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
})),
});
case "tools/call": {
const toolName = String(params.name ?? "");
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 result = await live.query(toolName, args);
if (result?.mismatched) {
// The server answered for another project. Refuse it: a plausible
// wrong answer is worse than an explicit failure.
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.`,
});
}
if (LIVE_ONLY_TOOLS.has(toolName)) {
// Report a clear reason instead of an empty result, so the agent does
// not conclude "this asset has no references".
return textResult(
id,
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 = result?.payload ?? tool.handler(args, snapshot);
return textResult(id, output);
}
default:
// Notifications have no id; ignore them.
if (id === undefined) return null;
return errorFor(id, -32601, `Method not found: ${method}`);
}
}
/** Wraps any tool payload into an MCP text content result. */
function textResult(id: unknown, payload: unknown): unknown {
const text = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
return resultFor(id, { content: [{ type: "text", text }] });
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
let projectDir: string | null = null;
let snapshotPath: string | null = null;
let agentHome: string | undefined;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--project" || args[i] === "-p") projectDir = args[++i] ?? null;
else if (args[i] === "--snapshot" || args[i] === "-s") snapshotPath = args[++i] ?? null;
else if (args[i] === "--agent-home") agentHome = args[++i] ?? undefined;
}
const resolvedSnapshotPath =
snapshotPath ??
(projectDir ? snapshotPathForProject(projectDir, agentHome) : null);
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,
});
rl.on("line", (line) => {
if (!line.trim()) return;
let message: Record<string, unknown>;
try {
message = JSON.parse(line) as Record<string, unknown>;
} catch {
return;
}
void handleRequest(message, snapshot, projectDir, live).then((response) => {
if (response != null) sendMessage(response);
});
});
}
// Only run the stdio loop when executed directly, so the module stays
// importable by tests.
if (typeof require !== "undefined" && require.main === module) {
void main();
}
+61
View File
@@ -0,0 +1,61 @@
/**
* One-time "this version can expose the index to AI agents" notification.
*
* Pure decision logic (no VS Code dependency) so the anti-nag rules can be
* unit tested:
*
* - a fresh install is informed once;
* - an upgrade from a build that predates the feature is informed once;
* - an upgrade from a build that already had the feature stays silent;
* - "Don't show again" (and simply ignoring the message) is remembered, so
* the prompt never becomes a recurring nag.
*/
/** Extension version that introduced AI Agent access. */
export const AGENT_FEATURE_VERSION = "0.1.26";
export interface AgentOnboardingState {
/** Extension version whose notification was already shown. */
informedVersion?: string;
/** The user explicitly chose "Don't show again". */
dismissed?: boolean;
}
/**
* True when the AI Agent introduction should be shown for this version.
*/
export function shouldOfferAgentOnboarding(
state: AgentOnboardingState | undefined,
currentVersion: string,
): boolean {
if (state?.dismissed) return false;
if (!state?.informedVersion) return true; // First run with this feature.
if (state.informedVersion === currentVersion) return false;
// Already informed by a build that had the feature: never repeat.
// Only upgrades from before the feature introduce it once.
return compareVersions(state.informedVersion, AGENT_FEATURE_VERSION) < 0;
}
/**
* Compares dotted numeric versions ("1.2.3" > "1.2"). Non-numeric segments
* count as 0, and a missing segment is smaller than a present one, so
* "0.1.26" > "0.1" and "0.1.26" > "0.1.26-beta".
*/
export function compareVersions(a: string, b: string): number {
const parse = (value: string): number[] =>
String(value)
.split(".")
.map((part) => {
const match = /^(\d+)/.exec(part.trim());
return match ? Number(match[1]) : 0;
});
const left = parse(a);
const right = parse(b);
const length = Math.max(left.length, right.length);
for (let i = 0; i < length; i++) {
const l = left[i] ?? 0;
const r = right[i] ?? 0;
if (l !== r) return l < r ? -1 : 1;
}
return 0;
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Query helpers over the stable AgentIndexSnapshot.
*
* Pure TypeScript and dependency-free, so the same functions can back a CLI,
* MCP tools, or a local HTTP API.
*/
import { resolve } from "node:path";
import type { AssetDef, ReferenceSite } from "../indexer/types";
import type {
AgentDefine,
AgentIndexSnapshot,
AgentIndexStatus,
AgentReferenceGroup,
AgentStream,
} from "./types";
function normalizePath(p: string): string {
return resolve(p).replace(/\\/g, "/").toLowerCase();
}
/** Returns all asset definitions whose id equals `id`, optionally filtered by type. */
export function findAssets(
snapshot: AgentIndexSnapshot,
id: string,
type?: string | null,
): AssetDef[] {
const wanted = id.toLowerCase();
const wantedType = type?.toLowerCase();
const out: AssetDef[] = [];
for (const asset of snapshot.assets) {
if (asset.id.toLowerCase() !== wanted) continue;
if (wantedType && asset.type.toLowerCase() !== wantedType) continue;
out.push(asset);
}
return out;
}
/** Returns asset definitions whose type and id prefix match. */
export function listAssetsByType(
snapshot: AgentIndexSnapshot,
type: string,
idPrefix = "",
limit?: number,
): AssetDef[] {
const wantedType = type.toLowerCase();
const wantedPrefix = idPrefix.toLowerCase();
const out: AssetDef[] = [];
for (const asset of snapshot.assets) {
if (asset.type.toLowerCase() !== wantedType) continue;
if (!asset.id.toLowerCase().startsWith(wantedPrefix)) continue;
out.push(asset);
if (limit != null && out.length >= limit) break;
}
return out;
}
/** Returns reference groups pointing to definitions matching `id` and optional `type`. */
export function findReferenceGroups(
snapshot: AgentIndexSnapshot,
id: string,
type?: string | null,
): AgentReferenceGroup[] {
const wanted = id.toLowerCase();
const wantedType = type?.toLowerCase();
return snapshot.references.filter((r) => {
if (r.id.toLowerCase() !== wanted) return false;
if (wantedType && r.type.toLowerCase() !== wantedType) return false;
return true;
});
}
/** Flattens reference groups into plain reference sites. */
export function findReferenceSites(
snapshot: AgentIndexSnapshot,
id: string,
type?: string | null,
): ReferenceSite[] {
return findReferenceGroups(snapshot, id, type).flatMap((g) => g.sites);
}
/** True when a file belongs to at least one indexed stream. */
export function isFileActive(snapshot: AgentIndexSnapshot, file: string): boolean {
const key = normalizePath(file);
return snapshot.streams.some((s) =>
s.files.some((candidate) => normalizePath(candidate) === key),
);
}
/** Returns the streams that contain a file. */
export function streamsForFile(
snapshot: AgentIndexSnapshot,
file: string,
): AgentStream[] {
const key = normalizePath(file);
return snapshot.streams.filter((s) =>
s.files.some((candidate) => normalizePath(candidate) === key),
);
}
/** Finds a define by case-insensitive name. */
export function findDefine(
snapshot: AgentIndexSnapshot,
name: string,
): AgentDefine[] {
const wanted = name.toLowerCase();
return snapshot.defines.filter((d) => d.name.toLowerCase() === wanted);
}
/** Resolves an Include source using the snapshot's candidate list. */
export function resolveIncludeSource(
snapshot: AgentIndexSnapshot,
source: string,
): { source: string; path: string } | null {
const wanted = source.toLowerCase();
const hit = snapshot.sourceCandidates.find(
(c) => c.source.toLowerCase() === wanted,
);
return hit ? { source: hit.source, path: hit.path } : null;
}
/** Returns the snapshot's status (ready_xml/ready/stale). */
export function statusFromSnapshot(
snapshot: AgentIndexSnapshot | null | undefined,
): AgentIndexStatus {
if (!snapshot) {
return { state: "no_index", detail: "No index snapshot is available." };
}
const state: AgentIndexStatus["state"] = snapshot.stale
? "stale"
: !snapshot.complete
? "ready_xml"
: "ready";
return {
state,
projectDir: snapshot.projectDir,
phase: snapshot.phase,
complete: snapshot.complete,
stale: snapshot.stale,
generatedAt: snapshot.generatedAt,
buildId: snapshot.buildId,
stats: snapshot.stats,
};
}
/** Convenience aggregate returned by MCP/CLI query tools. */
export interface QueryResult<T> {
index: AgentIndexStatus;
data: T;
}
/** Wraps any query data with current index status. */
export function withStatus<T>(snapshot: AgentIndexSnapshot | null, data: T): QueryResult<T> {
return {
index: statusFromSnapshot(snapshot),
data,
};
}
+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));
}
+359
View File
@@ -0,0 +1,359 @@
/**
* Helpers for creating the stable MCP launcher and MCP client configuration.
*
* The launcher lives outside the VS Code extension install directory (under
* ~/.ra3modxml) so AI client configs do not break when the extension is
* updated to a new version. The extension refreshes the launcher on every
* activation/update.
*
* Pure TypeScript: no VS Code dependency.
*/
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { defaultAgentHome } from "./snapshot";
import {
launcherScript,
resolveRuntime,
type AgentRuntime,
} from "./runtime";
/** Key this extension uses for its MCP server entry in every client config. */
export const MCP_SERVER_KEY = "ra3-mod-xml";
export interface McpConfigTarget {
id: string;
label: string;
path: string;
}
/** Result of writing the stable launcher. */
export interface LauncherResult {
path: string;
serverPath: string;
runtime: AgentRuntime;
/**
* True when the launcher runs on the VS Code Electron runtime and therefore
* does not need a Node installation.
*/
nodeFree: boolean;
}
/** File name of the stable launcher on the current platform. */
export function launcherFileName(): string {
return process.platform === "win32" ? "ra3-mod-xml-mcp.cmd" : "ra3-mod-xml-mcp";
}
/** Absolute path to the stable launcher under the agent home. */
export function launcherPath(agentHome = defaultAgentHome()): string {
return join(agentHome, launcherFileName());
}
/**
* Path to the bundled MCP server inside an extension install/dev directory.
* The packaged extension ships this file under dist/agent/mcpServer.js.
*/
export function bundledMcpServerPath(extensionRoot: string): string {
return join(extensionRoot, "dist", "agent", "mcpServer.js");
}
/**
* 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(),
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 });
await writeFile(launcher, script, "utf8");
if (process.platform !== "win32") {
await chmod(launcher, 0o755);
}
return {
path: launcher,
serverPath: server,
runtime: resolved,
nodeFree: resolved.kind === "electron",
};
}
/** MCP client config entry for one project. */
export function mcpServerConfig(
launcher: string,
projectDir: string,
): Record<string, unknown> {
return {
mcpServers: {
"ra3-mod-xml": {
command: launcher,
args: ["--project", projectDir],
},
},
};
}
/** Human-readable JSON config block users can paste into AI clients. */
export function mcpConfigJson(
launcher: string,
projectDir: string,
): string {
return JSON.stringify(mcpServerConfig(launcher, projectDir), null, 2);
}
/** Claude Desktop config path (Windows/macOS/Linux common locations). */
export function claudeDesktopConfigPath(): string {
if (process.env.APPDATA) return join(process.env.APPDATA, "Claude", "claude_desktop_config.json");
return join(homedir(), ".config", "Claude", "claude_desktop_config.json");
}
/** Cursor's global MCP config path. */
export function cursorGlobalConfigPath(): string {
return join(homedir(), ".cursor", "mcp.json");
}
/** Cursor's project-scoped MCP config path. */
export function cursorProjectConfigPath(projectDir: string): string {
return join(projectDir, ".cursor", "mcp.json");
}
/** Common local MCP config files this extension can offer to update. */
export function commonMcpConfigTargets(projectDir: string): McpConfigTarget[] {
return [
{
id: "claude-desktop",
label: "Claude Desktop",
path: claudeDesktopConfigPath(),
},
{
id: "cursor-global",
label: "Cursor (global)",
path: cursorGlobalConfigPath(),
},
{
id: "cursor-project",
label: "Cursor (current project)",
path: cursorProjectConfigPath(projectDir),
},
];
}
/**
* Adds the RA3 Mod XML MCP server entry to a JSON config file, preserving any
* existing keys and `mcpServers`. Creates the file when it does not exist.
*/
export async function addMcpServerToConfigFile(
filePath: string,
launcher: string,
projectDir: string,
serverKey = MCP_SERVER_KEY,
): Promise<void> {
let config: Record<string, unknown> = {};
try {
config = JSON.parse(await readFile(filePath, "utf8")) as Record<string, unknown>;
} catch {
// File absent or malformed: start fresh.
}
const servers = (config.mcpServers as Record<string, unknown> | undefined) ?? {};
servers[serverKey] = {
command: launcher,
args: ["--project", projectDir],
};
config.mcpServers = servers;
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
}
/**
* Removes this extension's server entry from a JSON config file, preserving
* every other server and top-level key. Handles both the widely used
* `mcpServers` container and VS Code's `servers` container, and deletes a
* container that becomes empty.
*
* Returns true only when an entry was actually removed; a missing, malformed
* or already-clean file is left untouched.
*/
export async function removeMcpServerFromConfigFile(
filePath: string,
serverKey = MCP_SERVER_KEY,
): Promise<boolean> {
let config: Record<string, unknown>;
try {
config = JSON.parse(await readFile(filePath, "utf8")) as Record<string, unknown>;
} catch {
return false;
}
let removed = false;
for (const containerKey of ["mcpServers", "servers"] as const) {
const container = config[containerKey];
if (!container || typeof container !== "object" || Array.isArray(container)) {
continue;
}
const servers = container as Record<string, unknown>;
if (!(serverKey in servers)) continue;
delete servers[serverKey];
removed = true;
if (Object.keys(servers).length === 0) delete config[containerKey];
}
if (!removed) return false;
await writeFile(filePath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
return true;
}
/** One MCP config file this extension wrote, so uninstall can undo exactly that. */
export interface McpInstallRecord {
path: string;
serverKey: string;
/** Top-level JSON container the entry was written under. */
format: "mcpServers" | "servers";
/** Human label (Claude Desktop / Cursor global / ...). */
label?: string;
sourceVersion?: string;
}
/** Path to the managed MCP config install record under the agent home. */
export function mcpInstallRecordPath(agentHome = defaultAgentHome()): string {
return join(agentHome, "mcp-install.json");
}
/** Reads the managed MCP config install record, or returns an empty list. */
export async function readMcpInstallRecord(
agentHome = defaultAgentHome(),
): Promise<McpInstallRecord[]> {
try {
const parsed = JSON.parse(
await readFile(mcpInstallRecordPath(agentHome), "utf8"),
) as { installed?: McpInstallRecord[] };
return Array.isArray(parsed.installed) ? parsed.installed : [];
} catch {
return [];
}
}
/** Writes the managed MCP config install record. */
export async function writeMcpInstallRecord(
installed: McpInstallRecord[],
agentHome = defaultAgentHome(),
): Promise<void> {
const file = mcpInstallRecordPath(agentHome);
await mkdir(dirname(file), { recursive: true });
await writeFile(file, JSON.stringify({ installed }, null, 2), "utf8");
}
export interface InstallMcpConfigOptions {
filePath: string;
launcher: string;
projectDir: string;
label?: string;
sourceVersion?: string;
serverKey?: string;
agentHome?: string;
}
/**
* Writes the server entry into a config file and remembers that we did, so
* "uninstall AI Agent integration" can remove exactly what this extension
* created without touching the user's other MCP servers.
*/
export async function installMcpServerConfigToFile(
options: InstallMcpConfigOptions,
): Promise<void> {
const serverKey = options.serverKey ?? MCP_SERVER_KEY;
await addMcpServerToConfigFile(
options.filePath,
options.launcher,
options.projectDir,
serverKey,
);
const agentHome = options.agentHome ?? defaultAgentHome();
const path = resolve(options.filePath);
const installed = (await readMcpInstallRecord(agentHome)).filter(
(r) => resolve(r.path).toLowerCase() !== path.toLowerCase(),
);
installed.push({
path,
serverKey,
format: "mcpServers",
label: options.label,
sourceVersion: options.sourceVersion,
});
await writeMcpInstallRecord(installed, agentHome);
}
export interface UninstallMcpConfigOptions {
/** Also scan the conventional client paths for this project. */
projectDir?: string | null;
agentHome?: string;
serverKey?: string;
}
/**
* Removes the server entry from every config file this extension recorded,
* plus the conventional client config paths for `projectDir` (which covers
* configs written before the install record existed).
*
* Returns the paths that actually changed. Missing/malformed files count as
* "nothing to remove" and are not reported.
*/
export async function uninstallMcpServerConfigs(
options: UninstallMcpConfigOptions = {},
): Promise<string[]> {
const serverKey = options.serverKey ?? MCP_SERVER_KEY;
const agentHome = options.agentHome ?? defaultAgentHome();
const record = await readMcpInstallRecord(agentHome);
const candidates = new Map<string, string>();
for (const entry of record) {
if (entry.serverKey && entry.serverKey !== serverKey) continue;
candidates.set(resolve(entry.path).toLowerCase(), entry.path);
}
if (options.projectDir) {
for (const target of commonMcpConfigTargets(options.projectDir)) {
candidates.set(resolve(target.path).toLowerCase(), target.path);
}
}
const changed: string[] = [];
for (const path of candidates.values()) {
try {
if (await removeMcpServerFromConfigFile(path, serverKey)) changed.push(path);
} catch {
// Best effort; keep going through the remaining files.
}
}
const changedKeys = new Set(changed.map((p) => resolve(p).toLowerCase()));
await writeMcpInstallRecord(
record.filter((r) => !changedKeys.has(resolve(r.path).toLowerCase())),
agentHome,
);
return changed;
}
/** Deletes the stable MCP launcher. Returns false when nothing was removable. */
export async function removeLauncher(agentHome = defaultAgentHome()): Promise<boolean> {
try {
await rm(launcherPath(agentHome), { force: true });
return true;
} catch {
return false;
}
}
/** Default path used for the agent home. */
export function defaultAgentHomeForSetup(): string {
return join(homedir(), ".ra3modxml");
}
+496
View File
@@ -0,0 +1,496 @@
/**
* Agent Skill generator/installer for the RA3 Mod XML MCP tools.
*
* The Skill focuses on the functionality itself: when to use it and how to
* use the RA3 Mod XML MCP query tools. It intentionally avoids referencing
* any project-specific docs (e.g. docs/codebase-navigation-guide.md) so the
* agent is not distracted by unrelated workspace guidance.
*
* Pure TypeScript: no VS Code dependency.
*/
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { defaultAgentHome } from "./snapshot";
export const SKILL_NAME = "ra3-mod-xml";
export const SKILL_MARKER_FILE = ".ra3modxml-skill.json";
const SKILL_MD = `---
name: ra3-mod-xml
description: Use the RA3 Mod XML semantic index to find asset definitions, references, outgoing references, active files, defines, and include sources in SAGE / BinaryAssetBuilder XML projects such as Command & Conquer: Red Alert 3 mods. Use this when you need exact facts about mod assets instead of guessing or full-text searching the XML tree.
---
# RA3 Mod XML Index
This skill provides access to the semantic index built by the RA3 Mod XML VS Code extension.
## When this skill applies
Use these tools only for SAGE / BinaryAssetBuilder mod XML projects — the kind
used by Command & Conquer: Red Alert 3 mods.
Positive signals (any single one is enough to try the tools):
- \`Data/Mod.xml\` exists.
- \`Data/additionalmaps/mapmetadata_*.xml\` exists.
- A \`*.babproj\` file exists.
- XML whose root element is \`<AssetDeclaration>\`.
- XML that uses \`<Includes><Include source="DATA:…" /></Includes>\` or declares
\`xmlns="uri:ea.com:eala:asset"\`.
Do **not** use these tools for unrelated repositories. SAGE-looking XML, a
build script, copied \`.xsd\` schema files, or a folder named \`Data\` are not
by themselves evidence of a Red Alert 3 mod project: require at least one of
the positive signals above. When the repository is not a SAGE / RA3 mod
project, ignore this skill entirely.
If you are unsure whether the current project is in scope, call \`get_status\`
first: it is cheap and reports the \`projectDir\` the index belongs to. Stop
using the index tools when:
- the state is \`no_index\`, or
- the reported \`projectDir\` does not match the workspace you are working in.
In those cases read the files directly instead. Never present index results
from one project as if they belonged to another.
## When to use
Use this skill when you need any of the following:
- Find where an asset id is defined (GameObject, WeaponTemplate, Texture, etc.).
- Find which files/positions reference an asset id.
- Find which assets an asset references, and through which element/attribute.
- List assets of a particular type.
- Check whether a file is actually part of the active include graph (i.e. not a dead file).
- Resolve an Include source string.
- Look up a $DEFINE constant.
- Get the current index status and statistics.
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, live discovery has not been enabled on this
machine yet (or no VS Code window is running). 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/\`).
It is a plain Node script: run it with
\`node <path-to-cli.js> <command>\`. If Node is not installed, do not
install anything — run the same file with the VS Code runtime recorded in
the launcher instead:
\`ELECTRON_RUN_AS_NODE=1 <runtime executable from the launcher> <path-to-cli.js> <command>\`.
\`cli.js help\` lists the commands. The CLI 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 your own client's configuration. Most clients only load MCP
servers at startup, so tell the user the change takes effect in a new session;
check what your own client supports before promising otherwise.
Never fabricate index results. 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,
current, or belongs to the project you are working in.
2. Use narrow queries:
- \`find_asset(id, type?)\` for definition locations.
- \`find_references(id, type?)\` for incoming semantic references.
- \`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.
- \`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.
5. If the returned index state is \`stale\`, \`ready_xml\`, or \`building\`, treat
results as provisional.
6. Never read or dump the whole index snapshot; query narrowly.
See [query-guide.md](./references/query-guide.md) for the full tool reference,
parameter defaults and result shapes.
## Following references with get_asset_references
Use \`get_asset_references\` to answer "which weapon / model / upgrade / die-object
does this asset actually use" without reading source first. It returns **edges**,
not a flat list, so provenance is preserved:
- \`from\` is the asset you asked about.
- \`to\` is the resolved definition (file + line), or null when unresolved.
- \`via.element\` / \`via.parent\` / \`via.attribute\` say exactly which XML
element and attribute created the link.
- \`source\` is the file/line where that link is written.
- \`definedIn\` is present when the link comes from an \`inheritFrom\` ancestor's
XML rather than from the asset's own file. Inheritance is walked at the same
depth, so a base asset's weapon slot configuration is reported together with
the derived asset's own modules.
Practical rules:
- Start with the default \`depth: 1\` (the queried asset only). Raise it to 2 or 3
only for a specific node you already decided to follow. The maximum is 3.
- Pass \`targetTypes\` to cut noise, e.g. \`["WeaponTemplate"]\`, or
\`["GameObject"]\` for \`CreateObjectDie\`-style die-object links.
- If \`truncated\` is true, read \`omittedByTargetType\` and narrow the query
(smaller \`targetTypes\`, lower \`depth\`) instead of blindly retrying.
- Call \`find_references\` in the opposite direction: it tells you who else would
be affected by a change.
- Merged/inherited *effective values* are not computed. When \`xai:joinAction\`
(\`Replace\` / \`Remove\`) appears in the merge path, open the file and confirm
the real result yourself.
`;
const QUERY_GUIDE_MD = `# RA3 Mod XML query tool reference
The MCP server exposes these tools:
- get_status()
- find_asset(id, type?)
- find_references(id, type?)
- get_asset_references(id, type?, depth?, targetTypes?, maxEdges?, includeUnresolved?)
- list_assets_by_type(type, prefix?, limit?)
- is_file_active(path)
- find_define(name)
- resolve_include(source)
- list_projects()
- get_usage_guide()
\`get_asset_references\` and \`list_projects\` require a live index (VS Code
running with the project indexed). All other tools also answer from the last
exported snapshot when VS Code is closed.
## Result metadata
Every query result includes an \`index\` object:
\`\`\`json
{
"state": "ready",
"projectDir": "D:/Mods/Example",
"complete": true,
"stale": false
}
\`\`\`
Possible states:
- no_index: no index snapshot exists.
- building: a rebuild is in progress.
- ready_xml: XML assets are available but art assets may be incomplete.
- ready: complete index.
- stale: index may be outdated.
- error: last build failed.
Always check \`index.projectDir\`. If it does not match the project you are
working on, discard the result and read files directly instead.
## get_asset_references
Returns outgoing reference edges with full provenance:
\`\`\`json
{
"index": { "state": "ready", "projectDir": "D:/Mods/Example" },
"data": {
"roots": [{ "type": "GameObject", "id": "AthenaCannon", "file": "...", "line": 12 }],
"edges": [
{
"depth": 1,
"from": { "type": "GameObject", "id": "AthenaCannon" },
"to": { "type": "WeaponTemplate", "id": "AthenaCannonWeapon", "file": "...", "line": 88 },
"via": { "kind": "attribute", "element": "Weapon", "parent": "WeaponSlotHardpoint", "attribute": "Template" },
"source": { "file": "D:/Mods/Example/Data/Allied/Units/AthenaCannon.xml", "line": 40, "character": 24 }
}
],
"nodes": [],
"truncated": false,
"omittedByTargetType": {},
"warnings": []
}
}
\`\`\`
\`via.kind\` is one of \`attribute\`, \`content\` or \`inheritFrom\`. Edges carrying
\`definedIn\` come from an \`inheritFrom\` ancestor's XML.
This tool requires a **live** index (VS Code open with the project indexed).
It is not available from the on-disk snapshot because element context is not
stored there. When live is unavailable the tool returns an explicit error
instead of an empty result.
`;
export interface SkillInstallRecord {
/** Skill directory (the directory containing SKILL.md). */
path: string;
/** Extension version that installed/updated this copy. */
sourceVersion: string;
}
/**
* Writes a managed copy of the Skill into `targetDir` (the directory that
* should contain SKILL.md).
*/
export async function writeSkillTo(
targetDir: string,
sourceVersion: string,
): Promise<void> {
await mkdir(join(targetDir, "references"), { recursive: true });
await writeFile(join(targetDir, "SKILL.md"), SKILL_MD, "utf8");
await writeFile(
join(targetDir, "references", "query-guide.md"),
QUERY_GUIDE_MD,
"utf8",
);
const marker: SkillInstallRecord = {
path: targetDir,
sourceVersion,
};
await writeFile(
join(targetDir, SKILL_MARKER_FILE),
JSON.stringify(marker, null, 2),
"utf8",
);
}
/** Conventional ~/.agents/skills/<skill-name> path. */
export function agentsSkillsDirForUser(home = homedir()): string {
return join(home, ".agents", "skills", SKILL_NAME);
}
/** Conventional ~/.claude/skills/<skill-name> path (Claude Code). */
export function claudeSkillsDirForUser(home = homedir()): string {
return join(home, ".claude", "skills", SKILL_NAME);
}
/** Path to the managed-install record under the agent home. */
export function skillInstallRecordPath(agentHome = defaultAgentHome()): string {
return join(agentHome, "skill-install.json");
}
/** Reads the managed skill install record, or returns an empty list. */
export async function readSkillInstallRecord(
agentHome = defaultAgentHome(),
): Promise<SkillInstallRecord[]> {
try {
const text = await readFile(skillInstallRecordPath(agentHome), "utf8");
const parsed = JSON.parse(text) as { installed?: SkillInstallRecord[] };
return Array.isArray(parsed.installed) ? parsed.installed : [];
} catch {
return [];
}
}
/** Writes the managed skill install record. */
export async function writeSkillInstallRecord(
installed: SkillInstallRecord[],
agentHome = defaultAgentHome(),
): Promise<void> {
const file = skillInstallRecordPath(agentHome);
await mkdir(dirname(file), { recursive: true });
await writeFile(
file,
JSON.stringify({ installed }, null, 2),
"utf8",
);
}
/**
* Installs the Skill into several directories and records each managed copy.
* Returns the directories successfully written.
*/
export async function installSkillToDirectories(
directories: string[],
sourceVersion: string,
agentHome = defaultAgentHome(),
): Promise<string[]> {
const installed = await readSkillInstallRecord(agentHome);
const succeeded: string[] = [];
for (const dir of directories) {
try {
await writeSkillTo(dir, sourceVersion);
if (!installed.some((r) => r.path === dir)) {
installed.push({ path: dir, sourceVersion });
} else {
const record = installed.find((r) => r.path === dir);
if (record) record.sourceVersion = sourceVersion;
}
succeeded.push(dir);
} catch {
// Keep going; caller can surface per-directory failures.
}
}
await writeSkillInstallRecord(installed, agentHome);
return succeeded;
}
/** Removes one managed Skill directory and its install record entry. */
export async function uninstallSkillFromDirectory(
directory: string,
agentHome = defaultAgentHome(),
): Promise<void> {
await rm(directory, { recursive: true, force: true });
const installed = (await readSkillInstallRecord(agentHome)).filter(
(r) => r.path !== directory,
);
await writeSkillInstallRecord(installed, agentHome);
}
/**
* Reads the managed marker of a Skill directory.
*
* Returns null when the directory has no `.ra3modxml-skill.json`, or when the
* marker does not describe that exact directory. A hand-copied or
* third-party skill is therefore never treated as ours, which is what makes
* uninstall/update safe.
*/
export async function readSkillMarker(
directory: string,
): Promise<SkillInstallRecord | null> {
try {
const parsed = JSON.parse(
await readFile(join(directory, SKILL_MARKER_FILE), "utf8"),
) as SkillInstallRecord;
if (!parsed?.path) return null;
if (resolve(parsed.path) !== resolve(directory)) return null;
return parsed;
} catch {
return null;
}
}
export interface InstalledSkillStatus extends SkillInstallRecord {
/** The recorded directory currently exists. */
exists: boolean;
/** The directory still carries our marker (safe to update/remove). */
managed: boolean;
}
/** Status of every recorded Skill copy (drives the manage/uninstall UI). */
export async function installedSkillStatus(
agentHome = defaultAgentHome(),
): Promise<InstalledSkillStatus[]> {
const installed = await readSkillInstallRecord(agentHome);
const out: InstalledSkillStatus[] = [];
for (const record of installed) {
let exists = false;
try {
exists = (await stat(record.path)).isDirectory();
} catch {
exists = false;
}
const managed = exists ? (await readSkillMarker(record.path)) != null : false;
out.push({ ...record, exists, managed });
}
return out;
}
/**
* Removes recorded Skill copies that still carry our marker. Directories that
* are missing, replaced by the user, or lack the marker are skipped and
* reported instead of being deleted.
*/
export async function uninstallRecordedSkills(
directories: readonly string[],
agentHome = defaultAgentHome(),
): Promise<{ removed: string[]; skipped: string[] }> {
const removed: string[] = [];
const skipped: string[] = [];
for (const dir of directories) {
const marker = await readSkillMarker(dir);
if (!marker) {
skipped.push(dir);
continue;
}
try {
await uninstallSkillFromDirectory(dir, agentHome);
removed.push(dir);
} catch {
skipped.push(dir);
}
}
return { removed, skipped };
}
/**
* Drops record entries for directories that are already gone, without
* touching the filesystem. Used by the uninstall UI when a recorded copy no
* longer exists.
*/
export async function forgetSkillInstallRecords(
directories: readonly string[],
agentHome = defaultAgentHome(),
): Promise<void> {
const wanted = new Set(directories.map((d) => resolve(d)));
const installed = (await readSkillInstallRecord(agentHome)).filter(
(r) => !wanted.has(resolve(r.path)),
);
await writeSkillInstallRecord(installed, agentHome);
}
/**
* Re-writes every recorded Skill copy with the current extension version.
*
* Copies that no longer carry our marker are never overwritten (that would
* clobber user content), but stay in the record so the uninstall UI can still
* show them. Records whose directory disappeared are dropped.
*/
export async function syncInstalledSkills(
sourceVersion: string,
agentHome = defaultAgentHome(),
): Promise<SkillInstallRecord[]> {
const installed = await readSkillInstallRecord(agentHome);
const synced: SkillInstallRecord[] = [];
for (const record of installed) {
let exists = false;
try {
exists = (await stat(record.path)).isDirectory();
} catch {
exists = false;
}
if (!exists) continue; // Nothing left to manage.
if (!(await readSkillMarker(record.path))) {
synced.push(record); // User-owned now: keep the record, never rewrite.
continue;
}
try {
await writeSkillTo(record.path, sourceVersion);
synced.push({ path: record.path, sourceVersion });
} catch {
synced.push(record); // Keep so a later run can retry.
}
}
await writeSkillInstallRecord(synced, agentHome);
return synced;
}
+204
View File
@@ -0,0 +1,204 @@
/**
* Convert an internal ModIndex into a stable, external agent snapshot and
* read/write those snapshots on disk.
*
* Pure TypeScript: no VS Code dependency, so CLI/MCP/tools can reuse this
* module outside the extension.
*/
import { createHash } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { gunzip, gzip } from "node:zlib";
import { promisify } from "node:util";
import type { ModIndex } from "../indexer/types";
import {
AGENT_SNAPSHOT_SCHEMA_VERSION,
type AgentIndexSnapshot,
type AgentIndexStatus,
} from "./types";
const gzipAsync = promisify(gzip);
const gunzipAsync = promisify(gunzip);
/** Default directory used for launcher/snapshots/skill installation state. */
export function defaultAgentHome(): string {
return join(homedir(), ".ra3modxml");
}
/** Directory where current external snapshots are stored. */
export function defaultSnapshotDir(agentHome = defaultAgentHome()): string {
return join(agentHome, "snapshots");
}
/**
* Stable, collision-resistant identity for one project directory.
* Case-insensitive (Windows paths) and independent of the current drive
* mapping case, so the same project always maps to the same key.
*/
export function projectHash(projectDir: string): string {
return createHash("sha1")
.update(resolve(projectDir).toLowerCase(), "utf8")
.digest("hex")
.slice(0, 12);
}
/**
* Short, filesystem-safe, human-readable prefix for a project (directory
* basename, sanitized). Falls back to "project" when the basename has no
* usable characters.
*/
export function projectSlug(projectDir: string): string {
const raw = basename(resolve(projectDir));
const slug = raw
.replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase();
return slug || "project";
}
/**
* A stable, readable file name for one project:
* `<slug>-<sha1-12>` (e.g. `corona-9f3a1c2b4d5e`).
*/
export function snapshotBaseName(projectDir: string): string {
return `${projectSlug(projectDir)}-${projectHash(projectDir)}`;
}
/** Converts the internal ModIndex to the stable external snapshot shape. */
export function snapshotFromIndex(index: ModIndex, buildId?: number): AgentIndexSnapshot {
const assets: AgentIndexSnapshot["assets"] = [];
for (const byId of index.assets.values()) {
for (const defs of byId.values()) {
assets.push(...defs);
}
}
const defines: AgentIndexSnapshot["defines"] = [];
for (const defs of index.defines.values()) {
defines.push(...defs);
}
const references: AgentIndexSnapshot["references"] = [];
for (const [key, sites] of index.references) {
const parts = key.split("\u0000");
if (parts.length !== 4) continue;
references.push({
type: parts[0],
id: parts[1],
file: parts[2],
line: Number(parts[3]) || 0,
sites,
});
}
const streams: AgentIndexSnapshot["streams"] = index.streams.map((s) => ({
name: s.name,
entry: s.entry,
files: [...s.files],
}));
return {
schemaVersion: AGENT_SNAPSHOT_SCHEMA_VERSION,
projectDir: index.projectDir,
sdkDir: index.sdkDir,
phase: index.phase,
complete: index.complete,
stale: index.stale,
generatedAt: new Date().toISOString(),
buildId,
stats: {
assetCount: assets.length,
referenceCount: index.references.size,
defineCount: defines.length,
fileCount: index.files.size,
streamCount: streams.length,
sourceCandidateCount: index.sourceCandidates.length,
manifestFileCount: index.manifests.size,
manifestAssetCount: index.stats.manifestAssetCount,
},
assets,
defines,
references,
streams,
sourceCandidates: index.sourceCandidates,
diagnostics: index.diagnostics,
};
}
/** Returns a status object for a missing/not-yet-built index. */
export function noIndexStatus(projectDir?: string, detail?: string): AgentIndexStatus {
return {
state: "no_index",
projectDir,
detail: detail ?? "No index has been built yet.",
};
}
/** Returns a status object for the current index state. */
export function statusFromIndex(index: ModIndex | null | undefined, projectDir?: string): AgentIndexStatus {
if (!index) return noIndexStatus(projectDir);
const state: AgentIndexStatus["state"] = index.stale
? "stale"
: !index.complete
? "ready_xml"
: "ready";
return {
state,
projectDir: index.projectDir,
phase: index.phase,
complete: index.complete,
stale: index.stale,
generatedAt: new Date().toISOString(),
stats: {
assetCount: index.stats.assetCount,
referenceCount: index.stats.referenceCount,
defineCount: index.stats.defineCount,
fileCount: index.stats.indexedFiles,
streamCount: index.stats.streams,
sourceCandidateCount: index.stats.sourceCandidates,
manifestFileCount: index.stats.manifestFiles,
manifestAssetCount: index.stats.manifestAssetCount,
},
};
}
/** Serializes a snapshot to a JSON string (not compressed). */
export function snapshotToJson(snapshot: AgentIndexSnapshot): string {
return JSON.stringify(snapshot);
}
/** Writes a snapshot as gzip-compressed JSON using atomic temp+rename. */
export async function writeSnapshotFile(
filePath: string,
snapshot: AgentIndexSnapshot,
): Promise<string> {
const payload = Buffer.from(snapshotToJson(snapshot), "utf8");
const buf = await gzipAsync(payload);
const target = resolve(filePath);
await mkdir(dirname(target), { recursive: true });
const tmp = `${target}.tmp`;
await writeFile(tmp, buf);
await rename(tmp, target);
return target;
}
/** Reads a gzip-compressed JSON snapshot written by writeSnapshotFile. */
export async function readSnapshotFile(filePath: string): Promise<AgentIndexSnapshot | null> {
try {
const buf = await readFile(filePath);
const text = (await gunzipAsync(buf)).toString("utf8");
return JSON.parse(text) as AgentIndexSnapshot;
} catch {
return null;
}
}
/** Builds the conventional snapshot path for a project under the agent home. */
export function snapshotPathForProject(
projectDir: string,
agentHome = defaultAgentHome(),
): string {
return join(defaultSnapshotDir(agentHome), `${snapshotBaseName(projectDir)}.json.gz`);
}
+103
View File
@@ -0,0 +1,103 @@
/**
* Public, stable data types for exposing RA3 Mod XML indexes to AI Agents
* and external tools.
*
* These types intentionally mirror the internal index model but use plain
* serializable arrays instead of Maps/Sets. They are independent of the VS
* Code API and of the extension's internal workspaceStorage layout.
*/
import type {
AssetDef,
DefineDef,
IndexerDiagnostic,
ReferenceSite,
SourceCandidate,
} from "../indexer/types";
/** Current external snapshot schema version. */
export const AGENT_SNAPSHOT_SCHEMA_VERSION = 1;
export type AgentIndexState =
| "no_index"
| "building"
| "ready_xml"
| "ready"
| "stale"
| "error";
export interface AgentIndexStats {
assetCount: number;
referenceCount: number;
defineCount: number;
fileCount: number;
streamCount: number;
sourceCandidateCount: number;
manifestFileCount: number;
manifestAssetCount: number;
}
export interface AgentAsset extends AssetDef {
// AssetDef is already plain/serializable.
}
export interface AgentDefine extends DefineDef {
// DefineDef is already plain/serializable.
}
/** A stream (static or global:<name>) with the normalized file paths in it. */
export interface AgentStream {
name: string;
entry: string;
files: string[];
}
/** Reference sites grouped by the definition they point to. */
export interface AgentReferenceGroup {
type: string;
id: string;
file: string;
line: number;
sites: ReferenceSite[];
}
/**
* Immutable, tool-facing snapshot of one project index.
*/
export interface AgentIndexSnapshot {
schemaVersion: number;
projectDir: string;
sdkDir: string;
/** Last finished phase: "xml" or "art". */
phase: "xml" | "art";
complete: boolean;
stale?: boolean;
generatedAt: string;
/** Build counter from the workspace; useful for change detection. */
buildId?: number;
stats: AgentIndexStats;
assets: AgentAsset[];
defines: AgentDefine[];
/**
* Reverse references grouped by target definition key.
* Consumers normally filter by `type` + `id`, then aggregate groups.
*/
references: AgentReferenceGroup[];
streams: AgentStream[];
sourceCandidates: SourceCandidate[];
diagnostics: IndexerDiagnostic[];
}
/** Status returned by query interfaces when an index may not be ready. */
export interface AgentIndexStatus {
state: AgentIndexState;
projectDir?: string;
phase?: "xml" | "art";
complete?: boolean;
stale?: boolean;
generatedAt?: string;
buildId?: number;
stats?: AgentIndexStats;
/** Human-readable explanation for no_index/error states. */
detail?: string;
}
+749 -1
View File
@@ -1,4 +1,7 @@
import * as vscode from "vscode";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { stripBom } from "./language/xmlParser";
import { ModWorkspace } from "./workspace";
import { SdkSetup } from "./sdkSetup";
import { Ra3CompletionProvider } from "./features/completion";
@@ -15,12 +18,59 @@ import {
findUnreferencedAssets,
findUnreferencedAssetsOfType,
} from "./features/unreferenced";
import { findAsset } from "./features/findAsset";
import { Ra3Diagnostics } from "./features/diagnostics";
import {
Ra3SemanticTokensProvider,
RA3_SEMANTIC_TOKENS_LEGEND,
} from "./features/semanticTokens";
import { t } from "./localize";
import {
snapshotFromIndex,
snapshotPathForProject,
writeSnapshotFile,
} from "./agent/snapshot";
import {
claudeDesktopConfigPath,
cursorGlobalConfigPath,
cursorProjectConfigPath,
installMcpServerConfigToFile,
launcherPath,
mcpConfigJson,
removeLauncher,
uninstallMcpServerConfigs,
writeLauncher,
} from "./agent/setup";
import {
SKILL_NAME,
agentsSkillsDirForUser,
claudeSkillsDirForUser,
forgetSkillInstallRecords,
installSkillToDirectories,
installedSkillStatus,
readSkillInstallRecord,
syncInstalledSkills,
uninstallRecordedSkills,
} from "./agent/skill";
import {
shouldOfferAgentOnboarding,
type AgentOnboardingState,
} from "./agent/onboarding";
import { startLocalServer, type LocalServerHandle } from "./agent/localServer";
import { parseLoadedXml } from "./agent/forwardRefs";
import {
clearEndpointForProject,
writeEndpoint,
writeEndpointForProject,
} from "./agent/endpoint";
import {
clearInstance,
makeInstanceId,
pruneInstances,
refreshDiscovery,
writeInstance,
writeManifest,
} from "./agent/instances";
const XML_SELECTOR: vscode.DocumentSelector = [{ language: "xml" }];
/** Safety-net refresh interval while a rebuild is running. */
@@ -96,9 +146,232 @@ export function activate(context: vscode.ExtensionContext): void {
ws.log("[codelens] retry started");
};
ws.onBuildStart = startCodeLensRetry;
/** Running extension version (used by the agent onboarding / upgrade sync). */
const extensionVersion = String(
(context.extension.packageJSON as { version?: string }).version ?? "dev",
);
// Coalesced agent snapshot refresh: after AI Agent access is enabled, keep
// the external snapshot current without writing on every intermediate
// rebuild. The timer only fires after a quiet period following a complete,
// non-stale final index.
let agentAccessEnabled = context.workspaceState.get<boolean>(
"ra3modxml.agentAccessEnabled",
false,
);
const AGENT_ONBOARDING_KEY = "ra3modxml.agentOnboarding";
/** Session guard: one notification at most per activation. */
let agentOnboardingChecked = false;
/**
* One-time introduction of the AI Agent feature. Shown after the first
* index for this workspace, once per machine (see agent/onboarding.ts for
* the anti-nag rules). Choosing an action is optional and silent.
*/
const maybeOfferAgentOnboarding = async (): Promise<void> => {
if (agentOnboardingChecked) return;
if (!ws.activeIndex()) return;
const state = context.globalState.get<AgentOnboardingState>(
AGENT_ONBOARDING_KEY,
);
if (!shouldOfferAgentOnboarding(state, extensionVersion)) {
agentOnboardingChecked = true;
return;
}
agentOnboardingChecked = true;
const enable = t("Enable AI Agent access…");
const learnMore = t("Learn more");
const never = t("Don't show again");
const pick = await vscode.window.showInformationMessage(
t(
"RA3 Mod XML: this version can expose the project's asset index to AI agents (MCP + Agent Skill). Enable it?",
),
enable,
learnMore,
never,
);
// Whatever the user chose (including ignoring the message), remember that
// this version already informed them so it never becomes a recurring nag.
await context.globalState.update(AGENT_ONBOARDING_KEY, {
informedVersion: extensionVersion,
dismissed: pick === never,
} satisfies AgentOnboardingState);
if (pick === enable) {
void vscode.commands.executeCommand("ra3modxml.enableAgentAccess");
} else if (pick === learnMore) {
void vscode.env.openExternal(
vscode.Uri.parse(
"https://github.com/RA3CoronaDevelopers/Ra3ModXmlExt#ai-agent-access",
),
);
}
};
const AGENT_SNAPSHOT_QUIET_MS = 5000;
let agentSnapshotTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleAgentSnapshot = (): void => {
if (!agentAccessEnabled) return;
if (agentSnapshotTimer) clearTimeout(agentSnapshotTimer);
agentSnapshotTimer = setTimeout(() => {
agentSnapshotTimer = null;
const current = ws.activeIndex();
if (!current?.complete || current.stale === true) return;
void writeSnapshotFile(
snapshotPathForProject(current.projectDir),
snapshotFromIndex(current, ws.buildCount),
).catch((err) => {
ws.log(
`[agent-snapshot] export failed: ${err instanceof Error ? err.message : String(err)}`,
);
});
}, AGENT_SNAPSHOT_QUIET_MS);
};
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.
getIndex: (projectDir) =>
projectDir ? ws.indexForProject(projectDir) : ws.activeIndex(),
listProjects: () => ws.getProjectRoots(),
loadFile: async (file) => {
const text = stripBom(await readFile(file, "utf8"));
return parseLoadedXml(text);
},
});
agentLocalServer = handle;
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,
projects,
processId: process.pid,
updatedAt: new Date().toISOString(),
};
// One file per project, so two open windows cannot shadow each other.
for (const project of projects) {
const file = await writeEndpointForProject(project, {
...endpoint,
projectDir: project,
});
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(
`[agent-local-server] failed to start: ${err instanceof Error ? err.message : String(err)}`,
);
}
};
/**
* 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 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);
// Other VS Code windows may still be serving an index. Re-derive the
// legacy global pointer and the merged manifest from the surviving
// instance files instead of clearing them, so closing this window never
// hides a still-running window from agents reading ~/.ra3modxml/index.json.
await refreshDiscovery().catch(() => undefined);
};
/**
* 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, and so the merged manifest is
* refreshed.
*/
const refreshAgentEndpoints = async (): Promise<void> => {
if (!agentLocalServer) return;
const url = `http://127.0.0.1:${agentLocalServer.port}`;
const token = agentLocalServer.token;
const projects = ws.getProjectRoots();
const base = {
url,
token,
projects,
processId: process.pid,
updatedAt: new Date().toISOString(),
};
for (const project of projects) {
try {
await writeEndpointForProject(project, { ...base, projectDir: project });
agentEndpointProjects.add(project);
} catch (err) {
ws.log(
`[agent-local-server] could not write endpoint for ${project}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
const instances = await refreshInstanceFiles(url, token, projects);
await writeManifest(instances);
};
context.subscriptions.push({
dispose: () => {
if (codeLensRetryTimer) clearInterval(codeLensRetryTimer);
if (agentSnapshotTimer) clearTimeout(agentSnapshotTimer);
void stopAgentLocalServer();
},
});
context.subscriptions.push(
@@ -123,6 +396,9 @@ export function activate(context: vscode.ExtensionContext): void {
`[codelens] refresh (project=${idx.stats.projectDir}, phase=${idx.phase}, assets=${idx.stats.assetCount}, complete=${idx.complete}, stale=${idx.stale === true})`,
);
}
scheduleAgentSnapshot();
void refreshAgentEndpoints();
void maybeOfferAgentOnboarding();
for (const doc of vscode.workspace.textDocuments) {
if (doc.languageId === "xml") void diagnostics.update(doc);
}
@@ -282,6 +558,408 @@ export function activate(context: vscode.ExtensionContext): void {
);
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.exportIndexSnapshot", async () => {
const idx = ws.activeIndex();
if (!idx) {
void vscode.window.showInformationMessage(
t(
"RA3 Mod XML: no index available yet. Wait for indexing to finish before exporting an AI Agent snapshot.",
),
);
return;
}
const path = snapshotPathForProject(idx.projectDir);
try {
const snapshot = snapshotFromIndex(idx, ws.buildCount);
await writeSnapshotFile(path, snapshot);
void vscode.window.showInformationMessage(
t(
"RA3 Mod XML: exported AI Agent index snapshot to {0}",
path,
),
t("Reveal in Explorer"),
).then((pick) => {
if (pick) void vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(path));
});
} catch (err) {
void vscode.window.showErrorMessage(
t(
"RA3 Mod XML: failed to export AI Agent index snapshot: {0}",
err instanceof Error ? err.message : String(err),
),
);
}
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.enableAgentAccess", async () => {
const idx = ws.activeIndex();
if (!idx) {
void vscode.window.showInformationMessage(
t(
"RA3 Mod XML: no index available yet. Wait for indexing to finish before enabling AI Agent access.",
),
);
return;
}
const projectDir = idx.projectDir;
try {
const snapshot = snapshotFromIndex(idx, ws.buildCount);
await writeSnapshotFile(snapshotPathForProject(projectDir), snapshot);
const launcher = await writeLauncher(
context.extensionUri.fsPath,
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();
const version = String((context.extension.packageJSON as { version?: string }).version ?? "dev");
const installSkill = t("Install Agent Skill (recommended)");
const writeClaude = t("Write MCP config to Claude Desktop");
const writeCursorGlobal = t("Write MCP config to Cursor (global)");
const writeCursorProject = t("Write MCP config to Cursor (project)");
const copyConfig = t("Copy MCP config");
const pick = await vscode.window.showQuickPick(
[
{
label: installSkill,
description: agentsSkillsDirForUser(),
id: "skill",
},
{
label: writeClaude,
description: claudeDesktopConfigPath(),
id: "claude",
},
{
label: writeCursorGlobal,
description: cursorGlobalConfigPath(),
id: "cursor-global",
},
{
label: writeCursorProject,
description: cursorProjectConfigPath(projectDir),
id: "cursor-project",
},
{
label: copyConfig,
id: "copy",
},
],
{
placeHolder: t("RA3 Mod XML AI Agent access enabled. Choose an optional next step."),
},
);
if (pick?.id === "skill") {
const target = agentsSkillsDirForUser();
const installed = await installSkillToDirectories([target], version);
if (installed.length) {
void vscode.window.showInformationMessage(
t("RA3 Mod XML Agent Skill installed to {0}", target),
);
} else {
void vscode.window.showErrorMessage(
t("RA3 Mod XML: could not write the Agent Skill to {0}", target),
);
}
} else if (pick?.id === "claude") {
await installMcpServerConfigToFile({
filePath: claudeDesktopConfigPath(),
launcher: launcher.path,
projectDir,
label: "Claude Desktop",
sourceVersion: version,
});
void vscode.window.showInformationMessage(
t("RA3 Mod XML MCP config written to {0}", claudeDesktopConfigPath()),
);
} else if (pick?.id === "cursor-global") {
await installMcpServerConfigToFile({
filePath: cursorGlobalConfigPath(),
launcher: launcher.path,
projectDir,
label: "Cursor (global)",
sourceVersion: version,
});
void vscode.window.showInformationMessage(
t("RA3 Mod XML MCP config written to {0}", cursorGlobalConfigPath()),
);
} else if (pick?.id === "cursor-project") {
await installMcpServerConfigToFile({
filePath: cursorProjectConfigPath(projectDir),
launcher: launcher.path,
projectDir,
label: "Cursor (project)",
sourceVersion: version,
});
void vscode.window.showInformationMessage(
t("RA3 Mod XML MCP config written to {0}", cursorProjectConfigPath(projectDir)),
);
} else if (pick?.id === "copy") {
await vscode.env.clipboard.writeText(configJson);
void vscode.window.showInformationMessage(
t("RA3 Mod XML MCP config copied to clipboard."),
);
}
} catch (err) {
void vscode.window.showErrorMessage(
t(
"RA3 Mod XML: failed to enable AI Agent access: {0}",
err instanceof Error ? err.message : String(err),
),
);
}
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.installAgentSkill", async () => {
// Installing the Skill only writes text files; it does not need an
// index, so users can prepare their agent tooling before the first build.
const projectDir = ws.projectRoot;
const version = String((context.extension.packageJSON as { version?: string }).version ?? "dev");
const choices: Array<{
label: string;
description: string;
target: string | null;
picked?: boolean;
}> = [
{
label: t("Default (~/.agents/skills)"),
description: agentsSkillsDirForUser(),
target: agentsSkillsDirForUser(),
picked: true,
},
{
label: t("Claude Code (~/.claude/skills)"),
description: claudeSkillsDirForUser(),
target: claudeSkillsDirForUser(),
},
];
if (projectDir) {
choices.push(
{
label: t("Current project .agents/skills"),
description: join(projectDir, ".agents", "skills", SKILL_NAME),
target: join(projectDir, ".agents", "skills", SKILL_NAME),
},
{
label: t("Current project .claude/skills"),
description: join(projectDir, ".claude", "skills", SKILL_NAME),
target: join(projectDir, ".claude", "skills", SKILL_NAME),
},
);
}
choices.push({
label: t("Choose a custom folder…"),
description: t("The skill is installed as <folder>/{0}", SKILL_NAME),
target: null,
});
const picked = await vscode.window.showQuickPick(choices, {
canPickMany: true,
placeHolder: t("Select Agent Skill install locations"),
});
if (!picked?.length) return;
const targets = picked
.map((p) => p.target)
.filter((p): p is string => p != null);
if (picked.some((p) => p.target == null)) {
const folder = await vscode.window.showOpenDialog({
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
openLabel: t("Choose a skill folder"),
title: t("Choose the folder that should contain the {0} skill", SKILL_NAME),
});
const dir = folder?.[0]?.fsPath;
if (dir) targets.push(join(dir, SKILL_NAME));
}
if (!targets.length) return;
const succeeded = await installSkillToDirectories(targets, version);
const failed = targets.length - succeeded.length;
if (!succeeded.length) {
void vscode.window.showErrorMessage(
t("RA3 Mod XML: could not write the Agent Skill (0 of {0} locations).", targets.length),
);
return;
}
void vscode.window.showInformationMessage(
failed > 0
? t(
"RA3 Mod XML Agent Skill installed to {0} location(s); {1} failed.",
succeeded.length,
failed,
)
: t("RA3 Mod XML Agent Skill installed to {0} location(s).", succeeded.length),
t("Show installed skills"),
).then((pick) => {
if (pick) void vscode.commands.executeCommand("ra3modxml.uninstallAgentSkill");
});
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.uninstallAgentSkill", async () => {
const installed = await installedSkillStatus();
if (!installed.length) {
void vscode.window.showInformationMessage(
t("RA3 Mod XML: no recorded Agent Skill installation to remove."),
);
return;
}
const picks = await vscode.window.showQuickPick(
installed.map((entry) => ({
label: entry.path,
description: !entry.exists
? t("missing — will be dropped from the record")
: entry.managed
? t("installed by this extension (v{0})", entry.sourceVersion)
: t("not managed by this extension — will be skipped"),
detail: entry.managed || !entry.exists ? undefined : t("No RA3 Mod XML skill marker found; remove it manually if you want it gone."),
path: entry.path,
picked: entry.exists && entry.managed,
})),
{
canPickMany: true,
placeHolder: t("Select Agent Skill installations to remove"),
},
);
if (!picks?.length) return;
const selected = picks.map((p) => p.path);
// A record whose directory is already gone only needs its record entry
// dropped; a managed directory is deleted by uninstallRecordedSkills.
const missing = installed
.filter((e) => selected.includes(e.path) && !e.exists)
.map((e) => e.path);
const { removed, skipped } = await uninstallRecordedSkills(
selected.filter((p) => !missing.includes(p)),
);
if (missing.length) {
await forgetSkillInstallRecords(missing);
}
void vscode.window.showInformationMessage(
skipped.length
? t(
"RA3 Mod XML: removed {0} Agent Skill installation(s); {1} skipped (not managed by this extension).",
removed.length,
skipped.length,
)
: t("RA3 Mod XML: removed {0} Agent Skill installation(s).", removed.length),
);
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.disableAgentAccess", async () => {
agentAccessEnabled = false;
await context.workspaceState.update("ra3modxml.agentAccessEnabled", false);
// Stops the loopback server and removes this window's instance /
// endpoint files; discovery files are re-derived from other windows.
await stopAgentLocalServer();
ws.log("[agent] AI Agent access disabled for this workspace");
void vscode.window.showInformationMessage(
t(
"RA3 Mod XML: AI Agent access disabled for this workspace. Installed skills and MCP client configurations were kept.",
),
t("Uninstall AI Agent integration…"),
).then((pick) => {
if (pick) void vscode.commands.executeCommand("ra3modxml.uninstallAgentIntegration");
});
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.uninstallAgentIntegration", async () => {
const projectDir = ws.projectRoot;
const skills = await installedSkillStatus();
const removableSkills = skills.filter((s) => s.exists);
const live = agentAccessEnabled || agentLocalServer != null;
const choices: Array<{
label: string;
description?: string;
id: "disable" | "skills" | "mcp" | "launcher";
picked?: boolean;
}> = [];
if (live) {
choices.push({
label: t("Stop live AI Agent access in this window"),
id: "disable",
picked: true,
});
}
if (removableSkills.length) {
choices.push({
label: t("Remove installed Agent Skills"),
description: t("{0} location(s)", removableSkills.length),
id: "skills",
picked: true,
});
}
choices.push(
{
label: t("Remove MCP client configuration entries"),
description: t("Claude Desktop / Cursor and files recorded by this extension"),
id: "mcp",
picked: true,
},
{
label: t("Remove the stable MCP launcher"),
description: launcherPath(),
id: "launcher",
},
);
const picked = await vscode.window.showQuickPick(choices, {
canPickMany: true,
placeHolder: t("Select what to remove (nothing is removed until you confirm)"),
});
if (!picked?.length) return;
const confirm = await vscode.window.showWarningMessage(
t("Remove the selected AI Agent components?"),
{ modal: true },
t("Remove"),
);
if (confirm !== t("Remove")) return;
const summary: string[] = [];
if (picked.some((p) => p.id === "disable")) {
agentAccessEnabled = false;
await context.workspaceState.update("ra3modxml.agentAccessEnabled", false);
await stopAgentLocalServer();
summary.push(t("live access stopped"));
}
if (picked.some((p) => p.id === "skills")) {
const { removed, skipped } = await uninstallRecordedSkills(
removableSkills.filter((s) => s.managed).map((s) => s.path),
);
summary.push(t("{0} skill installation(s) removed", removed.length));
if (skipped.length) {
summary.push(t("{0} unmanaged skill folder(s) skipped", skipped.length));
}
}
if (picked.some((p) => p.id === "mcp")) {
const changed = await uninstallMcpServerConfigs({ projectDir });
summary.push(t("MCP config removed from {0} file(s)", changed.length));
}
if (picked.some((p) => p.id === "launcher")) {
await removeLauncher();
summary.push(t("MCP launcher removed"));
}
void vscode.window.showInformationMessage(
t("RA3 Mod XML AI Agent cleanup: {0}.", summary.join("; ")),
);
}),
);
context.subscriptions.push(
vscode.commands.registerCommand(
"ra3modxml.showReferences",
@@ -289,6 +967,21 @@ export function activate(context: vscode.ExtensionContext): void {
void showReferencesForDef(ws, args),
),
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.findAsset", () => {
// Prefill with a single-line selection so the editor context menu entry
// can search for the id the user highlighted.
const editor = vscode.window.activeTextEditor;
const selected = editor
? editor.document.getText(editor.selection).trim()
: "";
const initial =
selected && !/[\r\n]/.test(selected) && selected.length <= 200
? selected
: "";
return findAsset(ws, initial);
}),
);
context.subscriptions.push(
vscode.commands.registerCommand(
"ra3modxml.findUnreferencedAssets",
@@ -302,8 +995,63 @@ export function activate(context: vscode.ExtensionContext): void {
),
);
/**
* 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();
}
/**
* Keeps recorded Skill copies in sync after an extension upgrade. Only runs
* when a recorded copy is at a different version, and never rewrites a
* directory that no longer carries our marker.
*/
void (async () => {
try {
const installed = await readSkillInstallRecord();
if (!installed.some((r) => r.sourceVersion !== extensionVersion)) return;
const synced = await syncInstalledSkills(extensionVersion);
ws.log(
`[agent] synced ${synced.length} installed skill(s) to v${extensionVersion}`,
);
} catch {
// Best effort: the install/uninstall commands can always repair this.
}
})();
void sdkSetup.evaluate(ws);
void ws.initialize().then(() => void sdkSetup.evaluate(ws));
void ws.initialize().then(() => {
void sdkSetup.evaluate(ws);
if (agentAccessEnabled) {
void startAgentLocalServer();
void refreshLaunchers();
}
});
}
export function deactivate(): void {
+283
View File
@@ -0,0 +1,283 @@
/**
* Pure asset-search core for the "Find asset…" command.
*
* A query is matched against the live `ModIndex` maps (type -> id ->
* definitions) plus the document-local overlay, so assets that only exist in
* the current file's include chain (including unsaved edits) are searchable
* too. Keeping the parsing/matching/ranking logic free of the VS Code API
* makes it unit-testable and reusable by other surfaces (CLI, tools).
*
* Supported query forms (all case-insensitive):
* - `Type:Id` — type filter plus id filter (both may be partial);
* - `Id` — exact / prefix / substring id match; type names match as
* well, so `GameObject` also lists the assets of that type;
* - `Type:` — every asset of that type.
*
* Manifest-style qualified names can carry several colon segments
* (`W3DContainer:W3DContainer:AUGunship`). Asset ids never contain ":", so the
* segment after the last colon is the id, exactly like `normalizeReferenceId`.
*/
import type { AssetDef, ModIndex } from "../indexer/types";
/** Default result cap; the UI reports how many matches were hidden. */
export const ASSET_SEARCH_LIMIT = 200;
export interface AssetSearchQuery {
/** Trimmed original text (used in messages). */
raw: string;
/** Lowercased type filter, or null when the query has no `Type:` part. */
type: string | null;
/** Lowercased id filter; "" when the query only filters by type. */
id: string;
}
/**
* One searchable asset (a `type:id` pair). The same id can be defined in
* several places (project + SDK + manifest, or duplicated files); the pair is
* what users search for, so the list is de-duplicated and the best-ranked
* definition is kept while `definitionCount` records the other sites.
*/
export interface AssetSearchCandidate {
def: AssetDef;
/** Distinct definition sites (file + line) for this type:id. */
definitionCount: number;
}
export interface AssetSearchResult {
matches: AssetSearchCandidate[];
/** Total matches before the limit was applied. */
total: number;
}
/** `0` exact, `1` prefix, `2` substring, `3` no match. */
const RANK_NONE = 3;
/** Parses a user query into a type filter and an id filter. */
export function parseAssetSearchQuery(rawInput: string): AssetSearchQuery {
const raw = rawInput.trim();
const colon = raw.indexOf(":");
if (colon < 0) return { raw, type: null, id: raw.toLowerCase() };
const type = raw.slice(0, colon).trim().toLowerCase();
const rest = raw.slice(colon + 1).trim();
const lastColon = rest.lastIndexOf(":");
const id = (lastColon >= 0 ? rest.slice(lastColon + 1) : rest)
.trim()
.toLowerCase();
return { raw, type: type || null, id };
}
/** True when the query asks for nothing (the UI shows only the placeholder). */
export function isEmptyAssetSearchQuery(query: AssetSearchQuery): boolean {
return !query.type && !query.id;
}
/**
* Lower rank = better. Document-local (unsaved) definitions win, then mod
* definitions, then SDK sources, then compiled manifests.
*/
export function assetOriginRank(def: AssetDef): number {
if (def.stream === "local") return 0;
switch (def.origin) {
case "project":
return 1;
case "sdk":
return 2;
default:
return 3;
}
}
/**
* Flattens the index (and the optional local overlay) into one de-duplicated
* candidate per `type:id`, keeping the best-ranked definition for each pair.
*/
export function collectAssetSearchCandidates(
index: ModIndex,
): AssetSearchCandidate[] {
const byKey = new Map<
string,
{ def: AssetDef; sites: Set<string>; count: number }
>();
const add = (def: AssetDef): void => {
const key = `${def.type}\u0000${def.id.toLowerCase()}`;
const site = `${def.file.toLowerCase()}\u0000${def.line}`;
let entry = byKey.get(key);
if (!entry) {
entry = { def, sites: new Set(), count: 0 };
byKey.set(key, entry);
}
if (entry.sites.has(site)) return;
entry.sites.add(site);
entry.count++;
if (assetOriginRank(def) < assetOriginRank(entry.def)) entry.def = def;
};
// Local overlay first: on equal origin rank the first definition wins, and
// the overlay carries the freshest text of the current file chain.
if (index.local) {
for (const byId of index.local.assets.values()) {
for (const defs of byId.values()) for (const def of defs) add(def);
}
}
for (const byId of index.assets.values()) {
for (const defs of byId.values()) for (const def of defs) add(def);
}
return [...byKey.values()].map((entry) => ({
def: entry.def,
definitionCount: entry.count,
}));
}
interface ScoredCandidate {
candidate: AssetSearchCandidate;
/** Lower is better: how strongly the query matched. */
tier: number;
/** id match rank (RANK_NONE when the query has no id part). */
idRank: number;
/** type match rank (RANK_NONE when the query has no type part). */
typeRank: number;
originRank: number;
}
/**
* Ranks the candidates for one query and returns the best `limit` matches
* plus the total number of matches (so the UI can say "N of M").
*/
export function searchAssetCandidates(
candidates: readonly AssetSearchCandidate[],
query: AssetSearchQuery,
limit: number = ASSET_SEARCH_LIMIT,
): AssetSearchResult {
if (isEmptyAssetSearchQuery(query)) return { matches: [], total: 0 };
const scored: ScoredCandidate[] = [];
for (const candidate of candidates) {
const score = scoreCandidate(candidate, query);
if (score) scored.push(score);
}
scored.sort(compareScoredCandidates);
const capped = Math.max(0, limit);
return {
matches: scored.slice(0, capped).map((entry) => entry.candidate),
total: scored.length,
};
}
function scoreCandidate(
candidate: AssetSearchCandidate,
query: AssetSearchQuery,
): ScoredCandidate | null {
const id = candidate.def.id.toLowerCase();
const type = candidate.def.type.toLowerCase();
const idRank = query.id ? rankMatch(id, query.id) : RANK_NONE;
// With a bare query the same text also matches type names, so
// `GameObject` lists that type's assets as well as every id containing
// "gameobject".
const typeRank = query.type
? rankMatch(type, query.type)
: query.id
? rankMatch(type, query.id)
: RANK_NONE;
if (query.id && query.type) {
if (idRank === RANK_NONE || typeRank === RANK_NONE) return null;
} else if (query.id) {
if (idRank === RANK_NONE && typeRank === RANK_NONE) return null;
} else if (typeRank === RANK_NONE) {
return null;
}
return {
candidate,
tier: bestTier(idRank, typeRank, query.type != null),
idRank,
typeRank,
originRank: assetOriginRank(candidate.def),
};
}
/** 0 exact, 1 prefix, 2 substring, 3 no match. */
function rankMatch(value: string, filter: string): number {
if (value === filter) return 0;
if (value.startsWith(filter)) return 1;
return value.includes(filter) ? 2 : RANK_NONE;
}
/**
* Match strength tiers. When the user filtered by type explicitly, only the
* id quality ranks the results (the type part is a filter, not a signal).
* Otherwise the strongest of the two matches wins.
*/
function bestTier(
idRank: number,
typeRank: number,
typeFiltered: boolean,
): number {
if (typeFiltered) {
if (idRank === 0) return 0;
if (idRank === 1) return 2;
if (idRank === 2) return 4;
return 6; // `Type:` alone: list the whole type
}
if (idRank === 0) return 0;
if (typeRank === 0) return 1;
if (idRank === 1) return 2;
if (typeRank === 1) return 3;
if (idRank === 2) return 4;
return 5;
}
function compareScoredCandidates(a: ScoredCandidate, b: ScoredCandidate): number {
return (
a.tier - b.tier ||
a.originRank - b.originRank ||
a.idRank - b.idRank ||
a.typeRank - b.typeRank ||
compareText(a.candidate.def.id, b.candidate.def.id) ||
compareText(a.candidate.def.type, b.candidate.def.type)
);
}
/**
* Plain code-unit comparison: asset ids and type names are ASCII, and this is
* much cheaper than `localeCompare` when a short query matches thousands of
* candidates (the list is re-ranked on every keystroke).
*/
function compareText(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
/**
* Every definition site of one search result, local overlay first and then by
* origin rank. Used by the UI when several definitions share a `type:id`
* (e.g. a mod override of a vanilla asset) so the user can pick which one to
* open.
*/
export function assetDefsForCandidate(
index: ModIndex,
candidate: AssetSearchCandidate,
): AssetDef[] {
const { type, id } = candidate.def;
const key = id.toLowerCase();
const out: AssetDef[] = [];
const seen = new Set<string>();
const push = (defs: readonly AssetDef[] | undefined): void => {
if (!defs) return;
for (const def of defs) {
const site = `${def.file.toLowerCase()}\u0000${def.line}`;
if (seen.has(site)) continue;
seen.add(site);
out.push(def);
}
};
push(index.local?.assets.get(type)?.get(key));
push(index.assets.get(type)?.get(key));
out.sort((a, b) => assetOriginRank(a) - assetOriginRank(b));
return out;
}
+243
View File
@@ -0,0 +1,243 @@
/**
* "RA3 Mod XML: Find asset…" — quick-pick search over indexed assets.
*
* One picker does both the typing and the selection: results are filtered by
* the extension itself rather than by VS Code's built-in label filter, so the
* `Type:Id` form (where the type never appears in the visible label) keeps
* working. Accepting a result jumps to its definition; when the same
* `type:id` exists in several places (mod override + SDK + compiled
* manifest) a second picker chooses which definition to open.
*/
import * as vscode from "vscode";
import { relative } from "node:path";
import {
ASSET_SEARCH_LIMIT,
assetDefsForCandidate,
collectAssetSearchCandidates,
parseAssetSearchQuery,
searchAssetCandidates,
type AssetSearchCandidate,
type AssetSearchQuery,
} from "./assetSearch";
import { assetDefinitionLocation } from "./navigation";
import { referenceSitesForDef } from "../indexer/referenceIndex";
import type { AssetDef, ModIndex } from "../indexer/types";
import type { ModWorkspace } from "../workspace";
import { t } from "../localize";
interface AssetPickItem extends vscode.QuickPickItem {
candidate: AssetSearchCandidate;
}
interface DefinitionPickItem extends vscode.QuickPickItem {
def: AssetDef;
}
/** Entry point for the `ra3modxml.findAsset` command. */
export async function findAsset(
ws: ModWorkspace,
initialQuery = "",
): Promise<void> {
const index = await searchIndexFor(ws);
if (!index) {
void vscode.window.showInformationMessage(
t("RA3 Mod XML: no index available yet."),
);
return;
}
const candidates = collectAssetSearchCandidates(index);
if (!candidates.length) {
void vscode.window.showInformationMessage(
t("RA3 Mod XML: the index has no assets yet — wait for indexing to finish."),
);
return;
}
const picked = await pickAsset(index, candidates, initialQuery.trim());
if (!picked) return;
await revealAsset(ws, index, picked);
}
/**
* Index used for searching: the active XML document's project index with its
* local overlay attached when possible (so unsaved edits are searchable),
* otherwise the active project's last published snapshot.
*/
async function searchIndexFor(ws: ModWorkspace): Promise<ModIndex | null> {
const editor = vscode.window.activeTextEditor;
if (editor && editor.document.languageId === "xml" && ws.isRa3Workspace()) {
try {
const scope = await ws.getScope(editor.document);
if (scope.merged) return scope.merged;
} catch {
// Fall through to the plain snapshot.
}
}
return ws.activeIndex();
}
function pickAsset(
index: ModIndex,
candidates: readonly AssetSearchCandidate[],
initialQuery: string,
): Promise<AssetSearchCandidate | undefined> {
return new Promise<AssetSearchCandidate | undefined>((resolve) => {
const picker = vscode.window.createQuickPick<AssetPickItem>();
picker.title = t("RA3 Mod XML: Find asset");
picker.placeholder = t(
"Id, partial id or Type:Id (e.g. WeaponTemplate:AssaultRifle)",
);
let settled = false;
const finish = (value: AssetSearchCandidate | undefined): void => {
if (settled) return;
settled = true;
resolve(value);
picker.dispose();
};
const refresh = (value: string): void => {
const query = parseAssetSearchQuery(value);
const { matches, total } = searchAssetCandidates(
candidates,
query,
ASSET_SEARCH_LIMIT,
);
picker.items = matches.map((candidate) => toPickItem(index, candidate));
picker.prompt = promptFor(query, matches.length, total);
if (picker.items.length) picker.activeItems = [picker.items[0]];
};
picker.onDidChangeValue((value) => refresh(value));
picker.onDidAccept(() => {
const item = picker.activeItems[0] ?? picker.items[0];
if (item) finish(item.candidate);
});
picker.onDidHide(() => finish(undefined));
picker.value = initialQuery;
refresh(initialQuery);
picker.show();
});
}
function toPickItem(
index: ModIndex,
candidate: AssetSearchCandidate,
): AssetPickItem {
const def = candidate.def;
const refs = referenceSitesForDef(index, def).length;
const description = [def.type, originLabel(def)];
if (refs > 0) {
description.push(refs === 1 ? t("1 reference") : t("{0} references", refs));
}
const detail = [`${displayPath(index.projectDir, def.file)}:${def.line}`];
if (candidate.definitionCount > 1) {
detail.push(t("{0} definitions", candidate.definitionCount));
}
return {
label: def.id,
description: description.join(" · "),
detail: detail.join(" · "),
// The picker filters the list itself: a `Type:Id` query never appears in
// the visible label, so VS Code's built-in filter must not hide it.
alwaysShow: true,
candidate,
};
}
function promptFor(
query: AssetSearchQuery,
shown: number,
total: number,
): string {
if (!query.type && !query.id) {
return t("Type an id, a partial id or Type:Id to search.");
}
if (total === 0) return t('No asset matches "{0}".', query.raw);
if (total > shown) {
return t(
"{0} of {1} matches — keep typing to narrow the list.",
shown,
total,
);
}
return total === 1 ? t("1 asset found.") : t("{0} assets found.", total);
}
/**
* Opens a search result. Definitions of the same `type:id` are offered when
* there is more than one (e.g. the mod override and the vanilla source).
*/
async function revealAsset(
ws: ModWorkspace,
index: ModIndex,
candidate: AssetSearchCandidate,
): Promise<void> {
const defs = assetDefsForCandidate(index, candidate);
let def = defs[0] ?? candidate.def;
if (defs.length > 1) {
const picked = await vscode.window.showQuickPick(
defs.map((d) => toDefinitionItem(index, d)),
{
title: `${def.type}:${def.id}`,
placeHolder: t("Select the definition to open"),
matchOnDescription: true,
},
);
if (!picked) return;
def = picked.def;
}
const location = await assetDefinitionLocation(ws, def, index);
if (!location) {
void vscode.window.showInformationMessage(
t(
"RA3 Mod XML: no XML source location for {0} ({1}).",
`${def.type}:${def.id}`,
displayPath(index.projectDir, def.file),
),
);
return;
}
const document = await vscode.workspace.openTextDocument(location.uri);
await vscode.window.showTextDocument(document, {
selection: location.range,
preview: true,
});
}
function toDefinitionItem(
index: ModIndex,
def: AssetDef,
): DefinitionPickItem {
return {
label: `${displayPath(index.projectDir, def.file)}:${def.line}`,
description: originLabel(def),
detail: `${def.type}:${def.id}`,
def,
};
}
function originLabel(def: AssetDef): string {
if (def.stream === "local") return t("local");
switch (def.origin) {
case "sdk":
return t("SDK");
case "manifest":
return t("manifest");
default:
return t("project");
}
}
function displayPath(projectDir: string, file: string): string {
const rel = relative(projectDir, file);
return rel && !rel.startsWith("..") ? rel : file;
}
+94 -1
View File
@@ -1,6 +1,11 @@
import * as vscode from "vscode";
import { dirname } from "node:path";
import { findElementAt, parseXml, textContentTokenAt } from "../language/xmlParser";
import {
findElementAt,
LineMap,
parseXml,
textContentTokenAt,
} from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import {
buildSearchPaths,
@@ -220,6 +225,94 @@ async function assetDefLocation(
);
}
/**
* Location of one asset definition for the asset search command.
*
* Unlike {@link assetDefLocation} this needs no document scope: an already
* open (possibly unsaved) document wins, then manifest sources are resolved
* with the SDK-only search paths, then the owning indexer's cached DOM is
* asked for the precise `id` attribute range. Returns null only when a
* manifest definition has no resolvable XML source at all.
*/
export async function assetDefinitionLocation(
ws: ModWorkspace,
def: AssetDef,
idx: ModIndex,
): Promise<vscode.Location | null> {
const open = vscode.workspace.textDocuments.find(
(doc) => scopePathKey(doc.uri.fsPath) === scopePathKey(def.file),
);
if (open) {
const precise = locationInText(open.uri, open.getText(), def.id);
if (precise) return precise;
}
// While a rebuild is running, avoid readDom() mutating the live indexer's
// caches mid-build; a line-based location is a fine temporary fallback.
if (ws.isBuilding) return lineLocation(def);
if (def.origin === "manifest") {
const src = def.manifestSource;
if (!src) return null;
// manifestSource is a vanilla build path: resolve it with SDK-only search
// paths so a mod file shadowing the same DATA: path cannot hijack the
// jump. Opening the binary manifest itself would not help the user, so a
// missing SDK source stays unresolved instead.
const resolved = resolveSource(
src,
null,
buildVanillaSearchPaths(idx.sdkDir),
).path;
if (!resolved) return null;
return (
(await locationInDocument(ws, resolved, def.id)) ??
new vscode.Location(vscode.Uri.file(resolved), new vscode.Position(0, 0))
);
}
return (await locationInDocument(ws, def.file, def.id)) ?? lineLocation(def);
}
function lineLocation(def: AssetDef): vscode.Location {
const line = Math.max(0, def.line - 1);
return new vscode.Location(
vscode.Uri.file(def.file),
new vscode.Range(new vscode.Position(line, 0), new vscode.Position(line, 1)),
);
}
/** Precise `id` range inside arbitrary (possibly unsaved) document text. */
function locationInText(
uri: vscode.Uri,
text: string,
id: string,
): vscode.Location | null {
const parsed = parseXml(text);
const wanted = id.toLowerCase();
const el = parsed.elements.find((e) =>
e.attrs.some((a) => a.name === "id" && a.value.toLowerCase() === wanted),
);
if (!el) return null;
const lineMap = new LineMap(text);
const idAttr = el.attrs.find((a) => a.name === "id");
if (idAttr?.hasValue) {
return new vscode.Location(
uri,
new vscode.Range(
toVscodePosition(lineMap.positionAt(idAttr.valueStart)),
toVscodePosition(lineMap.positionAt(idAttr.valueEnd)),
),
);
}
return new vscode.Location(
uri,
new vscode.Range(
toVscodePosition(lineMap.positionAt(el.start)),
toVscodePosition(lineMap.positionAt(el.startTagEnd)),
),
);
}
function locationInCurrentDocument(
scope: DocumentScope,
id: string,
+12
View File
@@ -229,6 +229,18 @@ export class ModWorkspace {
return this.activeState()?.indexer ?? null;
}
/**
* Index of one specific project (normalized absolute root), or null.
*
* The live agent server routes queries through this instead of
* `activeIndex()`, so a query for project A can never be answered from
* whatever project the user happens to be editing right now.
*/
indexForProject(projectDir: string): ModIndex | null {
if (!projectDir) return null;
return this.states.get(normKey(projectDir))?.index ?? null;
}
/**
* Discovers project roots from the current workspace folders and open
* documents, registers per-project state and starts the initial build(s):
+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 });
}
});
+102
View File
@@ -0,0 +1,102 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import {
clearEndpoint,
clearEndpointForProject,
endpointPathForProject,
isProcessAlive,
readEndpoint,
readEndpointForProject,
sameProject,
writeEndpoint,
writeEndpointForProject,
} from "../out/agent/endpoint.js";
const PROJECT_A = "D:/Mods/ExampleA";
const PROJECT_B = "D:/Mods/ExampleB";
test("endpoint file round-trips and clears", async () => {
const home = mkdtempSync(join(tmpdir(), "ra3-endpoint-test-"));
try {
await writeEndpoint(
{ url: "http://127.0.0.1:12345", token: "abc", projectDir: PROJECT_A },
home,
);
const loaded = await readEndpoint(home);
assert.equal(loaded?.url, "http://127.0.0.1:12345");
assert.equal(loaded?.token, "abc");
await clearEndpoint(home);
assert.equal(await readEndpoint(home), null);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("per-project endpoints do not shadow each other", async () => {
const home = mkdtempSync(join(tmpdir(), "ra3-endpoint-multi-"));
try {
// Two "windows" enable agent access for different projects.
await writeEndpointForProject(
PROJECT_A,
{ url: "http://127.0.0.1:1111", token: "token-a", processId: process.pid },
home,
);
await writeEndpointForProject(
PROJECT_B,
{ url: "http://127.0.0.1:2222", token: "token-b", processId: process.pid },
home,
);
const a = await readEndpointForProject(PROJECT_A, home);
const b = await readEndpointForProject(PROJECT_B, home);
assert.equal(a?.url, "http://127.0.0.1:1111");
assert.ok(sameProject(a.projectDir, PROJECT_A));
assert.equal(b?.url, "http://127.0.0.1:2222");
assert.ok(sameProject(b.projectDir, PROJECT_B));
// Clearing one project must not disturb the other.
await clearEndpointForProject(PROJECT_A, home);
assert.equal(await readEndpointForProject(PROJECT_A, home), null);
assert.equal((await readEndpointForProject(PROJECT_B, home))?.token, "token-b");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("a per-project endpoint recording another project is rejected", async () => {
const home = mkdtempSync(join(tmpdir(), "ra3-endpoint-mismatch-"));
try {
const file = endpointPathForProject(PROJECT_A, home);
// Simulate a stale/edited file that claims to serve a different project.
mkdirSync(dirname(file), { recursive: true });
writeFileSync(
file,
JSON.stringify({
url: "http://127.0.0.1:3333",
token: "t",
projectDir: PROJECT_B,
}),
);
assert.equal(await readEndpointForProject(PROJECT_A, home), null);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("isProcessAlive detects dead pids and trusts unknown ones", () => {
assert.equal(isProcessAlive(process.pid), true);
assert.equal(isProcessAlive(undefined), true);
assert.equal(isProcessAlive(0), true);
assert.equal(isProcessAlive(NaN), true);
// Not a valid Windows PID, so it cannot correspond to a running process.
assert.equal(isProcessAlive(0x7fffffff), false);
});
test("sameProject is case-insensitive", () => {
assert.equal(sameProject("D:/Mods/Example", "d:/mods/example"), true);
assert.equal(sameProject("D:/Mods/Example", "D:/Mods/Other"), false);
});
+308
View File
@@ -0,0 +1,308 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
collectAssetReferences,
parseLoadedXml,
} from "../out/agent/forwardRefs.js";
// ── Fixture files ────────────────────────────────────────────────────
// AthenaCannon has no WeaponSetUpdate of its own: it inherits BaseCannon,
// which owns the weapon slot, and has its own die-object content reference.
const ATHENA = `<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="AthenaCannon" inheritFrom="BaseCannon">
<CreateObjectDie>
<CreateObject>AthenaCannon_Die</CreateObject>
</CreateObjectDie>
</GameObject>
</AssetDeclaration>`;
const BASE = `<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="BaseCannon">
<WeaponSetUpdate>
<WeaponSlotHardpoint>
<Weapon Template="AthenaCannonWeapon" />
</WeaponSlotHardpoint>
</WeaponSetUpdate>
</GameObject>
</AssetDeclaration>`;
const DIE = `<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="AthenaCannon_Die">
<WeaponSetUpdate>
<WeaponSlotHardpoint>
<Weapon Template="DieExplosionWeapon" />
</WeaponSlotHardpoint>
</WeaponSetUpdate>
</GameObject>
</AssetDeclaration>`;
const FILES = {
"D:/Mods/Example/Data/AthenaCannon.xml": ATHENA,
"D:/Mods/Example/Data/BaseCannon.xml": BASE,
"D:/Mods/Example/Data/AthenaCannon_Die.xml": DIE,
};
function def(type, id, file, line) {
return { type, id, file, line, origin: "project", stream: "static" };
}
const ATHENA_DEF = def("GameObject", "AthenaCannon", "D:/Mods/Example/Data/AthenaCannon.xml", 3);
const BASE_DEF = def("GameObject", "BaseCannon", "D:/Mods/Example/Data/BaseCannon.xml", 3);
const DIE_DEF = def("GameObject", "AthenaCannon_Die", "D:/Mods/Example/Data/AthenaCannon_Die.xml", 3);
const WEAPON_DEF = def(
"WeaponTemplate",
"AthenaCannonWeapon",
"D:/Mods/Example/Data/Weapon.xml",
88,
);
const DIE_WEAPON_DEF = def(
"WeaponTemplate",
"DieExplosionWeapon",
"D:/Mods/Example/Data/Weapon.xml",
120,
);
function makeIndex() {
const all = [ATHENA_DEF, BASE_DEF, DIE_DEF, WEAPON_DEF, DIE_WEAPON_DEF];
const assetsById = new Map();
for (const d of all) {
const key = d.id.toLowerCase();
if (!assetsById.has(key)) assetsById.set(key, []);
assetsById.get(key).push(d);
}
const assets = new Map([
["GameObject", new Map([["athenacannon", [ATHENA_DEF]], ["basecannon", [BASE_DEF]], ["athenacannon_die", [DIE_DEF]]])],
["WeaponTemplate", new Map([["athenacannonweapon", [WEAPON_DEF]], ["dieexplosionweapon", [DIE_WEAPON_DEF]]])],
]);
return {
projectDir: "D:/Mods/Example",
sdkDir: "",
complete: true,
phase: "art",
assets,
assetsById,
defines: new Map(),
files: new Map(),
streams: [],
manifests: new Map(),
sourceCandidates: [],
diagnostics: [],
references: new Map(),
recordsHashes: new Map(),
stats: {},
};
}
function loader(map = FILES) {
return async (file) => {
const text = map[file];
return text ? parseLoadedXml(text) : null;
};
}
function findEdge(edges, predicate) {
return edges.find(predicate);
}
test("depth 1 returns only the queried asset's own edges", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
{},
);
assert.equal(result.roots.length, 1);
// Own content ref + inherited weapon ref + the inheritFrom edge itself.
assert.ok(result.edges.length >= 3);
assert.deepEqual(
[...new Set(result.edges.map((e) => e.depth))],
[1],
"all edges must be at depth 1",
);
assert.equal(result.truncated, false);
});
test("attribute references carry element, parent and attribute provenance", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
);
const weapon = findEdge(
result.edges,
(e) => e.via.kind === "attribute" && e.to?.id === "AthenaCannonWeapon",
);
assert.ok(weapon, "weapon edge should exist");
assert.equal(weapon.via.element, "Weapon");
assert.equal(weapon.via.parent, "WeaponSlotHardpoint");
assert.equal(weapon.via.attribute, "Template");
assert.equal(weapon.to.type, "WeaponTemplate");
assert.equal(weapon.to.line, 88);
assert.ok(weapon.source.file.endsWith("BaseCannon.xml"));
assert.ok(weapon.source.line > 0);
});
test("content references (CreateObjectDie) are reported with kind=content", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
);
const die = findEdge(result.edges, (e) => e.via.kind === "content");
assert.ok(die, "die-object content edge should exist");
assert.equal(die.via.element, "CreateObject");
assert.equal(die.via.parent, "CreateObjectDie");
assert.equal(die.via.attribute, null);
assert.equal(die.to.type, "GameObject");
assert.equal(die.to.id, "AthenaCannon_Die");
assert.ok(die.source.file.endsWith("AthenaCannon.xml"));
});
test("inheritFrom is walked and marked with definedIn", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
);
const inherit = findEdge(result.edges, (e) => e.via.kind === "inheritFrom");
assert.ok(inherit, "inheritFrom edge should exist");
assert.equal(inherit.to.id, "BaseCannon");
assert.equal(inherit.value, "BaseCannon");
assert.equal(inherit.definedIn, undefined, "the inheritFrom edge itself is on AthenaCannon");
const inheritedWeapon = findEdge(
result.edges,
(e) => e.to?.id === "AthenaCannonWeapon",
);
assert.ok(inheritedWeapon, "weapon from the ancestor must still be reported");
assert.deepEqual(inheritedWeapon.definedIn, { type: "GameObject", id: "BaseCannon" });
assert.equal(inheritedWeapon.from.id, "AthenaCannon", "edge is attributed to the queried asset");
});
test("targetTypes filters edges, but inheritFrom edges always survive", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
{ targetTypes: ["WeaponTemplate"] },
);
assert.ok(result.edges.length > 0);
assert.ok(
result.edges.some((e) => e.to.type === "WeaponTemplate"),
"expected at least one WeaponTemplate edge",
);
for (const edge of result.edges) {
// inheritFrom is kept so the caller can see where the weapon is written.
if (edge.via.kind === "inheritFrom") continue;
assert.equal(edge.to.type, "WeaponTemplate", `${edge.via.element} should be filtered out`);
}
// Nodes mirror the kept edges, so the inheritFrom target may appear too.
const inheritIds = new Set(
result.edges
.filter((e) => e.via.kind === "inheritFrom")
.map((e) => e.to.id.toLowerCase()),
);
for (const node of result.nodes) {
assert.ok(
node.type === "WeaponTemplate" || inheritIds.has(node.id.toLowerCase()),
`unexpected node ${node.type}:${node.id}`,
);
}
});
test("depth 2 expands into referenced assets", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
{ depth: 2 },
);
const depths = new Set(result.edges.map((e) => e.depth));
assert.ok(depths.has(2), "expected depth-2 edges from the die-object GameObject");
const dieWeapon = findEdge(result.edges, (e) => e.to?.id === "DieExplosionWeapon");
assert.ok(dieWeapon, "the die object's weapon should appear at depth 2");
assert.equal(dieWeapon.depth, 2);
assert.equal(dieWeapon.from.id, "AthenaCannon_Die");
});
test("depth is clamped to the max of 3", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
{ depth: 99 },
);
for (const edge of result.edges) {
assert.ok(edge.depth <= 3, `depth ${edge.depth} exceeded the clamp`);
}
});
test("maxEdges truncates and reports what was dropped", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
{ maxEdges: 1 },
);
assert.equal(result.edges.length, 1);
assert.equal(result.truncated, true);
const omitted = Object.values(result.omittedByTargetType).reduce((a, b) => a + b, 0);
assert.ok(omitted >= 1, "expected the dropped edges to be summarised");
});
test("unresolved references are opt-in", async () => {
const text = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="Ghost">
<WeaponSetUpdate><WeaponSlotHardpoint><Weapon Template="DoesNotExist" /></WeaponSlotHardpoint></WeaponSetUpdate>
</GameObject>
</AssetDeclaration>`;
const ghost = def("GameObject", "Ghost", "D:/Mods/Example/Data/Ghost.xml", 2);
const index = makeIndex();
index.assetsById.set("ghost", [ghost]);
const ghostLoader = loader({ "D:/Mods/Example/Data/Ghost.xml": text });
const without = await collectAssetReferences(index, "Ghost", "GameObject", ghostLoader);
assert.equal(without.edges.length, 0);
const withUnresolved = await collectAssetReferences(index, "Ghost", "GameObject", ghostLoader, {
includeUnresolved: true,
});
assert.equal(withUnresolved.edges.length, 1);
assert.equal(withUnresolved.edges[0].to, null);
assert.equal(withUnresolved.edges[0].value, "DoesNotExist");
});
test("unknown ids produce a warning instead of throwing", async () => {
const result = await collectAssetReferences(
makeIndex(),
"NoSuchAsset",
"GameObject",
loader(),
);
assert.equal(result.edges.length, 0);
assert.equal(result.roots.length, 0);
assert.equal(result.warnings.length, 1);
assert.match(result.warnings[0], /No definition found/);
});
test("unreadable files produce a warning and no edges", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
async () => null,
);
assert.equal(result.edges.length, 0);
assert.ok(result.warnings.some((w) => w.includes("Could not read")));
});
+219
View File
@@ -0,0 +1,219 @@
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,
refreshDiscovery,
writeInstance,
writeManifest,
} from "../out/agent/instances.js";
import { endpointPath, readEndpoint } from "../out/agent/endpoint.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);
});
test("refreshDiscovery keeps surviving windows discoverable", async () => {
const home = instanceHome();
try {
const mine = makeInstance("mine");
const other = {
...makeInstance("other"),
url: "http://127.0.0.1:19999",
token: "tok-other",
projectDir: "D:/Mods/Beta",
projects: ["D:/Mods/Beta"],
};
await writeInstance(mine, home);
await writeInstance(other, home);
// This window closes: only its own instance file goes away.
await clearInstance(mine.instanceId, home);
const manifest = await refreshDiscovery(home);
assert.deepEqual(manifest.projects, ["D:/Mods/Beta"]);
assert.deepEqual(manifest.instances.map((i) => i.instanceId), ["other"]);
// The legacy global pointer must follow the survivor, not be deleted.
const endpoint = await readEndpoint(home);
assert.equal(endpoint?.url, other.url);
assert.equal(endpoint?.token, other.token);
assert.equal(existsSync(endpointPath(home)), true);
// The merged manifest on disk must agree with the returned value.
const reread = await readManifest(home);
assert.deepEqual(reread?.instances.map((i) => i.instanceId), ["other"]);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("refreshDiscovery clears the global pointer when the last window closes", async () => {
const home = instanceHome();
try {
await writeInstance(makeInstance("only"), home);
await clearInstance("only", home);
const manifest = await refreshDiscovery(home);
assert.deepEqual(manifest.instances, []);
assert.deepEqual(manifest.projects, []);
assert.equal(existsSync(endpointPath(home)), false);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
+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();
}
});
+283
View File
@@ -0,0 +1,283 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { startLocalServer } from "../out/agent/localServer.js";
import { parseLoadedXml } from "../out/agent/forwardRefs.js";
const PROJECT_A = "D:/Mods/Example";
const PROJECT_B = "D:/Mods/Other";
function makeStats() {
return {
projectDir: "P",
sdkDir: "",
phase: "art",
complete: true,
indexedFiles: 1,
parsedFiles: 1,
shallowScannedFiles: 0,
deferredArtFiles: 0,
shallowCacheHits: 0,
recordsCacheHits: 0,
resolveCacheHits: 0,
resolveCalls: 0,
snapshotHits: 0,
snapshotFallbacks: 0,
candidatesMs: 0,
walkMs: 0,
artScanMs: 0,
assetCount: 1,
referenceCount: 1,
defineCount: 1,
manifestFiles: 0,
manifestAssetCount: 0,
streams: 1,
sourceCandidates: 1,
elapsedMs: 1,
};
}
const CANNON_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 FILES = {
"D:/Mods/Example/Data/AthenaCannon.xml": CANNON_XML,
"D:/Mods/Example/Data/BaseCannon.xml": BASE_XML,
};
function makeIndex(projectDir, unitId, extraDefs = []) {
const file = `${projectDir}/Data/${unitId}.xml`;
const unit = { type: "GameObject", id: unitId, file, line: 2, origin: "project", stream: "static" };
const all = [unit, ...extraDefs];
const assets = new Map();
const assetsById = new Map();
for (const d of all) {
if (!assets.has(d.type)) assets.set(d.type, new Map());
const byId = assets.get(d.type);
if (!byId.has(d.id.toLowerCase())) byId.set(d.id.toLowerCase(), []);
byId.get(d.id.toLowerCase()).push(d);
const key = d.id.toLowerCase();
if (!assetsById.has(key)) assetsById.set(key, []);
assetsById.get(key).push(d);
}
return {
projectDir,
sdkDir: "",
complete: true,
phase: "art",
stale: false,
assets,
assetsById,
defines: new Map([
["exampledefine", [{ name: "ExampleDefine", value: "1", file, line: 2, origin: "project" }]],
]),
files: new Map(),
streams: [
{
name: "static",
entry: `${projectDir}/Data/Mod.xml`,
files: new Set([file.toLowerCase().replace(/\\/g, "/")]),
},
],
manifests: new Map(),
sourceCandidates: [],
diagnostics: [],
references: new Map([
[
`GameObject\u0000${unitId.toLowerCase()}\u0000${file}\u00002`,
[{ file: `${projectDir}/Data/Other.xml`, line: 3, start: 1, end: 2, kind: "attr" }],
],
]),
recordsHashes: new Map(),
stats: makeStats(),
};
}
const PROJECT_A_RELATED = [
{
type: "GameObject",
id: "BaseCannon",
file: "D:/Mods/Example/Data/BaseCannon.xml",
line: 2,
origin: "project",
stream: "static",
},
{
type: "GameObject",
id: "AthenaCannon_Die",
file: "D:/Mods/Example/Data/AthenaCannon_Die.xml",
line: 2,
origin: "project",
stream: "static",
},
{
type: "WeaponTemplate",
id: "AthenaCannonWeapon",
file: "D:/Mods/Example/Data/Weapon.xml",
line: 88,
origin: "project",
stream: "static",
},
];
/** Routes to a distinct index per requested project, like the extension does. */
function routedServerOptions() {
const indexes = new Map([
[PROJECT_A, makeIndex(PROJECT_A, "AthenaCannon", PROJECT_A_RELATED)],
[PROJECT_B, makeIndex(PROJECT_B, "OtherUnit")],
]);
return {
getIndex: (projectDir) => (projectDir ? indexes.get(projectDir) ?? null : indexes.get(PROJECT_A)),
listProjects: () => [...indexes.keys()],
loadFile: async (file) => {
const text = FILES[file];
return text ? parseLoadedXml(text) : null;
},
};
}
async function withServer(fn) {
const handle = await startLocalServer({ ...routedServerOptions(), token: "test-token" });
const base = `http://127.0.0.1:${handle.port}`;
const headers = { authorization: "Bearer test-token" };
const get = async (path) => (await fetch(`${base}${path}`, { headers })).json();
try {
await fn({ base, headers, get });
} finally {
await handle.close();
}
}
test("local server requires the bearer token", async () => {
await withServer(async ({ base }) => {
const unauthorized = await fetch(`${base}/status`);
assert.equal(unauthorized.status, 401);
const forbidden = await fetch(`${base}/status`, {
headers: { authorization: "Bearer wrong" },
});
assert.equal(forbidden.status, 401);
});
});
test("local server exposes read-only queries", async () => {
await withServer(async ({ get }) => {
const status = await get(`/status?project=${encodeURIComponent(PROJECT_A)}`);
assert.equal(status.state, "ready");
assert.equal(status.projectDir, PROJECT_A);
const asset = await get(
`/find_asset?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&type=GameObject`,
);
assert.equal(asset.data.length, 1);
const refs = await get(
`/find_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon`,
);
assert.equal(refs.data.length, 1);
const active = await get(
`/is_file_active?project=${encodeURIComponent(PROJECT_A)}&path=D:/Mods/Example/Data/AthenaCannon.xml`,
);
assert.equal(active.data.active, true);
});
});
test("?project= selects the index instead of the active editor's project", async () => {
await withServer(async ({ get }) => {
const a = await get(`/find_asset?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon`);
assert.equal(a.index.projectDir, PROJECT_A);
assert.equal(a.data.length, 1);
const b = await get(`/find_asset?project=${encodeURIComponent(PROJECT_B)}&id=OtherUnit`);
assert.equal(b.index.projectDir, PROJECT_B);
assert.equal(b.data.length, 1);
// The same id must not resolve when asked about the other project.
const cross = await get(
`/find_asset?project=${encodeURIComponent(PROJECT_B)}&id=AthenaCannon`,
);
assert.equal(cross.data.length, 0);
assert.equal(cross.index.projectDir, PROJECT_B);
});
});
test("an unknown project reports no_index without faking a projectDir", async () => {
await withServer(async ({ get }) => {
const unknown = "D:/Mods/Unknown";
const result = await get(`/status?project=${encodeURIComponent(unknown)}`);
assert.equal(result.state, "no_index");
assert.equal(result.projectDir, unknown);
});
});
test("/projects lists the known roots", async () => {
await withServer(async ({ get }) => {
const result = await get("/projects");
assert.deepEqual(new Set(result.data), new Set([PROJECT_A, PROJECT_B]));
});
});
test("/get_asset_references returns provenance-carrying edges", async () => {
await withServer(async ({ get }) => {
const result = await get(
`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&type=GameObject`,
);
assert.equal(result.index.state, "ready");
const data = result.data;
assert.ok(data, "expected a data payload");
assert.equal(data.edges.length >= 3, true);
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, "D:/Mods/Example/Data/BaseCannon.xml");
const die = data.edges.find((e) => e.via.kind === "content");
assert.ok(die, "expected the CreateObjectDie content edge");
assert.equal(die.to.id, "AthenaCannon_Die");
});
});
test("/get_asset_references honours targetTypes and depth parameters", async () => {
await withServer(async ({ get }) => {
const filtered = await get(
`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&targetTypes=WeaponTemplate`,
);
for (const edge of filtered.data.edges) {
if (edge.via.kind === "inheritFrom") continue;
assert.equal(edge.to.type, "WeaponTemplate");
}
const shallow = await get(
`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&depth=1`,
);
for (const edge of shallow.data.edges) {
assert.equal(edge.depth, 1);
}
const deep = await get(
`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&depth=2`,
);
for (const edge of deep.data.edges) {
assert.ok(edge.depth <= 2);
}
});
});
test("/get_asset_references explains itself when live data is unavailable", async () => {
await withServer(async ({ get }) => {
const missing = await get(`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=Nope`);
assert.equal(missing.data.roots.length, 0);
assert.ok(missing.data.warnings.length > 0);
});
});
+22
View File
@@ -0,0 +1,22 @@
import { test } from "node:test";
import assert from "node:assert/strict";
// 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("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("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 });
}
},
);
+138
View File
@@ -0,0 +1,138 @@
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 {
addMcpServerToConfigFile,
installMcpServerConfigToFile,
launcherPath,
mcpConfigJson,
mcpServerConfig,
readMcpInstallRecord,
removeLauncher,
removeMcpServerFromConfigFile,
uninstallMcpServerConfigs,
} from "../out/agent/setup.js";
test("mcpServerConfig and mcpConfigJson use the stable launcher", () => {
const config = mcpServerConfig("C:/Users/me/.ra3modxml/ra3-mod-xml-mcp.cmd", "D:/Mods/Example");
assert.deepEqual(config.mcpServers["ra3-mod-xml"].args, ["--project", "D:/Mods/Example"]);
const json = mcpConfigJson("C:/launcher.cmd", "D:/Proj");
assert.ok(json.includes("C:/launcher.cmd"));
assert.ok(json.includes("D:/Proj"));
});
test("addMcpServerToConfigFile creates and merges config", async () => {
const dir = mkdtempSync(join(tmpdir(), "ra3-setup-test-"));
try {
const file = join(dir, "mcp.json");
await addMcpServerToConfigFile(file, "C:/launcher.cmd", "D:/Proj");
const first = JSON.parse(readFileSync(file, "utf8"));
assert.ok(first.mcpServers["ra3-mod-xml"]);
writeFileSync(file, JSON.stringify({ mcpServers: { other: { command: "x" } } }, null, 2));
await addMcpServerToConfigFile(file, "C:/launcher.cmd", "D:/Proj");
const merged = JSON.parse(readFileSync(file, "utf8"));
assert.ok(merged.mcpServers.other);
assert.ok(merged.mcpServers["ra3-mod-xml"]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("removeMcpServerFromConfigFile removes only our entry and preserves the rest", async () => {
const dir = mkdtempSync(join(tmpdir(), "ra3-setup-remove-"));
try {
const file = join(dir, "mcp.json");
writeFileSync(
file,
JSON.stringify(
{
mcpServers: {
other: { command: "x" },
"ra3-mod-xml": { command: "launcher", args: ["--project", "D:/Mods/A"] },
},
someOtherKey: 1,
},
null,
2,
),
);
assert.equal(await removeMcpServerFromConfigFile(file), true);
const parsed = JSON.parse(readFileSync(file, "utf8"));
assert.ok(parsed.mcpServers.other);
assert.equal(parsed.mcpServers["ra3-mod-xml"], undefined);
assert.equal(parsed.someOtherKey, 1);
// Nothing left to remove: second call is a no-op.
assert.equal(await removeMcpServerFromConfigFile(file), false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("removeMcpServerFromConfigFile handles VS Code's servers key and drops empty containers", async () => {
const dir = mkdtempSync(join(tmpdir(), "ra3-setup-vscode-"));
try {
const file = join(dir, "mcp.json");
writeFileSync(
file,
JSON.stringify({ servers: { "ra3-mod-xml": { command: "x" } }, inputs: [] }, null, 2),
);
assert.equal(await removeMcpServerFromConfigFile(file), true);
const parsed = JSON.parse(readFileSync(file, "utf8"));
assert.equal(parsed.servers, undefined);
assert.deepEqual(parsed.inputs, []);
// A missing file is "nothing to remove", not an error.
assert.equal(await removeMcpServerFromConfigFile(join(dir, "nope.json")), false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("installed MCP configs can be uninstalled through the install record", async () => {
const home = mkdtempSync(join(tmpdir(), "ra3-setup-home-"));
const dir = mkdtempSync(join(tmpdir(), "ra3-setup-record-"));
try {
const file = join(dir, "mcp.json");
await installMcpServerConfigToFile({
filePath: file,
launcher: "C:/launcher.cmd",
projectDir: "D:/Mods/A",
label: "Test client",
sourceVersion: "1.2.3",
agentHome: home,
});
const record = await readMcpInstallRecord(home);
assert.equal(record.length, 1);
assert.equal(record[0].label, "Test client");
assert.equal(record[0].serverKey, "ra3-mod-xml");
assert.equal(record[0].sourceVersion, "1.2.3");
const removed = await uninstallMcpServerConfigs({ agentHome: home });
assert.deepEqual(removed, [file]);
assert.equal((await readMcpInstallRecord(home)).length, 0);
// Our entry was the only server: the empty container is dropped.
assert.deepEqual(JSON.parse(readFileSync(file, "utf8")), {});
} finally {
rmSync(home, { recursive: true, force: true });
rmSync(dir, { recursive: true, force: true });
}
});
test("removeLauncher deletes the stable launcher and tolerates its absence", async () => {
const home = mkdtempSync(join(tmpdir(), "ra3-setup-launcher-"));
try {
writeFileSync(launcherPath(home), "dummy", "utf8");
assert.equal(existsSync(launcherPath(home)), true);
assert.equal(await removeLauncher(home), true);
assert.equal(existsSync(launcherPath(home)), false);
// force: true, so removing it again is still a success.
assert.equal(await removeLauncher(home), true);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
+229
View File
@@ -0,0 +1,229 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
SKILL_MARKER_FILE,
forgetSkillInstallRecords,
installSkillToDirectories,
installedSkillStatus,
readSkillInstallRecord,
readSkillMarker,
uninstallRecordedSkills,
uninstallSkillFromDirectory,
writeSkillInstallRecord,
writeSkillTo,
} from "../out/agent/skill.js";
test("writeSkillTo creates SKILL.md and avoids project-doc noise", async () => {
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-test-"));
try {
const skillDir = join(dir, "ra3-mod-xml");
await writeSkillTo(skillDir, "0.1.25");
assert.ok(existsSync(join(skillDir, "SKILL.md")));
assert.ok(existsSync(join(skillDir, "references", "query-guide.md")));
assert.ok(existsSync(join(skillDir, SKILL_MARKER_FILE)));
const content = readFileSync(join(skillDir, "SKILL.md"), "utf8");
assert.ok(content.includes("find_asset"));
assert.ok(!content.includes("codebase-navigation-guide"));
// The bundled reference must list every tool the MCP server exposes.
const guide = readFileSync(
join(skillDir, "references", "query-guide.md"),
"utf8",
);
for (const tool of [
"get_status",
"find_asset",
"find_references",
"get_asset_references",
"list_assets_by_type",
"is_file_active",
"find_define",
"resolve_include",
"list_projects",
"get_usage_guide",
]) {
assert.ok(guide.includes(tool), `query-guide.md must mention ${tool}`);
}
assert.equal(guide.includes("CnC3Types.xsd"), false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("SKILL.md scopes itself to SAGE/RA3 projects and warns off others", async () => {
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-scope-"));
try {
const skillDir = join(dir, "ra3-mod-xml");
await writeSkillTo(skillDir, "0.1.25");
const content = readFileSync(join(skillDir, "SKILL.md"), "utf8");
// Must state its applicability and list concrete positive signals.
assert.match(content, /When this skill applies/);
assert.ok(content.includes("Data/Mod.xml"));
assert.ok(content.includes("babproj"));
assert.ok(content.includes("AssetDeclaration"));
// Must give an explicit negative rule and a cheap probe.
assert.match(content, /Do \*\*not\*\* use these tools for unrelated repositories/);
assert.ok(content.includes("get_status"));
assert.ok(content.includes("projectDir"));
// `CnC3Types.xsd` is the shared SAGE base schema (the RA3 Mod SDK ships it
// too), so it must not be presented as evidence in either direction.
assert.equal(content.includes("CnC3Types.xsd"), false);
assert.equal(content.includes("Tiberium Wars"), false);
// Second-phase capability must be documented.
assert.ok(content.includes("get_asset_references"));
assert.ok(content.includes("definedIn"));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
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 CLI is a Node script, and the skill must say how to run it when Node
// is not installed (VS Code's Electron binary as Node).
assert.ok(content.includes("ELECTRON_RUN_AS_NODE=1"));
// 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/);
// The bundled tool reference must be linked from SKILL.md, otherwise
// skills-compatible clients never load it (resources load on demand).
assert.ok(content.includes("./references/query-guide.md"));
// The tool list must stay a real list; a merged bullet hides an item.
assert.match(content, /indexed stream\.\n\s+- `find_define\(name\)`/);
// 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-"));
try {
const target = join(dir, "ra3-mod-xml");
const succeeded = await installSkillToDirectories([target], "0.1.25", home);
assert.deepEqual(succeeded, [target]);
const record = await readSkillInstallRecord(home);
assert.equal(record.length, 1);
assert.equal(record[0].path, target);
await uninstallSkillFromDirectory(target, home);
assert.equal(existsSync(target), false);
assert.equal((await readSkillInstallRecord(home)).length, 0);
} finally {
rmSync(home, { recursive: true, force: true });
rmSync(dir, { recursive: true, force: true });
}
});
test("readSkillMarker requires our marker to describe the same directory", async () => {
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-marker-"));
try {
const managed = join(dir, "managed");
await writeSkillTo(managed, "1.0.0");
const marker = await readSkillMarker(managed);
assert.equal(marker?.sourceVersion, "1.0.0");
// A hand-copied skill without a marker is never treated as ours.
const foreign = join(dir, "foreign");
mkdirSync(foreign, { recursive: true });
writeFileSync(join(foreign, "SKILL.md"), "user content", "utf8");
assert.equal(await readSkillMarker(foreign), null);
// A marker pointing at another directory must not make this one managed.
const spoofed = join(dir, "spoofed");
mkdirSync(spoofed, { recursive: true });
writeFileSync(
join(spoofed, SKILL_MARKER_FILE),
JSON.stringify({ path: managed, sourceVersion: "1.0.0" }),
"utf8",
);
assert.equal(await readSkillMarker(spoofed), null);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("uninstallRecordedSkills removes managed copies and skips user-owned ones", async () => {
const home = mkdtempSync(join(tmpdir(), "ra3-skill-uninstall-home-"));
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-uninstall-"));
try {
const managed = join(dir, "managed");
await installSkillToDirectories([managed], "1.0.0", home);
// A directory the user owns: recorded, but with no marker of ours.
const foreign = join(dir, "foreign");
mkdirSync(foreign, { recursive: true });
writeFileSync(join(foreign, "SKILL.md"), "user content", "utf8");
const record = await readSkillInstallRecord(home);
record.push({ path: foreign, sourceVersion: "0.0.0" });
await writeSkillInstallRecord(record, home);
const status = await installedSkillStatus(home);
assert.equal(status.length, 2);
assert.deepEqual(
status.map((s) => ({ path: s.path, managed: s.managed })),
[
{ path: managed, managed: true },
{ path: foreign, managed: false },
],
);
const { removed, skipped } = await uninstallRecordedSkills(
[managed, foreign],
home,
);
assert.deepEqual(removed, [managed]);
assert.deepEqual(skipped, [foreign]);
assert.equal(existsSync(managed), false);
assert.equal(readFileSync(join(foreign, "SKILL.md"), "utf8"), "user content");
// The unmanaged entry stays in the record so it can still be reviewed.
const after = await readSkillInstallRecord(home);
assert.deepEqual(after.map((r) => r.path), [foreign]);
} finally {
rmSync(home, { recursive: true, force: true });
rmSync(dir, { recursive: true, force: true });
}
});
test("installedSkillStatus and forgetSkillInstallRecords handle a missing directory", async () => {
const home = mkdtempSync(join(tmpdir(), "ra3-skill-missing-home-"));
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-missing-"));
try {
const gone = join(dir, "gone");
await writeSkillInstallRecord([{ path: gone, sourceVersion: "1.0.0" }], home);
const status = await installedSkillStatus(home);
assert.equal(status.length, 1);
assert.equal(status[0].exists, false);
assert.equal(status[0].managed, false);
await forgetSkillInstallRecords([gone], home);
assert.equal((await readSkillInstallRecord(home)).length, 0);
} finally {
rmSync(home, { recursive: true, force: true });
rmSync(dir, { recursive: true, force: true });
}
});
+184
View File
@@ -0,0 +1,184 @@
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 { snapshotFromIndex, writeSnapshotFile, readSnapshotFile } from "../out/agent/snapshot.js";
import {
findAssets,
findReferenceGroups,
isFileActive,
listAssetsByType,
resolveIncludeSource,
statusFromSnapshot,
} from "../out/agent/query.js";
function makeStats() {
return {
projectDir: "P",
sdkDir: "",
phase: "art",
complete: true,
indexedFiles: 3,
parsedFiles: 2,
shallowScannedFiles: 1,
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: 1,
elapsedMs: 1,
};
}
function makeIndex() {
const file = "D:/Mods/Example/Data/Units/Example.xml";
const assets = new Map([
[
"GameObject",
new Map([
[
"exampleunit",
[
{
type: "GameObject",
id: "ExampleUnit",
file,
line: 5,
origin: "project",
stream: "static",
},
],
],
]),
],
]);
const references = new Map([
[
"GameObject\u0000exampleunit\u0000D:/Mods/Example/Data/Units/Example.xml\u00005",
[
{
file: "D:/Mods/Example/Data/Other.xml",
line: 3,
start: 10,
end: 21,
kind: "attr",
},
],
],
]);
return {
projectDir: "D:/Mods/Example",
sdkDir: "",
complete: true,
phase: "art",
stale: false,
assets,
assetsById: new Map([
[
"exampleunit",
[
{
type: "GameObject",
id: "ExampleUnit",
file,
line: 5,
origin: "project",
stream: "static",
},
],
],
]),
defines: new Map([
[
"exampledefine",
[
{
name: "ExampleDefine",
value: "1",
file,
line: 2,
origin: "project",
},
],
],
]),
files: new Map(),
streams: [
{
name: "static",
entry: "D:/Mods/Example/Data/Mod.xml",
files: new Set(["d:/mods/example/data/units/example.xml"]),
},
],
manifests: new Map(),
sourceCandidates: [
{
source: "DATA:Units/Example.xml",
path: "D:/Mods/Example/Data/Units/Example.xml",
prefix: "DATA",
baseDir: "D:/Mods/Example/Data",
},
],
diagnostics: [],
references,
recordsHashes: new Map(),
stats: makeStats(),
};
}
test("snapshotFromIndex flattens assets, defines, streams and references", () => {
const snapshot = snapshotFromIndex(makeIndex(), 42);
assert.equal(snapshot.schemaVersion, 1);
assert.equal(snapshot.assets.length, 1);
assert.equal(snapshot.assets[0].id, "ExampleUnit");
assert.equal(snapshot.defines.length, 1);
assert.equal(snapshot.streams.length, 1);
assert.deepEqual(snapshot.streams[0].files, [
"d:/mods/example/data/units/example.xml",
]);
assert.equal(snapshot.references.length, 1);
assert.equal(snapshot.references[0].sites.length, 1);
assert.equal(snapshot.buildId, 42);
});
test("query helpers operate on snapshots", () => {
const snapshot = snapshotFromIndex(makeIndex(), 1);
assert.equal(findAssets(snapshot, "ExampleUnit").length, 1);
assert.equal(findAssets(snapshot, "exampleunit", "GameObject").length, 1);
assert.equal(findAssets(snapshot, "exampleunit", "WeaponTemplate").length, 0);
assert.equal(listAssetsByType(snapshot, "gameobject", "exa").length, 1);
assert.equal(findReferenceGroups(snapshot, "ExampleUnit").length, 1);
assert.equal(isFileActive(snapshot, "D:/Mods/Example/Data/Units/Example.xml"), true);
assert.equal(isFileActive(snapshot, "D:/Mods/Example/Data/Dead.xml"), false);
assert.equal(resolveIncludeSource(snapshot, "data:units/example.xml")?.path, "D:/Mods/Example/Data/Units/Example.xml");
assert.equal(statusFromSnapshot(snapshot).state, "ready");
});
test("snapshot file write/read round-trips", async () => {
const dir = mkdtempSync(join(tmpdir(), "ra3-agent-test-"));
try {
const file = join(dir, "snapshot.json.gz");
const snapshot = snapshotFromIndex(makeIndex(), 7);
await writeSnapshotFile(file, snapshot);
const loaded = await readSnapshotFile(file);
assert.ok(loaded);
assert.equal(loaded.assets.length, snapshot.assets.length);
assert.equal(loaded.references[0].sites[0].file, "D:/Mods/Example/Data/Other.xml");
assert.equal(loaded.buildId, 7);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+318
View File
@@ -0,0 +1,318 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
assetDefsForCandidate,
assetOriginRank,
collectAssetSearchCandidates,
isEmptyAssetSearchQuery,
parseAssetSearchQuery,
searchAssetCandidates,
} from "../out/features/assetSearch.js";
function makeDef(type, id, file, line, extra = {}) {
return { type, id, file, line, origin: "project", ...extra };
}
function put(map, d) {
let byId = map.get(d.type);
if (!byId) {
byId = new Map();
map.set(d.type, byId);
}
const key = d.id.toLowerCase();
const arr = byId.get(key);
if (arr) arr.push(d);
else byId.set(key, [d]);
}
/** Minimal ModIndex surface used by the search core. */
function makeIndex({ assets = [], local = [] } = {}) {
const index = { assets: new Map() };
for (const d of assets) put(index.assets, d);
if (local.length) {
const localAssets = new Map();
for (const d of local) put(localAssets, d);
index.local = {
assets: localAssets,
assetsById: new Map(),
defines: new Map(),
};
}
return index;
}
const PROJECT = "D:/Mods/Corona";
const SDK = "C:/Apps/RA3-MODSDK-X";
test("parseAssetSearchQuery splits Type:Id, bare id and type-only queries", () => {
assert.deepEqual(parseAssetSearchQuery(" Assault "), {
raw: "Assault",
type: null,
id: "assault",
});
assert.deepEqual(parseAssetSearchQuery("WeaponTemplate:AssaultRifle"), {
raw: "WeaponTemplate:AssaultRifle",
type: "weapontemplate",
id: "assaultrifle",
});
assert.deepEqual(parseAssetSearchQuery("Weapon:"), {
raw: "Weapon:",
type: "weapon",
id: "",
});
// A bare leading colon is just an id search.
assert.deepEqual(parseAssetSearchQuery(":Assault"), {
raw: ":Assault",
type: null,
id: "assault",
});
// Manifest-style qualified names: type = first segment, id = last segment.
assert.deepEqual(parseAssetSearchQuery("W3DContainer:W3DContainer:AU"), {
raw: "W3DContainer:W3DContainer:AU",
type: "w3dcontainer",
id: "au",
});
});
test("parseAssetSearchQuery recognises empty queries", () => {
assert.ok(isEmptyAssetSearchQuery(parseAssetSearchQuery("")));
assert.ok(isEmptyAssetSearchQuery(parseAssetSearchQuery(" ")));
assert.ok(isEmptyAssetSearchQuery(parseAssetSearchQuery(":")));
assert.ok(!isEmptyAssetSearchQuery(parseAssetSearchQuery("Weapon:")));
});
test("collectAssetSearchCandidates dedupes type:id and counts definition sites", () => {
const index = makeIndex({
assets: [
makeDef("WeaponTemplate", "AssaultRifle", `${PROJECT}/Data/weapons.xml`, 4),
makeDef("WeaponTemplate", "AssaultRifle", `${PROJECT}/Data/weapons.xml`, 9),
makeDef("WeaponTemplate", "AssaultRifle", `${SDK}/builtmods/static.manifest`, 0, {
origin: "manifest",
manifestSource: "DATA:globaldata/weapon.xml",
}),
makeDef("GameObject", "AssaultRifle", `${PROJECT}/Data/units.xml`, 2),
],
});
const candidates = collectAssetSearchCandidates(index);
assert.equal(candidates.length, 2);
const weapon = candidates.find((c) => c.def.type === "WeaponTemplate");
assert.equal(weapon.definitionCount, 3);
// The mod definition wins over the manifest one.
assert.equal(weapon.def.origin, "project");
assert.equal(weapon.def.line, 4);
const gameObject = candidates.find((c) => c.def.type === "GameObject");
assert.equal(gameObject.definitionCount, 1);
});
test("collectAssetSearchCandidates merges the local overlay (unsaved wins)", () => {
const index = makeIndex({
assets: [makeDef("GameObject", "Tank", `${PROJECT}/Data/units.xml`, 12)],
local: [
makeDef("GameObject", "Tank", `${PROJECT}/Data/units.xml`, 12, {
stream: "local",
}),
makeDef("GameObject", "TankPrototype", `${PROJECT}/Data/units.xml`, 40, {
stream: "local",
}),
],
});
const byId = new Map(
collectAssetSearchCandidates(index).map((c) => [c.def.id, c]),
);
assert.equal(byId.size, 2);
assert.equal(byId.get("Tank").def.stream, "local");
// The same file+line from overlay and global index is one definition.
assert.equal(byId.get("Tank").definitionCount, 1);
assert.equal(byId.get("TankPrototype").definitionCount, 1);
});
test("assetOriginRank orders local, project, SDK and manifest definitions", () => {
assert.equal(assetOriginRank(makeDef("A", "a", "f", 1, { stream: "local" })), 0);
assert.equal(assetOriginRank(makeDef("A", "a", "f", 1)), 1);
assert.equal(assetOriginRank(makeDef("A", "a", "f", 1, { origin: "sdk" })), 2);
assert.equal(
assetOriginRank(makeDef("A", "a", "f", 1, { origin: "manifest" })),
3,
);
});
test("searchAssetCandidates finds exact, prefix and partial ids", () => {
const index = makeIndex({
assets: [
makeDef("GameObject", "CrateDebris_01", `${PROJECT}/Data/a.xml`, 1),
makeDef("GameObject", "CrateDebris_02", `${PROJECT}/Data/a.xml`, 5),
makeDef("GameObject", "MyCrateDebris", `${PROJECT}/Data/a.xml`, 9),
makeDef("GameObject", "Unrelated", `${PROJECT}/Data/a.xml`, 13),
],
});
const candidates = collectAssetSearchCandidates(index);
const exact = searchAssetCandidates(
candidates,
parseAssetSearchQuery("CrateDebris_02"),
);
assert.equal(exact.total, 1);
assert.equal(exact.matches[0].def.id, "CrateDebris_02");
const partial = searchAssetCandidates(
candidates,
parseAssetSearchQuery("cratedebris"),
);
assert.equal(partial.total, 3);
// Prefix matches rank before substring matches.
assert.deepEqual(
partial.matches.map((m) => m.def.id),
["CrateDebris_01", "CrateDebris_02", "MyCrateDebris"],
);
const mid = searchAssetCandidates(
candidates,
parseAssetSearchQuery("Debris"),
);
assert.equal(mid.total, 3);
});
test("searchAssetCandidates is case-insensitive and type-aware for bare queries", () => {
const index = makeIndex({
assets: [
makeDef("GameObject", "Tank", `${PROJECT}/Data/a.xml`, 1),
makeDef("WeaponTemplate", "TankGun", `${PROJECT}/Data/b.xml`, 1),
makeDef("GameObject", "OxTank", `${PROJECT}/Data/a.xml`, 5),
],
});
const candidates = collectAssetSearchCandidates(index);
const upper = searchAssetCandidates(candidates, parseAssetSearchQuery("TANK"));
assert.equal(upper.total, 3);
// A bare query that matches a type name lists that type's assets too.
const byType = searchAssetCandidates(
candidates,
parseAssetSearchQuery("GameObject"),
);
assert.equal(byType.total, 2);
assert.deepEqual(
byType.matches.map((m) => m.def.id).sort(),
["OxTank", "Tank"],
);
});
test("searchAssetCandidates honours Type:Id, Type: and prefixed queries", () => {
const index = makeIndex({
assets: [
makeDef("GameObject", "Tank", `${PROJECT}/Data/a.xml`, 1),
makeDef("GameObject", "TankPrototype", `${PROJECT}/Data/a.xml`, 5),
makeDef("WeaponTemplate", "TankGun", `${PROJECT}/Data/b.xml`, 1),
makeDef("WeaponTemplate", "AssaultRifle", `${PROJECT}/Data/b.xml`, 9),
],
});
const candidates = collectAssetSearchCandidates(index);
const typed = searchAssetCandidates(
candidates,
parseAssetSearchQuery("GameObject:Tank"),
);
assert.deepEqual(
typed.matches.map((m) => m.def.id),
["Tank", "TankPrototype"],
);
// Partial type names work as a filter as well.
const partialType = searchAssetCandidates(
candidates,
parseAssetSearchQuery("weapontemplate:assault"),
);
assert.equal(partialType.total, 1);
assert.equal(partialType.matches[0].def.id, "AssaultRifle");
// `Type:` lists every asset of that type.
const typeOnly = searchAssetCandidates(
candidates,
parseAssetSearchQuery("WeaponTemplate:"),
);
assert.equal(typeOnly.total, 2);
// A mismatching type excludes otherwise matching ids.
const mismatch = searchAssetCandidates(
candidates,
parseAssetSearchQuery("WeaponTemplate:Tank"),
);
assert.equal(mismatch.total, 1); // TankGun only (prefix), not GameObject Tank
assert.equal(mismatch.matches[0].def.id, "TankGun");
});
test("searchAssetCandidates ranks mod definitions before vanilla ones", () => {
const index = makeIndex({
assets: [
makeDef("WeaponTemplate", "AssaultRifle", `${SDK}/builtmods/static.manifest`, 0, {
origin: "manifest",
}),
makeDef("WeaponTemplate", "AssaultRifle", `${PROJECT}/Data/weapon.xml`, 7),
],
});
const candidates = collectAssetSearchCandidates(index);
const result = searchAssetCandidates(
candidates,
parseAssetSearchQuery("AssaultRifle"),
);
assert.equal(result.total, 1);
assert.equal(result.matches[0].def.origin, "project");
assert.equal(result.matches[0].definitionCount, 2);
});
test("searchAssetCandidates returns no rows for an empty query and caps results", () => {
const assets = [];
for (let i = 0; i < 25; i++) {
assets.push(
makeDef("GameObject", `Target_${String(i).padStart(2, "0")}`, "f", i + 1),
);
}
const candidates = collectAssetSearchCandidates(makeIndex({ assets }));
const empty = searchAssetCandidates(candidates, parseAssetSearchQuery(""));
assert.equal(empty.total, 0);
assert.equal(empty.matches.length, 0);
const all = searchAssetCandidates(candidates, parseAssetSearchQuery("Target"));
assert.equal(all.total, 25);
assert.equal(all.matches.length, 25);
const capped = searchAssetCandidates(
candidates,
parseAssetSearchQuery("Target"),
10,
);
assert.equal(capped.total, 25);
assert.equal(capped.matches.length, 10);
});
test("assetDefsForCandidate lists distinct sites, local overlay first", () => {
const index = makeIndex({
assets: [
makeDef("GameObject", "Tank", `${PROJECT}/Data/units.xml`, 3),
makeDef("GameObject", "Tank", `${SDK}/SageXml/units.xml`, 1, {
origin: "sdk",
}),
makeDef("GameObject", "Tank", `${SDK}/SageXml/units.xml`, 1, {
origin: "sdk",
}),
],
local: [
makeDef("GameObject", "Tank", `${PROJECT}/Data/units.xml`, 3, {
stream: "local",
}),
],
});
const candidate = collectAssetSearchCandidates(index).find(
(c) => c.def.id === "Tank",
);
const defs = assetDefsForCandidate(index, candidate);
assert.equal(defs.length, 2);
assert.equal(defs[0].stream, "local");
assert.equal(defs[1].origin, "sdk");
});
+64
View File
@@ -0,0 +1,64 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
AGENT_FEATURE_VERSION,
compareVersions,
shouldOfferAgentOnboarding,
} from "../out/agent/onboarding.js";
test("compareVersions orders dotted numeric versions", () => {
assert.equal(compareVersions("0.1.26", "0.1.26"), 0);
assert.equal(compareVersions("0.1.26", "0.1.25"), 1);
assert.equal(compareVersions("0.1.25", "0.1.26"), -1);
assert.equal(compareVersions("0.1.26", "0.1"), 1);
assert.equal(compareVersions("0.2", "0.1.99"), 1);
assert.equal(compareVersions("1.0.0", "2.0.0"), -1);
assert.equal(compareVersions("0.1.26-beta", "0.1.26"), 0);
assert.equal(compareVersions("dev", "0.1.25"), -1);
});
test("a fresh install is offered the AI Agent introduction once", () => {
assert.equal(shouldOfferAgentOnboarding(undefined, "0.1.26"), true);
assert.equal(shouldOfferAgentOnboarding({}, "0.1.26"), true);
});
test("upgrading from before the feature informs once", () => {
assert.equal(
shouldOfferAgentOnboarding({ informedVersion: "0.1.25" }, "0.1.26"),
true,
);
// ...but not again on the same version or later ones.
assert.equal(
shouldOfferAgentOnboarding({ informedVersion: "0.1.26" }, "0.1.26"),
false,
);
assert.equal(
shouldOfferAgentOnboarding({ informedVersion: "0.1.26" }, "0.1.27"),
false,
);
});
test("upgrading from a build that already had the feature stays silent", () => {
// 0.1.26 introduced it; someone informed on 0.1.26 must not be re-prompted.
assert.equal(
shouldOfferAgentOnboarding({ informedVersion: "0.1.26" }, "0.1.30"),
false,
);
});
test("dismissed state suppresses the prompt forever", () => {
assert.equal(
shouldOfferAgentOnboarding(
{ informedVersion: "0.1.20", dismissed: true },
"0.1.26",
),
false,
);
assert.equal(
shouldOfferAgentOnboarding(
{ informedVersion: AGENT_FEATURE_VERSION, dismissed: true },
"0.1.30",
),
false,
);
});
+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);
})();