diff --git a/CHANGELOG.md b/CHANGELOG.md index eea668f..0b45c03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index e938cec..c9f9b91 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 5630c3a..3e79505 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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/.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`。 ## 环境要求 diff --git a/docs/ai-agent-integration-plan.md b/docs/ai-agent-integration-plan.md index 75a7c58..2dc2593 100644 --- a/docs/ai-agent-integration-plan.md +++ b/docs/ai-agent-integration-plan.md @@ -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`、以及自定义文件夹(`/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` 仍有并发启动竞态。 diff --git a/esbuild.mjs b/esbuild.mjs index 7f31adc..5dbdc6f 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -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", diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index ab558a0..2907f7d 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -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 /{0}": "The skill is installed as /{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" } diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json index 9d02b42..1c52e59 100644 --- a/l10n/bundle.l10n.zh-cn.json +++ b/l10n/bundle.l10n.zh-cn.json @@ -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 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 /{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": "不再提示" } diff --git a/package.json b/package.json index bd6f602..c3b2c1b 100644 --- a/package.json +++ b/package.json @@ -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%" diff --git a/package.nls.json b/package.nls.json index 8beaedd..ae6bb51 100644 --- a/package.nls.json +++ b/package.nls.json @@ -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" diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index 32ca193..f92f8ff 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -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: 查找该类型的未引用资产" diff --git a/src/agent/instances.ts b/src/agent/instances.ts index 976f7e2..6c19171 100644 --- a/src/agent/instances.ts +++ b/src/agent/instances.ts @@ -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 { + 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); diff --git a/src/agent/onboarding.ts b/src/agent/onboarding.ts new file mode 100644 index 0000000..d7f0582 --- /dev/null +++ b/src/agent/onboarding.ts @@ -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; +} diff --git a/src/agent/setup.ts b/src/agent/setup.ts index 47185fe..124332e 100644 --- a/src/agent/setup.ts +++ b/src/agent/setup.ts @@ -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 { let config: Record = {}; try { @@ -166,7 +170,7 @@ export async function addMcpServerToConfigFile( // File absent or malformed: start fresh. } const servers = (config.mcpServers as Record | 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 { + let config: Record; + try { + config = JSON.parse(await readFile(filePath, "utf8")) as Record; + } 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; + 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 { + 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 { + 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 { + 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 { + const serverKey = options.serverKey ?? MCP_SERVER_KEY; + const agentHome = options.agentHome ?? defaultAgentHome(); + const record = await readMcpInstallRecord(agentHome); + const candidates = new Map(); + 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 { + 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"); diff --git a/src/agent/skill.ts b/src/agent/skill.ts index 848c090..5318824 100644 --- a/src/agent/skill.ts +++ b/src/agent/skill.ts @@ -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 \`\` 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 \`. 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 \`. + \`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 { + 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 { + 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 { + 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); diff --git a/src/extension.ts b/src/extension.ts index 5ff2b23..1f1fa4f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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 => { + if (agentOnboardingChecked) return; + if (!ws.activeIndex()) return; + const state = context.globalState.get( + 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 | 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 /{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); diff --git a/test/agentInstances.test.mjs b/test/agentInstances.test.mjs index b47f765..c4aff58 100644 --- a/test/agentInstances.test.mjs +++ b/test/agentInstances.test.mjs @@ -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 }); + } +}); diff --git a/test/agentSetup.test.mjs b/test/agentSetup.test.mjs index 6f5409e..1dc927a 100644 --- a/test/agentSetup.test.mjs +++ b/test/agentSetup.test.mjs @@ -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 }); + } +}); diff --git a/test/agentSkill.test.mjs b/test/agentSkill.test.mjs index c31a85a..a5ce170 100644 --- a/test/agentSkill.test.mjs +++ b/test/agentSkill.test.mjs @@ -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 }); + } +}); diff --git a/test/onboarding.test.mjs b/test/onboarding.test.mjs new file mode 100644 index 0000000..0d90067 --- /dev/null +++ b/test/onboarding.test.mjs @@ -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, + ); +});