334 lines
12 KiB
TypeScript
334 lines
12 KiB
TypeScript
/**
|
|
* Agent Skill generator/installer for the RA3 Mod XML MCP tools.
|
|
*
|
|
* The Skill focuses on the functionality itself: when to use it and how to
|
|
* use the RA3 Mod XML MCP query tools. It intentionally avoids referencing
|
|
* any project-specific docs (e.g. docs/codebase-navigation-guide.md) so the
|
|
* agent is not distracted by unrelated workspace guidance.
|
|
*
|
|
* Pure TypeScript: no VS Code dependency.
|
|
*/
|
|
|
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { homedir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { defaultAgentHome } from "./snapshot";
|
|
|
|
export const SKILL_NAME = "ra3-mod-xml";
|
|
export const SKILL_MARKER_FILE = ".ra3modxml-skill.json";
|
|
|
|
const SKILL_MD = `---
|
|
name: ra3-mod-xml
|
|
description: Use the RA3 Mod XML semantic index to find asset definitions, references, outgoing references, active files, defines, and include sources in SAGE / BinaryAssetBuilder XML projects such as Command & Conquer: Red Alert 3 mods. Use this when you need exact facts about mod assets instead of guessing or full-text searching the XML tree.
|
|
---
|
|
|
|
# RA3 Mod XML Index
|
|
|
|
This skill provides access to the semantic index built by the RA3 Mod XML VS Code extension.
|
|
|
|
## When this skill applies
|
|
|
|
Use these tools only for SAGE / BinaryAssetBuilder mod XML projects — the kind
|
|
used by Command & Conquer: Red Alert 3 mods.
|
|
|
|
Positive signals (any single one is enough to try the tools):
|
|
|
|
- \`Data/Mod.xml\` exists.
|
|
- \`Data/additionalmaps/mapmetadata_*.xml\` exists.
|
|
- A \`*.babproj\` file exists.
|
|
- XML whose root element is \`<AssetDeclaration>\`.
|
|
- XML that uses \`<Includes><Include source="DATA:…" /></Includes>\` or declares
|
|
\`xmlns="uri:ea.com:eala:asset"\`.
|
|
|
|
Do **not** use these tools for unrelated repositories. In particular, do not
|
|
use them merely because a project contains XML, a build script, copied \`.xsd\`
|
|
files, or a folder named \`Data\`. A file named \`CnC3Types.xsd\` refers to
|
|
C&C3 (Tiberium Wars / Kane's Wrath) and is **not** by itself evidence of a
|
|
Red Alert 3 mod. When the repository is not a SAGE / RA3 mod project, ignore
|
|
this skill entirely.
|
|
|
|
If you are unsure whether the current project is in scope, call \`get_status\`
|
|
first: it is cheap and reports the \`projectDir\` the index belongs to. Stop
|
|
using the index tools when:
|
|
|
|
- the state is \`no_index\`, or
|
|
- the reported \`projectDir\` does not match the workspace root you are working in.
|
|
|
|
In those cases read the files directly instead. Never present index results
|
|
from one project as if they belonged to another.
|
|
|
|
## When to use
|
|
|
|
Use this skill when you need any of the following:
|
|
|
|
- Find where an asset id is defined (GameObject, WeaponTemplate, Texture, etc.).
|
|
- Find which files/positions reference an asset id.
|
|
- Find which assets an asset references, and through which element/attribute.
|
|
- List assets of a particular type.
|
|
- Check whether a file is actually part of the active include graph (i.e. not a dead file).
|
|
- Resolve an Include source string.
|
|
- Look up a $DEFINE constant.
|
|
- Get the current index status and statistics.
|
|
|
|
Do not use full-text search over the XML tree when one of the MCP query tools
|
|
can answer the question directly.
|
|
|
|
## How to use
|
|
|
|
1. Call \`get_status\` first when you are unsure whether an index is available,
|
|
current, or belongs to the project you are working in.
|
|
2. Use narrow queries:
|
|
- \`find_asset(id, type?)\` for definition locations.
|
|
- \`find_references(id, type?)\` for incoming semantic references.
|
|
- \`get_asset_references(id, type?, depth?, targetTypes?)\` for outgoing
|
|
references (what this asset uses, and where that link is written).
|
|
- \`list_assets_by_type(type, prefix?, limit?)\` for browsing assets.
|
|
- \`is_file_active(path)\` to determine whether a file is included in an indexed stream.
|
|
- \`find_define(name)\` for $DEFINE constants.
|
|
- \`resolve_include(source)\` for Include source candidates.
|
|
3. Asset ids are case-insensitive.
|
|
4. If an id exists for multiple asset types, pass the type filter to avoid mixing definitions.
|
|
5. If the returned index state is \`stale\`, \`ready_xml\`, or \`building\`, treat
|
|
results as provisional.
|
|
6. Never read or dump the whole index snapshot; query narrowly.
|
|
|
|
## Following references with get_asset_references
|
|
|
|
Use \`get_asset_references\` to answer "which weapon / model / upgrade / die-object
|
|
does this asset actually use" without reading source first. It returns **edges**,
|
|
not a flat list, so provenance is preserved:
|
|
|
|
- \`from\` is the asset you asked about.
|
|
- \`to\` is the resolved definition (file + line), or null when unresolved.
|
|
- \`via.element\` / \`via.parent\` / \`via.attribute\` say exactly which XML
|
|
element and attribute created the link.
|
|
- \`source\` is the file/line where that link is written.
|
|
- \`definedIn\` is present when the link comes from an \`inheritFrom\` ancestor's
|
|
XML rather than from the asset's own file. Inheritance is walked at the same
|
|
depth, so a base asset's weapon slot configuration is reported together with
|
|
the derived asset's own modules.
|
|
|
|
Practical rules:
|
|
|
|
- Start with the default \`depth: 1\` (the queried asset only). Raise it to 2 or 3
|
|
only for a specific node you already decided to follow. The maximum is 3.
|
|
- Pass \`targetTypes\` to cut noise, e.g. \`["WeaponTemplate"]\`, or
|
|
\`["GameObject"]\` for \`CreateObjectDie\`-style die-object links.
|
|
- If \`truncated\` is true, read \`omittedByTargetType\` and narrow the query
|
|
(smaller \`targetTypes\`, lower \`depth\`) instead of blindly retrying.
|
|
- Call \`find_references\` in the opposite direction: it tells you who else would
|
|
be affected by a change.
|
|
- Merged/inherited *effective values* are not computed. When \`xai:joinAction\`
|
|
(\`Replace\` / \`Remove\`) appears in the merge path, open the file and confirm
|
|
the real result yourself.
|
|
`;
|
|
|
|
const QUERY_GUIDE_MD = `# RA3 Mod XML query tool reference
|
|
|
|
The MCP server exposes these tools:
|
|
|
|
- get_status()
|
|
- find_asset(id, type?)
|
|
- find_references(id, type?)
|
|
- get_asset_references(id, type?, depth?, targetTypes?, maxEdges?, includeUnresolved?)
|
|
- list_assets_by_type(type, prefix?, limit?)
|
|
- is_file_active(path)
|
|
- find_define(name)
|
|
- resolve_include(source)
|
|
- get_usage_guide()
|
|
|
|
## Result metadata
|
|
|
|
Every query result includes an \`index\` object:
|
|
|
|
\`\`\`json
|
|
{
|
|
"state": "ready",
|
|
"projectDir": "D:/Mods/Example",
|
|
"complete": true,
|
|
"stale": false
|
|
}
|
|
\`\`\`
|
|
|
|
Possible states:
|
|
|
|
- no_index: no index snapshot exists.
|
|
- building: a rebuild is in progress.
|
|
- ready_xml: XML assets are available but art assets may be incomplete.
|
|
- ready: complete index.
|
|
- stale: index may be outdated.
|
|
- error: last build failed.
|
|
|
|
Always check \`index.projectDir\`. If it does not match the project you are
|
|
working on, discard the result and read files directly instead.
|
|
|
|
## get_asset_references
|
|
|
|
Returns outgoing reference edges with full provenance:
|
|
|
|
\`\`\`json
|
|
{
|
|
"index": { "state": "ready", "projectDir": "D:/Mods/Example" },
|
|
"data": {
|
|
"roots": [{ "type": "GameObject", "id": "AthenaCannon", "file": "...", "line": 12 }],
|
|
"edges": [
|
|
{
|
|
"depth": 1,
|
|
"from": { "type": "GameObject", "id": "AthenaCannon" },
|
|
"to": { "type": "WeaponTemplate", "id": "AthenaCannonWeapon", "file": "...", "line": 88 },
|
|
"via": { "kind": "attribute", "element": "Weapon", "parent": "WeaponSlotHardpoint", "attribute": "Template" },
|
|
"source": { "file": "D:/Mods/Example/Data/Allied/Units/AthenaCannon.xml", "line": 40, "character": 24 }
|
|
}
|
|
],
|
|
"nodes": [],
|
|
"truncated": false,
|
|
"omittedByTargetType": {},
|
|
"warnings": []
|
|
}
|
|
}
|
|
\`\`\`
|
|
|
|
\`via.kind\` is one of \`attribute\`, \`content\` or \`inheritFrom\`. Edges carrying
|
|
\`definedIn\` come from an \`inheritFrom\` ancestor's XML.
|
|
|
|
This tool requires a **live** index (VS Code open with the project indexed).
|
|
It is not available from the on-disk snapshot because element context is not
|
|
stored there. When live is unavailable the tool returns an explicit error
|
|
instead of an empty result.
|
|
`;
|
|
|
|
export interface SkillInstallRecord {
|
|
/** Skill directory (the directory containing SKILL.md). */
|
|
path: string;
|
|
/** Extension version that installed/updated this copy. */
|
|
sourceVersion: string;
|
|
}
|
|
|
|
/**
|
|
* Writes a managed copy of the Skill into `targetDir` (the directory that
|
|
* should contain SKILL.md).
|
|
*/
|
|
export async function writeSkillTo(
|
|
targetDir: string,
|
|
sourceVersion: string,
|
|
): Promise<void> {
|
|
await mkdir(join(targetDir, "references"), { recursive: true });
|
|
await writeFile(join(targetDir, "SKILL.md"), SKILL_MD, "utf8");
|
|
await writeFile(
|
|
join(targetDir, "references", "query-guide.md"),
|
|
QUERY_GUIDE_MD,
|
|
"utf8",
|
|
);
|
|
const marker: SkillInstallRecord = {
|
|
path: targetDir,
|
|
sourceVersion,
|
|
};
|
|
await writeFile(
|
|
join(targetDir, SKILL_MARKER_FILE),
|
|
JSON.stringify(marker, null, 2),
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
/** Conventional ~/.agents/skills/<skill-name> path. */
|
|
export function agentsSkillsDirForUser(home = homedir()): string {
|
|
return join(home, ".agents", "skills", SKILL_NAME);
|
|
}
|
|
|
|
/** Conventional ~/.claude/skills/<skill-name> path (Claude Code). */
|
|
export function claudeSkillsDirForUser(home = homedir()): string {
|
|
return join(home, ".claude", "skills", SKILL_NAME);
|
|
}
|
|
|
|
/** Path to the managed-install record under the agent home. */
|
|
export function skillInstallRecordPath(agentHome = defaultAgentHome()): string {
|
|
return join(agentHome, "skill-install.json");
|
|
}
|
|
|
|
/** Reads the managed skill install record, or returns an empty list. */
|
|
export async function readSkillInstallRecord(
|
|
agentHome = defaultAgentHome(),
|
|
): Promise<SkillInstallRecord[]> {
|
|
try {
|
|
const text = await readFile(skillInstallRecordPath(agentHome), "utf8");
|
|
const parsed = JSON.parse(text) as { installed?: SkillInstallRecord[] };
|
|
return Array.isArray(parsed.installed) ? parsed.installed : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** Writes the managed skill install record. */
|
|
export async function writeSkillInstallRecord(
|
|
installed: SkillInstallRecord[],
|
|
agentHome = defaultAgentHome(),
|
|
): Promise<void> {
|
|
const file = skillInstallRecordPath(agentHome);
|
|
await mkdir(dirname(file), { recursive: true });
|
|
await writeFile(
|
|
file,
|
|
JSON.stringify({ installed }, null, 2),
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Installs the Skill into several directories and records each managed copy.
|
|
* Returns the directories successfully written.
|
|
*/
|
|
export async function installSkillToDirectories(
|
|
directories: string[],
|
|
sourceVersion: string,
|
|
agentHome = defaultAgentHome(),
|
|
): Promise<string[]> {
|
|
const installed = await readSkillInstallRecord(agentHome);
|
|
const succeeded: string[] = [];
|
|
for (const dir of directories) {
|
|
try {
|
|
await writeSkillTo(dir, sourceVersion);
|
|
if (!installed.some((r) => r.path === dir)) {
|
|
installed.push({ path: dir, sourceVersion });
|
|
} else {
|
|
const record = installed.find((r) => r.path === dir);
|
|
if (record) record.sourceVersion = sourceVersion;
|
|
}
|
|
succeeded.push(dir);
|
|
} catch {
|
|
// Keep going; caller can surface per-directory failures.
|
|
}
|
|
}
|
|
await writeSkillInstallRecord(installed, agentHome);
|
|
return succeeded;
|
|
}
|
|
|
|
/** Removes one managed Skill directory and its install record entry. */
|
|
export async function uninstallSkillFromDirectory(
|
|
directory: string,
|
|
agentHome = defaultAgentHome(),
|
|
): Promise<void> {
|
|
await rm(directory, { recursive: true, force: true });
|
|
const installed = (await readSkillInstallRecord(agentHome)).filter(
|
|
(r) => r.path !== directory,
|
|
);
|
|
await writeSkillInstallRecord(installed, agentHome);
|
|
}
|
|
|
|
/** Re-writes every recorded Skill copy with the current extension version. */
|
|
export async function syncInstalledSkills(
|
|
sourceVersion: string,
|
|
agentHome = defaultAgentHome(),
|
|
): Promise<SkillInstallRecord[]> {
|
|
const installed = await readSkillInstallRecord(agentHome);
|
|
const synced: SkillInstallRecord[] = [];
|
|
for (const record of installed) {
|
|
try {
|
|
await writeSkillTo(record.path, sourceVersion);
|
|
synced.push({ path: record.path, sourceVersion });
|
|
} catch {
|
|
// Skip unreadable/missing targets; the record will be cleaned below.
|
|
}
|
|
}
|
|
await writeSkillInstallRecord(synced, agentHome);
|
|
return synced;
|
|
}
|