finalize
This commit is contained in:
@@ -1,5 +1,42 @@
|
|||||||
# Changelog
|
# 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
|
## 0.1.25 — 2026-08-11
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -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…`
|
||||||
* `RA3 Mod XML: Find unreferenced assets of this type`
|
* `RA3 Mod XML: Find unreferenced assets of this type`
|
||||||
* `RA3 Mod XML: Enable AI Agent access…`
|
* `RA3 Mod XML: Enable AI Agent access…`
|
||||||
|
* `RA3 Mod XML: Disable AI Agent access`
|
||||||
* `RA3 Mod XML: Install Agent Skill…`
|
* `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`
|
* `RA3 Mod XML: Export AI Agent index snapshot`
|
||||||
|
|
||||||
## AI Agent Access
|
## 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
|
otherwise; commands that need element context say so explicitly rather than
|
||||||
returning an empty result.
|
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.
|
See `docs/ai-agent-integration-plan.md` for the full design and progress.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|||||||
@@ -153,7 +153,10 @@
|
|||||||
* `RA3 Mod XML: Find unreferenced assets…`(查找未引用的资产…)
|
* `RA3 Mod XML: Find unreferenced assets…`(查找未引用的资产…)
|
||||||
* `RA3 Mod XML: Find unreferenced assets of this type`(查找此类型的未引用资产)
|
* `RA3 Mod XML: Find unreferenced assets of this type`(查找此类型的未引用资产)
|
||||||
* `RA3 Mod XML: Enable AI Agent access…`(启用 AI Agent 访问…)
|
* `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: 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 索引快照)
|
* `RA3 Mod XML: Export AI Agent index snapshot`(导出 AI Agent 索引快照)
|
||||||
|
|
||||||
## 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 运行时走实时索引,否则读导出的快照;需要元素上下文的命令会明确说明,而不是返回空结果。
|
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`。
|
完整设计与进度见 `docs/ai-agent-integration-plan.md`。
|
||||||
|
|
||||||
## 环境要求
|
## 环境要求
|
||||||
|
|||||||
@@ -1120,3 +1120,66 @@ test/*.test.mjs 37 个文件 / 302 个用例,299 通过,3 跳
|
|||||||
- **发现降级**:删掉 `endpoints/` 后仍能通过 `instances/` 找到实例。
|
- **发现降级**:删掉 `endpoints/` 后仍能通过 `instances/` 找到实例。
|
||||||
- **崩溃剪枝**:插入一个 PID 必死的实例,剪枝后另一个实例仍正常工作;
|
- **崩溃剪枝**:插入一个 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` 仍有并发启动竞态。
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import * as esbuild from "esbuild";
|
import * as esbuild from "esbuild";
|
||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
|
||||||
const watch = process.argv.includes("--watch");
|
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({
|
const ctx = await esbuild.context({
|
||||||
entryPoints: [
|
entryPoints: [
|
||||||
"./src/extension.ts",
|
"./src/extension.ts",
|
||||||
|
|||||||
+60
-1
@@ -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 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 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)",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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 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)",
|
"Reference \"{0}\" has no matching definition (ids with the same name exist for other types)": "引用 \"{0}\" 没有匹配的定义(其他类型存在同名 id)",
|
||||||
"Unresolved reference \"{0}\" (not found in the current index)": "无法解析的引用 \"{0}\"(当前索引中未找到)"
|
"Unresolved reference \"{0}\" (not found in the current index)": "无法解析的引用 \"{0}\"(当前索引中未找到)",
|
||||||
|
"RA3 Mod XML: no index available yet. Wait for indexing to finish before exporting an AI Agent snapshot.": "RA3 Mod XML:暂无可用索引。请等待索引完成后再导出 AI Agent 快照。",
|
||||||
|
"RA3 Mod XML: exported AI Agent index snapshot to {0}": "RA3 Mod XML:已导出 AI Agent 索引快照到 {0}",
|
||||||
|
"Reveal in Explorer": "在资源管理器中显示",
|
||||||
|
"RA3 Mod XML: failed to export AI Agent index snapshot: {0}": "RA3 Mod XML:导出 AI Agent 索引快照失败:{0}",
|
||||||
|
"RA3 Mod XML: no index available yet. Wait for indexing to finish before enabling AI Agent access.": "RA3 Mod XML:暂无可用索引。请等待索引完成后再启用 AI Agent 访问。",
|
||||||
|
"RA3 Mod XML: the AI Agent launcher will use Node from PATH. Install Node, or run VS Code from a normal installation so the bundled runtime can be used.": "RA3 Mod XML:AI Agent 启动器将使用 PATH 中的 Node。请安装 Node,或从正常安装的 VS Code 启动,以使用内置运行时。",
|
||||||
|
"Install Agent Skill (recommended)": "安装 Agent Skill(推荐)",
|
||||||
|
"Write MCP config to Claude Desktop": "将 MCP 配置写入 Claude Desktop",
|
||||||
|
"Write MCP config to Cursor (global)": "将 MCP 配置写入 Cursor(全局)",
|
||||||
|
"Write MCP config to Cursor (project)": "将 MCP 配置写入 Cursor(项目)",
|
||||||
|
"Copy MCP config": "复制 MCP 配置",
|
||||||
|
"RA3 Mod XML AI Agent access enabled. Choose an optional next step.": "RA3 Mod XML AI Agent 访问已启用。请选择可选的后续操作。",
|
||||||
|
"RA3 Mod XML Agent Skill installed to {0}": "RA3 Mod XML Agent Skill 已安装到 {0}",
|
||||||
|
"RA3 Mod XML: could not write the Agent Skill to {0}": "RA3 Mod XML:无法将 Agent Skill 写入 {0}",
|
||||||
|
"RA3 Mod XML MCP config written to {0}": "RA3 Mod XML MCP 配置已写入 {0}",
|
||||||
|
"RA3 Mod XML MCP config copied to clipboard.": "RA3 Mod XML MCP 配置已复制到剪贴板。",
|
||||||
|
"RA3 Mod XML: failed to enable AI Agent access: {0}": "RA3 Mod XML:启用 AI Agent 访问失败:{0}",
|
||||||
|
"Default (~/.agents/skills)": "默认(~/.agents/skills)",
|
||||||
|
"Claude Code (~/.claude/skills)": "Claude Code(~/.claude/skills)",
|
||||||
|
"Current project .agents/skills": "当前项目 .agents/skills",
|
||||||
|
"Current project .claude/skills": "当前项目 .claude/skills",
|
||||||
|
"Choose a custom folder…": "选择自定义文件夹…",
|
||||||
|
"The skill is installed as <folder>/{0}": "Skill 将安装到 <文件夹>/{0}",
|
||||||
|
"Select Agent Skill install locations": "选择 Agent Skill 安装位置",
|
||||||
|
"Choose a skill folder": "选择 Skill 文件夹",
|
||||||
|
"Choose the folder that should contain the {0} skill": "选择用于存放 {0} Skill 的文件夹",
|
||||||
|
"RA3 Mod XML: could not write the Agent Skill (0 of {0} locations).": "RA3 Mod XML:无法写入 Agent Skill({0} 个位置全部失败)。",
|
||||||
|
"RA3 Mod XML Agent Skill installed to {0} location(s); {1} failed.": "RA3 Mod XML Agent Skill 已安装到 {0} 个位置;{1} 个失败。",
|
||||||
|
"RA3 Mod XML Agent Skill installed to {0} location(s).": "RA3 Mod XML Agent Skill 已安装到 {0} 个位置。",
|
||||||
|
"Show installed skills": "查看已安装的 Skill",
|
||||||
|
"RA3 Mod XML: no recorded Agent Skill installation to remove.": "RA3 Mod XML:没有可移除的 Agent Skill 安装记录。",
|
||||||
|
"missing — will be dropped from the record": "已缺失——将从记录中删除",
|
||||||
|
"installed by this extension (v{0})": "由本扩展安装(v{0})",
|
||||||
|
"not managed by this extension — will be skipped": "不由本扩展管理——将跳过",
|
||||||
|
"No RA3 Mod XML skill marker found; remove it manually if you want it gone.": "未找到 RA3 Mod XML Skill 标记;如需删除请手动处理。",
|
||||||
|
"Select Agent Skill installations to remove": "选择要移除的 Agent Skill 安装",
|
||||||
|
"RA3 Mod XML: removed {0} Agent Skill installation(s); {1} skipped (not managed by this extension).": "RA3 Mod XML:已移除 {0} 个 Agent Skill 安装;跳过 {1} 个(不由本扩展管理)。",
|
||||||
|
"RA3 Mod XML: removed {0} Agent Skill installation(s).": "RA3 Mod XML:已移除 {0} 个 Agent Skill 安装。",
|
||||||
|
"RA3 Mod XML: AI Agent access disabled for this workspace. Installed skills and MCP client configurations were kept.": "RA3 Mod XML:已禁用此工作区的 AI Agent 访问。已安装的 Skill 和 MCP 客户端配置已保留。",
|
||||||
|
"Uninstall AI Agent integration…": "卸载 AI Agent 集成…",
|
||||||
|
"Stop live AI Agent access in this window": "在此窗口中停止实时 AI Agent 访问",
|
||||||
|
"Remove installed Agent Skills": "移除已安装的 Agent Skill",
|
||||||
|
"{0} location(s)": "{0} 个位置",
|
||||||
|
"Remove MCP client configuration entries": "移除 MCP 客户端配置项",
|
||||||
|
"Claude Desktop / Cursor and files recorded by this extension": "Claude Desktop / Cursor 以及本扩展记录过的配置文件",
|
||||||
|
"Remove the stable MCP launcher": "移除稳定的 MCP 启动器",
|
||||||
|
"Select what to remove (nothing is removed until you confirm)": "选择要移除的内容(确认前不会删除任何内容)",
|
||||||
|
"Remove the selected AI Agent components?": "要移除选中的 AI Agent 组件吗?",
|
||||||
|
"Remove": "移除",
|
||||||
|
"live access stopped": "已停止实时访问",
|
||||||
|
"{0} skill installation(s) removed": "已移除 {0} 个 Skill 安装",
|
||||||
|
"{0} unmanaged skill folder(s) skipped": "已跳过 {0} 个非本扩展管理的 Skill 目录",
|
||||||
|
"MCP config removed from {0} file(s)": "已从 {0} 个配置文件中移除 MCP 配置",
|
||||||
|
"MCP launcher removed": "已移除 MCP 启动器",
|
||||||
|
"RA3 Mod XML AI Agent cleanup: {0}.": "RA3 Mod XML AI Agent 清理完成:{0}。",
|
||||||
|
"RA3 Mod XML: this version can expose the project's asset index to AI agents (MCP + Agent Skill). Enable it?": "RA3 Mod XML:此版本可以把项目资产索引提供给 AI Agent(MCP + Agent Skill)。要启用吗?",
|
||||||
|
"Enable AI Agent access": "启用 AI Agent 访问",
|
||||||
|
"Learn more": "了解更多",
|
||||||
|
"Don't show again": "不再提示"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,10 +130,22 @@
|
|||||||
"command": "ra3modxml.enableAgentAccess",
|
"command": "ra3modxml.enableAgentAccess",
|
||||||
"title": "%ra3modxml.command.enableAgentAccess.title%"
|
"title": "%ra3modxml.command.enableAgentAccess.title%"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "ra3modxml.disableAgentAccess",
|
||||||
|
"title": "%ra3modxml.command.disableAgentAccess.title%"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "ra3modxml.installAgentSkill",
|
"command": "ra3modxml.installAgentSkill",
|
||||||
"title": "%ra3modxml.command.installAgentSkill.title%"
|
"title": "%ra3modxml.command.installAgentSkill.title%"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "ra3modxml.uninstallAgentSkill",
|
||||||
|
"title": "%ra3modxml.command.uninstallAgentSkill.title%"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "ra3modxml.uninstallAgentIntegration",
|
||||||
|
"title": "%ra3modxml.command.uninstallAgentIntegration.title%"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "ra3modxml.exportIndexSnapshot",
|
"command": "ra3modxml.exportIndexSnapshot",
|
||||||
"title": "%ra3modxml.command.exportIndexSnapshot.title%"
|
"title": "%ra3modxml.command.exportIndexSnapshot.title%"
|
||||||
|
|||||||
@@ -14,7 +14,10 @@
|
|||||||
"ra3modxml.command.configureSdkPath.title": "RA3 Mod XML: Configure SDK path…",
|
"ra3modxml.command.configureSdkPath.title": "RA3 Mod XML: Configure SDK path…",
|
||||||
"ra3modxml.command.showCacheReport.title": "RA3 Mod XML: Show cache report",
|
"ra3modxml.command.showCacheReport.title": "RA3 Mod XML: Show cache report",
|
||||||
"ra3modxml.command.enableAgentAccess.title": "RA3 Mod XML: Enable AI Agent access…",
|
"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.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.exportIndexSnapshot.title": "RA3 Mod XML: Export AI Agent index snapshot",
|
||||||
"ra3modxml.command.findUnreferencedAssets.title": "RA3 Mod XML: Find unreferenced assets…",
|
"ra3modxml.command.findUnreferencedAssets.title": "RA3 Mod XML: Find unreferenced assets…",
|
||||||
"ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: Find unreferenced assets of this type"
|
"ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: Find unreferenced assets of this type"
|
||||||
|
|||||||
@@ -14,7 +14,10 @@
|
|||||||
"ra3modxml.command.configureSdkPath.title": "RA3 Mod XML: 配置 SDK 路径…",
|
"ra3modxml.command.configureSdkPath.title": "RA3 Mod XML: 配置 SDK 路径…",
|
||||||
"ra3modxml.command.showCacheReport.title": "RA3 Mod XML: 显示缓存报告",
|
"ra3modxml.command.showCacheReport.title": "RA3 Mod XML: 显示缓存报告",
|
||||||
"ra3modxml.command.enableAgentAccess.title": "RA3 Mod XML: 启用 AI Agent 访问…",
|
"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.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.exportIndexSnapshot.title": "RA3 Mod XML: 导出 AI Agent 索引快照",
|
||||||
"ra3modxml.command.findUnreferencedAssets.title": "RA3 Mod XML: 查找未引用的资产…",
|
"ra3modxml.command.findUnreferencedAssets.title": "RA3 Mod XML: 查找未引用的资产…",
|
||||||
"ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: 查找该类型的未引用资产"
|
"ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: 查找该类型的未引用资产"
|
||||||
|
|||||||
+47
-1
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { dirname, join } from "node:path";
|
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";
|
import { defaultAgentHome, snapshotBaseName } from "./snapshot";
|
||||||
|
|
||||||
/** One live extension-host instance. */
|
/** 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. */
|
/** Snapshot path for a project, re-exported for discovery convenience. */
|
||||||
export function projectKey(projectDir: string): string {
|
export function projectKey(projectDir: string): string {
|
||||||
return snapshotBaseName(projectDir);
|
return snapshotBaseName(projectDir);
|
||||||
|
|||||||
@@ -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
@@ -9,9 +9,9 @@
|
|||||||
* Pure TypeScript: no VS Code dependency.
|
* 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 { homedir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
import { defaultAgentHome } from "./snapshot";
|
import { defaultAgentHome } from "./snapshot";
|
||||||
import {
|
import {
|
||||||
launcherScript,
|
launcherScript,
|
||||||
@@ -19,6 +19,9 @@ import {
|
|||||||
type AgentRuntime,
|
type AgentRuntime,
|
||||||
} from "./runtime";
|
} 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 {
|
export interface McpConfigTarget {
|
||||||
id: string;
|
id: string;
|
||||||
label: 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
|
* 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(
|
export async function addMcpServerToConfigFile(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
launcher: string,
|
launcher: string,
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
|
serverKey = MCP_SERVER_KEY,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let config: Record<string, unknown> = {};
|
let config: Record<string, unknown> = {};
|
||||||
try {
|
try {
|
||||||
@@ -166,7 +170,7 @@ export async function addMcpServerToConfigFile(
|
|||||||
// File absent or malformed: start fresh.
|
// File absent or malformed: start fresh.
|
||||||
}
|
}
|
||||||
const servers = (config.mcpServers as Record<string, unknown> | undefined) ?? {};
|
const servers = (config.mcpServers as Record<string, unknown> | undefined) ?? {};
|
||||||
servers["ra3-mod-xml"] = {
|
servers[serverKey] = {
|
||||||
command: launcher,
|
command: launcher,
|
||||||
args: ["--project", projectDir],
|
args: ["--project", projectDir],
|
||||||
};
|
};
|
||||||
@@ -175,6 +179,180 @@ export async function addMcpServerToConfigFile(
|
|||||||
await writeFile(filePath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
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. */
|
/** Default path used for the agent home. */
|
||||||
export function defaultAgentHomeForSetup(): string {
|
export function defaultAgentHomeForSetup(): string {
|
||||||
return join(homedir(), ".ra3modxml");
|
return join(homedir(), ".ra3modxml");
|
||||||
|
|||||||
+147
-23
@@ -9,9 +9,9 @@
|
|||||||
* Pure TypeScript: no VS Code dependency.
|
* 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 { homedir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
import { defaultAgentHome } from "./snapshot";
|
import { defaultAgentHome } from "./snapshot";
|
||||||
|
|
||||||
export const SKILL_NAME = "ra3-mod-xml";
|
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
|
- XML that uses \`<Includes><Include source="DATA:…" /></Includes>\` or declares
|
||||||
\`xmlns="uri:ea.com:eala:asset"\`.
|
\`xmlns="uri:ea.com:eala:asset"\`.
|
||||||
|
|
||||||
Do **not** use these tools for unrelated repositories. In particular, do not
|
Do **not** use these tools for unrelated repositories. SAGE-looking XML, a
|
||||||
use them merely because a project contains XML, a build script, copied \`.xsd\`
|
build script, copied \`.xsd\` schema files, or a folder named \`Data\` are not
|
||||||
files, or a folder named \`Data\`. A file named \`CnC3Types.xsd\` refers to
|
by themselves evidence of a Red Alert 3 mod project: require at least one of
|
||||||
C&C3 (Tiberium Wars / Kane's Wrath) and is **not** by itself evidence of a
|
the positive signals above. When the repository is not a SAGE / RA3 mod
|
||||||
Red Alert 3 mod. When the repository is not a SAGE / RA3 mod project, ignore
|
project, ignore this skill entirely.
|
||||||
this skill entirely.
|
|
||||||
|
|
||||||
If you are unsure whether the current project is in scope, call \`get_status\`
|
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
|
first: it is cheap and reports the \`projectDir\` the index belongs to. Stop
|
||||||
using the index tools when:
|
using the index tools when:
|
||||||
|
|
||||||
- the state is \`no_index\`, or
|
- 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
|
In those cases read the files directly instead. Never present index results
|
||||||
from one project as if they belonged to another.
|
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
|
stdio. Read \`~/.ra3modxml/index.json\` first: it is a small, stable
|
||||||
discovery manifest listing the live instances and their project roots.
|
discovery manifest listing the live instances and their project roots.
|
||||||
|
|
||||||
- If it does not exist, the extension has never been enabled for this
|
- If it does not exist, live discovery has not been enabled on this
|
||||||
project. Tell the user to open the project in VS Code and run
|
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.
|
"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\`
|
- If it exists, use the launcher at \`~/.ra3modxml/ra3-mod-xml-mcp.cmd\`
|
||||||
(Windows) or \`~/.ra3modxml/ra3-mod-xml-mcp\` (elsewhere), or call the
|
(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
|
A CLI with the same capabilities is bundled alongside the MCP server
|
||||||
(\`cli.js\` next to \`mcpServer.js\` in the extension's \`dist/agent/\`).
|
(\`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
|
It is a plain Node script: run it with
|
||||||
commands. It answers from the live index when VS Code is running and
|
\`node <path-to-cli.js> <command>\`. If Node is not installed, do not
|
||||||
falls back to the last exported snapshot otherwise. Commands that need
|
install anything — run the same file with the VS Code runtime recorded in
|
||||||
element context (\`outgoing\`, \`projects\`) require the live index and will
|
the launcher instead:
|
||||||
say so explicitly instead of returning an empty result.
|
\`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
|
3. **You can write configuration, if the user agrees.** You may add the MCP
|
||||||
server to the your own harness' configuration.
|
server to your own client's configuration. Most clients only load MCP
|
||||||
Check what your own client supports (e.g. if a restart is required)
|
servers at startup, so tell the user the change takes effect in a new session;
|
||||||
before promising otherwise.
|
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
|
## 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
|
- \`get_asset_references(id, type?, depth?, targetTypes?)\` for outgoing
|
||||||
references (what this asset uses, and where that link is written).
|
references (what this asset uses, and where that link is written).
|
||||||
- \`list_assets_by_type(type, prefix?, limit?)\` for browsing assets.
|
- \`list_assets_by_type(type, prefix?, limit?)\` for browsing assets.
|
||||||
- \`is_file_active(path)\` to determine whether a file is included in an indexed stream. - \`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.
|
- \`resolve_include(source)\` for Include source candidates.
|
||||||
3. Asset ids are case-insensitive.
|
3. Asset ids are case-insensitive.
|
||||||
4. If an id exists for multiple asset types, pass the type filter to avoid mixing definitions.
|
4. If an id exists for multiple asset types, pass the type filter to avoid mixing definitions.
|
||||||
@@ -131,6 +138,9 @@ If none of the steps work, say the index is unavailable and read the XML files d
|
|||||||
results as provisional.
|
results as provisional.
|
||||||
6. Never read or dump the whole index snapshot; query narrowly.
|
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
|
## Following references with get_asset_references
|
||||||
|
|
||||||
Use \`get_asset_references\` to answer "which weapon / model / upgrade / die-object
|
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)
|
- is_file_active(path)
|
||||||
- find_define(name)
|
- find_define(name)
|
||||||
- resolve_include(source)
|
- resolve_include(source)
|
||||||
|
- list_projects()
|
||||||
- get_usage_guide()
|
- 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
|
## Result metadata
|
||||||
|
|
||||||
Every query result includes an \`index\` object:
|
Every query result includes an \`index\` object:
|
||||||
@@ -352,7 +367,105 @@ export async function uninstallSkillFromDirectory(
|
|||||||
await writeSkillInstallRecord(installed, agentHome);
|
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(
|
export async function syncInstalledSkills(
|
||||||
sourceVersion: string,
|
sourceVersion: string,
|
||||||
agentHome = defaultAgentHome(),
|
agentHome = defaultAgentHome(),
|
||||||
@@ -360,11 +473,22 @@ export async function syncInstalledSkills(
|
|||||||
const installed = await readSkillInstallRecord(agentHome);
|
const installed = await readSkillInstallRecord(agentHome);
|
||||||
const synced: SkillInstallRecord[] = [];
|
const synced: SkillInstallRecord[] = [];
|
||||||
for (const record of installed) {
|
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 {
|
try {
|
||||||
await writeSkillTo(record.path, sourceVersion);
|
await writeSkillTo(record.path, sourceVersion);
|
||||||
synced.push({ path: record.path, sourceVersion });
|
synced.push({ path: record.path, sourceVersion });
|
||||||
} catch {
|
} 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);
|
await writeSkillInstallRecord(synced, agentHome);
|
||||||
|
|||||||
+323
-33
@@ -30,26 +30,34 @@ import {
|
|||||||
writeSnapshotFile,
|
writeSnapshotFile,
|
||||||
} from "./agent/snapshot";
|
} from "./agent/snapshot";
|
||||||
import {
|
import {
|
||||||
addMcpServerToConfigFile,
|
|
||||||
claudeDesktopConfigPath,
|
claudeDesktopConfigPath,
|
||||||
cursorGlobalConfigPath,
|
cursorGlobalConfigPath,
|
||||||
cursorProjectConfigPath,
|
cursorProjectConfigPath,
|
||||||
|
installMcpServerConfigToFile,
|
||||||
|
launcherPath,
|
||||||
mcpConfigJson,
|
mcpConfigJson,
|
||||||
|
removeLauncher,
|
||||||
|
uninstallMcpServerConfigs,
|
||||||
writeLauncher,
|
writeLauncher,
|
||||||
} from "./agent/setup";
|
} from "./agent/setup";
|
||||||
import {
|
import {
|
||||||
SKILL_NAME,
|
SKILL_NAME,
|
||||||
agentsSkillsDirForUser,
|
agentsSkillsDirForUser,
|
||||||
claudeSkillsDirForUser,
|
claudeSkillsDirForUser,
|
||||||
|
forgetSkillInstallRecords,
|
||||||
installSkillToDirectories,
|
installSkillToDirectories,
|
||||||
|
installedSkillStatus,
|
||||||
readSkillInstallRecord,
|
readSkillInstallRecord,
|
||||||
writeSkillInstallRecord,
|
syncInstalledSkills,
|
||||||
writeSkillTo,
|
uninstallRecordedSkills,
|
||||||
} from "./agent/skill";
|
} from "./agent/skill";
|
||||||
|
import {
|
||||||
|
shouldOfferAgentOnboarding,
|
||||||
|
type AgentOnboardingState,
|
||||||
|
} from "./agent/onboarding";
|
||||||
import { startLocalServer, type LocalServerHandle } from "./agent/localServer";
|
import { startLocalServer, type LocalServerHandle } from "./agent/localServer";
|
||||||
import { parseLoadedXml } from "./agent/forwardRefs";
|
import { parseLoadedXml } from "./agent/forwardRefs";
|
||||||
import {
|
import {
|
||||||
clearEndpoint,
|
|
||||||
clearEndpointForProject,
|
clearEndpointForProject,
|
||||||
writeEndpoint,
|
writeEndpoint,
|
||||||
writeEndpointForProject,
|
writeEndpointForProject,
|
||||||
@@ -58,6 +66,7 @@ import {
|
|||||||
clearInstance,
|
clearInstance,
|
||||||
makeInstanceId,
|
makeInstanceId,
|
||||||
pruneInstances,
|
pruneInstances,
|
||||||
|
refreshDiscovery,
|
||||||
writeInstance,
|
writeInstance,
|
||||||
writeManifest,
|
writeManifest,
|
||||||
} from "./agent/instances";
|
} from "./agent/instances";
|
||||||
@@ -136,6 +145,10 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||||||
ws.log("[codelens] retry started");
|
ws.log("[codelens] retry started");
|
||||||
};
|
};
|
||||||
ws.onBuildStart = startCodeLensRetry;
|
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
|
// Coalesced agent snapshot refresh: after AI Agent access is enabled, keep
|
||||||
// the external snapshot current without writing on every intermediate
|
// the external snapshot current without writing on every intermediate
|
||||||
// rebuild. The timer only fires after a quiet period following a complete,
|
// 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",
|
"ra3modxml.agentAccessEnabled",
|
||||||
false,
|
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;
|
const AGENT_SNAPSHOT_QUIET_MS = 5000;
|
||||||
let agentSnapshotTimer: ReturnType<typeof setTimeout> | null = null;
|
let agentSnapshotTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
const scheduleAgentSnapshot = (): void => {
|
const scheduleAgentSnapshot = (): void => {
|
||||||
@@ -270,8 +329,11 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||||||
}
|
}
|
||||||
agentEndpointProjects.clear();
|
agentEndpointProjects.clear();
|
||||||
await clearInstance(agentInstanceId).catch(() => undefined);
|
await clearInstance(agentInstanceId).catch(() => undefined);
|
||||||
await clearEndpoint().catch(() => undefined);
|
// Other VS Code windows may still be serving an index. Re-derive the
|
||||||
await writeManifest([]).catch(() => undefined);
|
// 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
|
* Republishes this window's endpoint/instance files for every project it now
|
||||||
@@ -335,6 +397,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||||||
}
|
}
|
||||||
scheduleAgentSnapshot();
|
scheduleAgentSnapshot();
|
||||||
void refreshAgentEndpoints();
|
void refreshAgentEndpoints();
|
||||||
|
void maybeOfferAgentOnboarding();
|
||||||
for (const doc of vscode.workspace.textDocuments) {
|
for (const doc of vscode.workspace.textDocuments) {
|
||||||
if (doc.languageId === "xml") void diagnostics.update(doc);
|
if (doc.languageId === "xml") void diagnostics.update(doc);
|
||||||
}
|
}
|
||||||
@@ -600,25 +663,46 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||||||
);
|
);
|
||||||
if (pick?.id === "skill") {
|
if (pick?.id === "skill") {
|
||||||
const target = agentsSkillsDirForUser();
|
const target = agentsSkillsDirForUser();
|
||||||
await writeSkillTo(target, version);
|
const installed = await installSkillToDirectories([target], version);
|
||||||
const installed = await readSkillInstallRecord();
|
if (installed.length) {
|
||||||
if (!installed.some((r) => r.path === target)) installed.push({ path: target, sourceVersion: version });
|
|
||||||
await writeSkillInstallRecord(installed);
|
|
||||||
void vscode.window.showInformationMessage(
|
void vscode.window.showInformationMessage(
|
||||||
t("RA3 Mod XML Agent Skill installed to {0}", target),
|
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") {
|
} 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(
|
void vscode.window.showInformationMessage(
|
||||||
t("RA3 Mod XML MCP config written to {0}", claudeDesktopConfigPath()),
|
t("RA3 Mod XML MCP config written to {0}", claudeDesktopConfigPath()),
|
||||||
);
|
);
|
||||||
} else if (pick?.id === "cursor-global") {
|
} else if (pick?.id === "cursor-global") {
|
||||||
await addMcpServerToConfigFile(cursorGlobalConfigPath(), launcher.path, projectDir);
|
await installMcpServerConfigToFile({
|
||||||
|
filePath: cursorGlobalConfigPath(),
|
||||||
|
launcher: launcher.path,
|
||||||
|
projectDir,
|
||||||
|
label: "Cursor (global)",
|
||||||
|
sourceVersion: version,
|
||||||
|
});
|
||||||
void vscode.window.showInformationMessage(
|
void vscode.window.showInformationMessage(
|
||||||
t("RA3 Mod XML MCP config written to {0}", cursorGlobalConfigPath()),
|
t("RA3 Mod XML MCP config written to {0}", cursorGlobalConfigPath()),
|
||||||
);
|
);
|
||||||
} else if (pick?.id === "cursor-project") {
|
} else if (pick?.id === "cursor-project") {
|
||||||
await addMcpServerToConfigFile(cursorProjectConfigPath(projectDir), launcher.path, projectDir);
|
await installMcpServerConfigToFile({
|
||||||
|
filePath: cursorProjectConfigPath(projectDir),
|
||||||
|
launcher: launcher.path,
|
||||||
|
projectDir,
|
||||||
|
label: "Cursor (project)",
|
||||||
|
sourceVersion: version,
|
||||||
|
});
|
||||||
void vscode.window.showInformationMessage(
|
void vscode.window.showInformationMessage(
|
||||||
t("RA3 Mod XML MCP config written to {0}", cursorProjectConfigPath(projectDir)),
|
t("RA3 Mod XML MCP config written to {0}", cursorProjectConfigPath(projectDir)),
|
||||||
);
|
);
|
||||||
@@ -640,51 +724,238 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||||||
);
|
);
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
vscode.commands.registerCommand("ra3modxml.installAgentSkill", async () => {
|
vscode.commands.registerCommand("ra3modxml.installAgentSkill", async () => {
|
||||||
const idx = ws.activeIndex();
|
// Installing the Skill only writes text files; it does not need an
|
||||||
if (!idx) {
|
// index, so users can prepare their agent tooling before the first build.
|
||||||
void vscode.window.showInformationMessage(
|
const projectDir = ws.projectRoot;
|
||||||
t(
|
|
||||||
"RA3 Mod XML: no index available yet. Open and index a project before installing the Agent Skill.",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const projectDir = idx.projectDir;
|
|
||||||
const version = String((context.extension.packageJSON as { version?: string }).version ?? "dev");
|
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)"),
|
label: t("Default (~/.agents/skills)"),
|
||||||
description: agentsSkillsDirForUser(),
|
description: agentsSkillsDirForUser(),
|
||||||
path: agentsSkillsDirForUser(),
|
target: agentsSkillsDirForUser(),
|
||||||
picked: true,
|
picked: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("Claude Code (~/.claude/skills)"),
|
label: t("Claude Code (~/.claude/skills)"),
|
||||||
description: claudeSkillsDirForUser(),
|
description: claudeSkillsDirForUser(),
|
||||||
path: claudeSkillsDirForUser(),
|
target: claudeSkillsDirForUser(),
|
||||||
},
|
},
|
||||||
|
];
|
||||||
|
if (projectDir) {
|
||||||
|
choices.push(
|
||||||
{
|
{
|
||||||
label: t("Current project .agents/skills"),
|
label: t("Current project .agents/skills"),
|
||||||
description: join(projectDir, ".agents", "skills", SKILL_NAME),
|
description: join(projectDir, ".agents", "skills", SKILL_NAME),
|
||||||
path: join(projectDir, ".agents", "skills", SKILL_NAME),
|
target: join(projectDir, ".agents", "skills", SKILL_NAME),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("Current project .claude/skills"),
|
label: t("Current project .claude/skills"),
|
||||||
description: join(projectDir, ".claude", "skills", SKILL_NAME),
|
description: join(projectDir, ".claude", "skills", SKILL_NAME),
|
||||||
path: 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, {
|
const picked = await vscode.window.showQuickPick(choices, {
|
||||||
canPickMany: true,
|
canPickMany: true,
|
||||||
placeHolder: t("Select Agent Skill install locations"),
|
placeHolder: t("Select Agent Skill install locations"),
|
||||||
});
|
});
|
||||||
if (!picked?.length) return;
|
if (!picked?.length) return;
|
||||||
const succeeded = await installSkillToDirectories(
|
|
||||||
picked.map((p) => p.path),
|
const targets = picked
|
||||||
version,
|
.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(
|
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 startAgentLocalServer();
|
||||||
void refreshLaunchers();
|
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 sdkSetup.evaluate(ws);
|
||||||
void ws.initialize().then(() => {
|
void ws.initialize().then(() => {
|
||||||
void sdkSetup.evaluate(ws);
|
void sdkSetup.evaluate(ws);
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import {
|
|||||||
pruneInstances,
|
pruneInstances,
|
||||||
readInstances,
|
readInstances,
|
||||||
readManifest,
|
readManifest,
|
||||||
|
refreshDiscovery,
|
||||||
writeInstance,
|
writeInstance,
|
||||||
writeManifest,
|
writeManifest,
|
||||||
} from "../out/agent/instances.js";
|
} from "../out/agent/instances.js";
|
||||||
|
import { endpointPath, readEndpoint } from "../out/agent/endpoint.js";
|
||||||
|
|
||||||
function instanceHome() {
|
function instanceHome() {
|
||||||
return mkdtempSync(join(tmpdir(), "ra3-instances-"));
|
return mkdtempSync(join(tmpdir(), "ra3-instances-"));
|
||||||
@@ -166,3 +168,52 @@ test("makeInstanceId is unique across rapid calls", () => {
|
|||||||
assert.equal(ids.size, 200);
|
assert.equal(ids.size, 200);
|
||||||
for (const id of ids) assert.ok(id.startsWith("1234-"), id);
|
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
@@ -1,12 +1,18 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import {
|
import {
|
||||||
addMcpServerToConfigFile,
|
addMcpServerToConfigFile,
|
||||||
|
installMcpServerConfigToFile,
|
||||||
|
launcherPath,
|
||||||
mcpConfigJson,
|
mcpConfigJson,
|
||||||
mcpServerConfig,
|
mcpServerConfig,
|
||||||
|
readMcpInstallRecord,
|
||||||
|
removeLauncher,
|
||||||
|
removeMcpServerFromConfigFile,
|
||||||
|
uninstallMcpServerConfigs,
|
||||||
} from "../out/agent/setup.js";
|
} from "../out/agent/setup.js";
|
||||||
|
|
||||||
test("mcpServerConfig and mcpConfigJson use the stable launcher", () => {
|
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 });
|
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
@@ -1,13 +1,18 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import {
|
import {
|
||||||
SKILL_MARKER_FILE,
|
SKILL_MARKER_FILE,
|
||||||
|
forgetSkillInstallRecords,
|
||||||
installSkillToDirectories,
|
installSkillToDirectories,
|
||||||
|
installedSkillStatus,
|
||||||
readSkillInstallRecord,
|
readSkillInstallRecord,
|
||||||
|
readSkillMarker,
|
||||||
|
uninstallRecordedSkills,
|
||||||
uninstallSkillFromDirectory,
|
uninstallSkillFromDirectory,
|
||||||
|
writeSkillInstallRecord,
|
||||||
writeSkillTo,
|
writeSkillTo,
|
||||||
} from "../out/agent/skill.js";
|
} 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");
|
const content = readFileSync(join(skillDir, "SKILL.md"), "utf8");
|
||||||
assert.ok(content.includes("find_asset"));
|
assert.ok(content.includes("find_asset"));
|
||||||
assert.ok(!content.includes("codebase-navigation-guide"));
|
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 {
|
} finally {
|
||||||
rmSync(dir, { recursive: true, force: true });
|
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.match(content, /Do \*\*not\*\* use these tools for unrelated repositories/);
|
||||||
assert.ok(content.includes("get_status"));
|
assert.ok(content.includes("get_status"));
|
||||||
assert.ok(content.includes("projectDir"));
|
assert.ok(content.includes("projectDir"));
|
||||||
// The CnC3 red herring is called out explicitly.
|
// `CnC3Types.xsd` is the shared SAGE base schema (the RA3 Mod SDK ships it
|
||||||
assert.ok(content.includes("CnC3Types.xsd"));
|
// too), so it must not be presented as evidence in either direction.
|
||||||
assert.ok(content.includes("C&C3"));
|
assert.equal(content.includes("CnC3Types.xsd"), false);
|
||||||
|
assert.equal(content.includes("Tiberium Wars"), false);
|
||||||
|
|
||||||
// Second-phase capability must be documented.
|
// Second-phase capability must be documented.
|
||||||
assert.ok(content.includes("get_asset_references"));
|
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.
|
// The launcher and the bundled CLI are both mentioned.
|
||||||
assert.ok(content.includes("ra3-mod-xml-mcp"));
|
assert.ok(content.includes("ra3-mod-xml-mcp"));
|
||||||
assert.ok(content.includes("cli.js"));
|
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.
|
// The stdio escape hatch must be shown, since it needs no setup at all.
|
||||||
assert.ok(content.includes("tools/call"));
|
assert.ok(content.includes("tools/call"));
|
||||||
// It must not pretend configuring a client takes effect immediately.
|
// It must not pretend configuring a client takes effect immediately.
|
||||||
assert.ok(content.includes("new session"));
|
assert.ok(content.includes("new session"));
|
||||||
// And it must refuse to invent results.
|
// And it must refuse to invent results.
|
||||||
assert.match(content, /Never fabricate index 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.
|
// Instructions must not send it looking for project-specific docs.
|
||||||
assert.equal(content.includes("codebase-navigation-guide"), false);
|
assert.equal(content.includes("codebase-navigation-guide"), false);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -101,3 +136,94 @@ test("installSkillToDirectories records managed copies and uninstall removes the
|
|||||||
rmSync(dir, { recursive: true, force: true });
|
rmSync(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("readSkillMarker requires our marker to describe the same directory", async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-marker-"));
|
||||||
|
try {
|
||||||
|
const managed = join(dir, "managed");
|
||||||
|
await writeSkillTo(managed, "1.0.0");
|
||||||
|
const marker = await readSkillMarker(managed);
|
||||||
|
assert.equal(marker?.sourceVersion, "1.0.0");
|
||||||
|
|
||||||
|
// A hand-copied skill without a marker is never treated as ours.
|
||||||
|
const foreign = join(dir, "foreign");
|
||||||
|
mkdirSync(foreign, { recursive: true });
|
||||||
|
writeFileSync(join(foreign, "SKILL.md"), "user content", "utf8");
|
||||||
|
assert.equal(await readSkillMarker(foreign), null);
|
||||||
|
|
||||||
|
// A marker pointing at another directory must not make this one managed.
|
||||||
|
const spoofed = join(dir, "spoofed");
|
||||||
|
mkdirSync(spoofed, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(spoofed, SKILL_MARKER_FILE),
|
||||||
|
JSON.stringify({ path: managed, sourceVersion: "1.0.0" }),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.equal(await readSkillMarker(spoofed), null);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uninstallRecordedSkills removes managed copies and skips user-owned ones", async () => {
|
||||||
|
const home = mkdtempSync(join(tmpdir(), "ra3-skill-uninstall-home-"));
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-uninstall-"));
|
||||||
|
try {
|
||||||
|
const managed = join(dir, "managed");
|
||||||
|
await installSkillToDirectories([managed], "1.0.0", home);
|
||||||
|
|
||||||
|
// A directory the user owns: recorded, but with no marker of ours.
|
||||||
|
const foreign = join(dir, "foreign");
|
||||||
|
mkdirSync(foreign, { recursive: true });
|
||||||
|
writeFileSync(join(foreign, "SKILL.md"), "user content", "utf8");
|
||||||
|
const record = await readSkillInstallRecord(home);
|
||||||
|
record.push({ path: foreign, sourceVersion: "0.0.0" });
|
||||||
|
await writeSkillInstallRecord(record, home);
|
||||||
|
|
||||||
|
const status = await installedSkillStatus(home);
|
||||||
|
assert.equal(status.length, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
status.map((s) => ({ path: s.path, managed: s.managed })),
|
||||||
|
[
|
||||||
|
{ path: managed, managed: true },
|
||||||
|
{ path: foreign, managed: false },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { removed, skipped } = await uninstallRecordedSkills(
|
||||||
|
[managed, foreign],
|
||||||
|
home,
|
||||||
|
);
|
||||||
|
assert.deepEqual(removed, [managed]);
|
||||||
|
assert.deepEqual(skipped, [foreign]);
|
||||||
|
assert.equal(existsSync(managed), false);
|
||||||
|
assert.equal(readFileSync(join(foreign, "SKILL.md"), "utf8"), "user content");
|
||||||
|
|
||||||
|
// The unmanaged entry stays in the record so it can still be reviewed.
|
||||||
|
const after = await readSkillInstallRecord(home);
|
||||||
|
assert.deepEqual(after.map((r) => r.path), [foreign]);
|
||||||
|
} finally {
|
||||||
|
rmSync(home, { recursive: true, force: true });
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("installedSkillStatus and forgetSkillInstallRecords handle a missing directory", async () => {
|
||||||
|
const home = mkdtempSync(join(tmpdir(), "ra3-skill-missing-home-"));
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-missing-"));
|
||||||
|
try {
|
||||||
|
const gone = join(dir, "gone");
|
||||||
|
await writeSkillInstallRecord([{ path: gone, sourceVersion: "1.0.0" }], home);
|
||||||
|
|
||||||
|
const status = await installedSkillStatus(home);
|
||||||
|
assert.equal(status.length, 1);
|
||||||
|
assert.equal(status[0].exists, false);
|
||||||
|
assert.equal(status[0].managed, false);
|
||||||
|
|
||||||
|
await forgetSkillInstallRecords([gone], home);
|
||||||
|
assert.equal((await readSkillInstallRecord(home)).length, 0);
|
||||||
|
} finally {
|
||||||
|
rmSync(home, { recursive: true, force: true });
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user