This commit is contained in:
2026-09-10 21:39:32 +02:00
parent 5993da4ce6
commit a25adacaee
19 changed files with 1407 additions and 79 deletions
+37
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
+29
View File
@@ -156,7 +156,10 @@ to an empty string opts out of SDK features permanently.
* `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
@@ -213,6 +216,32 @@ 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
+14
View File
@@ -153,7 +153,10 @@
* `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 访问
@@ -184,6 +187,17 @@ Endpoint 按项目存放(`~/.ra3modxml/endpoints/<project>.json`),因此
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`
## 环境要求
+63
View File
@@ -1120,3 +1120,66 @@ test/*.test.mjs 37 个文件 / 302 个用例,299 通过,3 跳
- **发现降级**:删掉 `endpoints/` 后仍能通过 `instances/` 找到实例。
- **崩溃剪枝**:插入一个 PID 必死的实例,剪枝后另一个实例仍正常工作;
清理本窗口实例不影响其他窗口。
---
## 20. P0 修复(2026-09-10
三期测试暴露 / 评审发现的问题,以下四项已修复:
| # | 问题 | 修复 |
|---|---|---|
| 1 | `test/agentSkill.test.mjs``SKILL.md` 措辞不一致(`new session` / `Never fabricate index results` 在源码中丢失),干净重建后测试失败 | 恢复两条护栏;测试同步断言 |
| 2 | Skill 里的 `CnC3Types.xsd` 说法错误:它是 RA3 Mod SDK 也携带的 SAGE 基础 schema`sdk.ts` 的 SDK 根标记就是它),原句会让 agent 误判真实 RA3 项目 | 删除该句,改为通用否定规则(SAGE-looking XML / 复制的 `.xsd` / `Data` 目录本身都不构成证据) |
| 3 | 关闭一个 VS Code 窗口会无条件 `clearEndpoint()` + `writeManifest([])`,抹掉其他仍在服务窗口的 `endpoint.json` / `index.json` | 新增 `refreshDiscovery()`:从幸存 instance 文件重新推导两者;`stopAgentLocalServer` 改用它;新增回归测试 |
| 4 | `npm run build` 不清理 `dist/`0.1.26 VSIX 同时带着旧布局的 `dist/mcpServer.js` / `dist/cli.js` | `esbuild.mjs` 构建前 `rm -rf dist` |
同时:
- `SKILL.md` 修正被合并的 `find_define` 列表项与 `the your own harness'` 笔误;
`SKILL.md` 链接 `references/query-guide.md`(skills 客户端只按需加载被引用的
资源,之前该文件永远不会被读取);CLI 段落补充“它是 Node 脚本、没有 Node 时用
launcher 里的 Electron 运行时”的说明。
- `CHANGELOG.md` 补上 0.1.26 条目。
- 全量测试:310 通过 / 0 失败 / 3 跳过(跳过项仍是沙箱禁止 spawn 的三条)。
## 21. P1 第一批:安装 / 卸载 / 禁用(2026-09-10
针对“安装与卸载要简单”“升级后要能自动同步”两项:
| 内容 | 实现 |
|---|---|
| 安装不再依赖索引 | `ra3modxml.installAgentSkill` 改为只读取 `ws.projectRoot`(可为空),无项目时仍可装到用户级目录 |
| 安装位置更灵活 | 默认 `~/.agents/skills`、Claude Code、项目级 `.agents/skills` / `.claude/skills`、以及自定义文件夹(`<folder>/ra3-mod-xml` |
| 安全卸载 | 新增 `readSkillMarker` / `installedSkillStatus` / `uninstallRecordedSkills` / `forgetSkillInstallRecords`:只删除带 marker 且路径匹配的目录,用户替换过的目录跳过并报告 |
| 禁用实时访问 | 新增 `ra3modxml.disableAgentAccess`:停服务器、清本窗口 instance/endpoint、从幸存实例重建发现文件,保留 skill 与 MCP 配置 |
| 完整卸载向导 | 新增 `ra3modxml.uninstallAgentIntegration`:可勾选停止实时访问 / 移除 Skill / 移除 MCP 配置 / 删除 launcher,确认后才执行 |
| MCP 配置可回滚 | 新增 `mcp-install.json` 记录(`installMcpServerConfigToFile`)、`removeMcpServerFromConfigFile`(同时兼容 `mcpServers` 与 VS Code 的 `servers`,空容器自动删除)、`uninstallMcpServerConfigs`(记录 + 常规客户端路径)、`removeLauncher` |
| 升级自动同步 | 激活时对比 `skill-install.json` 中的版本,有差异才调用 `syncInstalledSkills` 重写;无 marker 的目录不改写 |
| 本地化 | 新增 55 条 AI Agent 相关字符串到 `l10n/bundle.l10n.json``zh-cn`(共 210 条/语言),命令标题加入 `package.nls.*` |
测试:`agentSetup.test.mjs` 6 项、`agentSkill.test.mjs` 7 项(含 marker 防误删、
MCP 配置保留其他 server、空容器清理、记录回滚)。
## 22. P1 第二批:首装 / 升级提示(2026-09-10
- 新增纯逻辑模块 `src/agent/onboarding.ts`(可单测):
- `AGENT_FEATURE_VERSION = "0.1.26"`
- `shouldOfferAgentOnboarding(state, currentVersion)`
首次安装提示一次;从早于 0.1.26 的版本升级提示一次;
已在带此功能的版本提示过则保持静默;`dismissed` 永久抑制;
- `compareVersions` 处理 `"0.1" < "0.1.26"``"0.1.26-beta" == "0.1.26"`
- `extension.ts` 在**第一次索引完成后**`ws.onIndexUpdate`)弹一次信息提示,
按钮为 `Enable AI Agent access…` / `Learn more` / `Don't show again`
无论用户选择还是忽略,都会把 `informedVersion` 写入 `globalState`,不会重复打扰。
- 新增 4 条本地化字符串(两种语言各 214 条)。
- `test/onboarding.test.mjs`5 个用例覆盖上述规则。
### 仍未处理(下一批)
- VS Code 原生 `chatSkills` / `mcpServerDefinitionProviders` 接入(免 launcher /
免手写配置)。
- Claude/Cursor 路径收敛为数据驱动预设 + `configure --harness`
稳定 CLI launcher(免 Node 路径发现)。
- `mcpServer.ts``serverInfo.version` 仍硬编码 `0.1.0`
`startAgentLocalServer` 仍有并发启动竞态。
+7
View File
@@ -1,7 +1,14 @@
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",
+60 -1
View File
@@ -153,5 +153,64 @@
"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"
}
+60 -1
View File
@@ -153,5 +153,64 @@
"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 XMLAI 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 AgentMCP + Agent Skill)。要启用吗?",
"Enable AI Agent access": "启用 AI Agent 访问",
"Learn more": "了解更多",
"Don't show again": "不再提示"
}
+12
View File
@@ -130,10 +130,22 @@
"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%"
+3
View File
@@ -14,7 +14,10 @@
"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.findUnreferencedAssets.title": "RA3 Mod XML: Find unreferenced assets…",
"ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: Find unreferenced assets of this type"
+3
View File
@@ -14,7 +14,10 @@
"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.findUnreferencedAssets.title": "RA3 Mod XML: 查找未引用的资产…",
"ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: 查找该类型的未引用资产"
+47 -1
View File
@@ -21,7 +21,7 @@
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { isProcessAlive, type AgentEndpoint } from "./endpoint";
import { clearEndpoint, isProcessAlive, writeEndpoint, type AgentEndpoint } from "./endpoint";
import { defaultAgentHome, snapshotBaseName } from "./snapshot";
/** One live extension-host instance. */
@@ -216,6 +216,52 @@ export async function readManifest(
}
}
/**
* 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);
+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;
}
+182 -4
View File
@@ -9,9 +9,9 @@
* Pure TypeScript: no VS Code dependency.
*/
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { dirname, join, resolve } from "node:path";
import { defaultAgentHome } from "./snapshot";
import {
launcherScript,
@@ -19,6 +19,9 @@ import {
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;
@@ -152,12 +155,13 @@ export function commonMcpConfigTargets(projectDir: string): McpConfigTarget[] {
/**
* 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.
* 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 {
@@ -166,7 +170,7 @@ export async function addMcpServerToConfigFile(
// File absent or malformed: start fresh.
}
const servers = (config.mcpServers as Record<string, unknown> | undefined) ?? {};
servers["ra3-mod-xml"] = {
servers[serverKey] = {
command: launcher,
args: ["--project", projectDir],
};
@@ -175,6 +179,180 @@ export async function addMcpServerToConfigFile(
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");
+147 -23
View File
@@ -9,9 +9,9 @@
* Pure TypeScript: no VS Code dependency.
*/
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { dirname, join, resolve } from "node:path";
import { defaultAgentHome } from "./snapshot";
export const SKILL_NAME = "ra3-mod-xml";
@@ -40,19 +40,18 @@ Positive signals (any single one is enough to try the tools):
- XML that uses \`<Includes><Include source="DATA:…" /></Includes>\` or declares
\`xmlns="uri:ea.com:eala:asset"\`.
Do **not** use these tools for unrelated repositories. In particular, do not
use them merely because a project contains XML, a build script, copied \`.xsd\`
files, or a folder named \`Data\`. A file named \`CnC3Types.xsd\` refers to
C&C3 (Tiberium Wars / Kane's Wrath) and is **not** by itself evidence of a
Red Alert 3 mod. When the repository is not a SAGE / RA3 mod project, ignore
this skill entirely.
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 root you are working in.
- 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.
@@ -86,8 +85,9 @@ Work down this list and stop at the first step that works.
stdio. Read \`~/.ra3modxml/index.json\` first: it is a small, stable
discovery manifest listing the live instances and their project roots.
- If it does not exist, the extension has never been enabled for this
project. Tell the user to open the project in VS Code and run
- 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
@@ -100,18 +100,24 @@ Work down this list and stop at the first step that works.
A CLI with the same capabilities is bundled alongside the MCP server
(\`cli.js\` next to \`mcpServer.js\` in the extension's \`dist/agent/\`).
Run it with the same runtime as the launcher; \`cli.js help\` lists the
commands. It answers from the live index when VS Code is running and
falls back to the last exported snapshot otherwise. Commands that need
element context (\`outgoing\`, \`projects\`) require the live index and will
say so explicitly instead of returning an empty result.
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 the your own harness' configuration.
Check what your own client supports (e.g. if a restart is required)
before promising otherwise.
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.
If none of the steps work, say the index is unavailable and read the XML files directly.
Never fabricate index results. If none of the steps work, say the index is
unavailable and read the XML files directly.
## How to use
@@ -123,7 +129,8 @@ If none of the steps work, say the index is unavailable and read the XML files d
- \`get_asset_references(id, type?, depth?, targetTypes?)\` for outgoing
references (what this asset uses, and where that link is written).
- \`list_assets_by_type(type, prefix?, limit?)\` for browsing assets.
- \`is_file_active(path)\` to determine whether a file is included in an indexed stream. - \`find_define(name)\` for $DEFINE constants.
- \`is_file_active(path)\` to determine whether a file is included in an indexed stream.
- \`find_define(name)\` for $DEFINE constants.
- \`resolve_include(source)\` for Include source candidates.
3. Asset ids are case-insensitive.
4. If an id exists for multiple asset types, pass the type filter to avoid mixing definitions.
@@ -131,6 +138,9 @@ If none of the steps work, say the index is unavailable and read the XML files d
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
@@ -174,8 +184,13 @@ The MCP server exposes these tools:
- 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:
@@ -352,7 +367,105 @@ export async function uninstallSkillFromDirectory(
await writeSkillInstallRecord(installed, agentHome);
}
/** Re-writes every recorded Skill copy with the current extension version. */
/**
* 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(),
@@ -360,11 +473,22 @@ export async function syncInstalledSkills(
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 {
// Skip unreadable/missing targets; the record will be cleaned below.
synced.push(record); // Keep so a later run can retry.
}
}
await writeSkillInstallRecord(synced, agentHome);
+334 -44
View File
@@ -30,26 +30,34 @@ import {
writeSnapshotFile,
} from "./agent/snapshot";
import {
addMcpServerToConfigFile,
claudeDesktopConfigPath,
cursorGlobalConfigPath,
cursorProjectConfigPath,
installMcpServerConfigToFile,
launcherPath,
mcpConfigJson,
removeLauncher,
uninstallMcpServerConfigs,
writeLauncher,
} from "./agent/setup";
import {
SKILL_NAME,
agentsSkillsDirForUser,
claudeSkillsDirForUser,
forgetSkillInstallRecords,
installSkillToDirectories,
installedSkillStatus,
readSkillInstallRecord,
writeSkillInstallRecord,
writeSkillTo,
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 {
clearEndpoint,
clearEndpointForProject,
writeEndpoint,
writeEndpointForProject,
@@ -58,6 +66,7 @@ import {
clearInstance,
makeInstanceId,
pruneInstances,
refreshDiscovery,
writeInstance,
writeManifest,
} from "./agent/instances";
@@ -136,6 +145,10 @@ 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,
@@ -144,6 +157,52 @@ export function activate(context: vscode.ExtensionContext): void {
"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 => {
@@ -270,8 +329,11 @@ export function activate(context: vscode.ExtensionContext): void {
}
agentEndpointProjects.clear();
await clearInstance(agentInstanceId).catch(() => undefined);
await clearEndpoint().catch(() => undefined);
await writeManifest([]).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
@@ -335,6 +397,7 @@ export function activate(context: vscode.ExtensionContext): void {
}
scheduleAgentSnapshot();
void refreshAgentEndpoints();
void maybeOfferAgentOnboarding();
for (const doc of vscode.workspace.textDocuments) {
if (doc.languageId === "xml") void diagnostics.update(doc);
}
@@ -600,25 +663,46 @@ export function activate(context: vscode.ExtensionContext): void {
);
if (pick?.id === "skill") {
const target = agentsSkillsDirForUser();
await writeSkillTo(target, version);
const installed = await readSkillInstallRecord();
if (!installed.some((r) => r.path === target)) installed.push({ path: target, sourceVersion: version });
await writeSkillInstallRecord(installed);
void vscode.window.showInformationMessage(
t("RA3 Mod XML Agent Skill installed to {0}", target),
);
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 addMcpServerToConfigFile(claudeDesktopConfigPath(), launcher.path, projectDir);
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 addMcpServerToConfigFile(cursorGlobalConfigPath(), launcher.path, projectDir);
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 addMcpServerToConfigFile(cursorProjectConfigPath(projectDir), launcher.path, projectDir);
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)),
);
@@ -640,51 +724,238 @@ export function activate(context: vscode.ExtensionContext): void {
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.installAgentSkill", async () => {
const idx = ws.activeIndex();
if (!idx) {
void vscode.window.showInformationMessage(
t(
"RA3 Mod XML: no index available yet. Open and index a project before installing the Agent Skill.",
),
);
return;
}
const projectDir = idx.projectDir;
// 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 = [
const choices: Array<{
label: string;
description: string;
target: string | null;
picked?: boolean;
}> = [
{
label: t("Default (~/.agents/skills)"),
description: agentsSkillsDirForUser(),
path: agentsSkillsDirForUser(),
target: agentsSkillsDirForUser(),
picked: true,
},
{
label: t("Claude Code (~/.claude/skills)"),
description: claudeSkillsDirForUser(),
path: claudeSkillsDirForUser(),
},
{
label: t("Current project .agents/skills"),
description: join(projectDir, ".agents", "skills", SKILL_NAME),
path: join(projectDir, ".agents", "skills", SKILL_NAME),
},
{
label: t("Current project .claude/skills"),
description: join(projectDir, ".claude", "skills", SKILL_NAME),
path: join(projectDir, ".claude", "skills", SKILL_NAME),
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 succeeded = await installSkillToDirectories(
picked.map((p) => p.path),
version,
);
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(
t("RA3 Mod XML Agent Skill installed to {0} location(s).", succeeded.length),
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("; ")),
);
}),
);
@@ -738,6 +1009,25 @@ export function activate(context: vscode.ExtensionContext): void {
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);
+51
View File
@@ -14,9 +14,11 @@ import {
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-"));
@@ -166,3 +168,52 @@ test("makeInstanceId is unique across rapid calls", () => {
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 });
}
});
+103 -1
View File
@@ -1,12 +1,18 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
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", () => {
@@ -34,3 +40,99 @@ test("addMcpServerToConfigFile creates and merges config", async () => {
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 });
}
});
+130 -4
View File
@@ -1,13 +1,18 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
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";
@@ -22,6 +27,27 @@ test("writeSkillTo creates SKILL.md and avoids project-doc noise", async () => {
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 });
}
@@ -44,9 +70,10 @@ test("SKILL.md scopes itself to SAGE/RA3 projects and warns off others", async (
assert.match(content, /Do \*\*not\*\* use these tools for unrelated repositories/);
assert.ok(content.includes("get_status"));
assert.ok(content.includes("projectDir"));
// The CnC3 red herring is called out explicitly.
assert.ok(content.includes("CnC3Types.xsd"));
assert.ok(content.includes("C&C3"));
// `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"));
@@ -69,12 +96,20 @@ test("SKILL.md explains how to reach the index without MCP", async () => {
// 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 {
@@ -101,3 +136,94 @@ test("installSkillToDirectories records managed copies and uninstall removes the
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 });
}
});
+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,
);
});