Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 141 additions & 14 deletions lib/adapter-transforms.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@
* @license MIT
*/

const path = require('path');
const discovery = require('./discovery');

function transformBodyForOpenCode(content, repoRoot) {
const plugins = discovery.discoverPlugins(repoRoot);
content = pointVersionedPathsAtInstallLayout(content, undefined, plugins);
content = content.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, '${PLUGIN_ROOT}');
content = content.replace(/\$CLAUDE_PLUGIN_ROOT/g, '$PLUGIN_ROOT');

Expand Down Expand Up @@ -43,7 +46,6 @@ function transformBodyForOpenCode(content, repoRoot) {
return '.opencode`';
});

const plugins = discovery.discoverPlugins(repoRoot);
if (plugins.length > 0) {
const pluginNames = plugins.join('|');
content = content.replace(new RegExp('`(' + pluginNames + '):([a-z-]+)`', 'g'), '`$2`');
Expand Down Expand Up @@ -125,7 +127,10 @@ function transformBodyForOpenCode(content, repoRoot) {
content = content.replace(/(?:const|let|var)\s+\{?[^}=\n]+\}?\s*=\s*require\s*\([^)]+\);?/g, '');
content = content.replace(/require\s*\(['"][^'"]+['"]\)/g, '');

if (content.includes('agent')) {
// An install path (`<home>/.agentsys/plugins/...`) is not a mention of
// agents, even when the home directory has "agent" in it: drop those paths
// before the check.
if (/agent(?!sys)/.test(content.replace(/[^\s`'"()]*\.agentsys[\\/][^\s`'"()]*/g, ''))) {
const note = `
> **OpenCode Note**: Invoke agents using \`@agent-name\` syntax.
> Available agents: task-discoverer, exploration-agent, planning-agent,
Expand Down Expand Up @@ -254,7 +259,25 @@ function transformAgentFrontmatterForOpenCode(content, options) {
);
}

function transformSkillBodyForOpenCode(content, repoRoot) {
/**
* Transform one markdown file of a skill for OpenCode.
*
* Same as transformBodyForOpenCode. With `pluginInstallPath`, the plugin-root
* wording and versioned-cache paths are pointed at the install path first,
* since OpenCode loads the skill from `~/.config/opencode/skills/<name>/`.
*
* @param {string} content - Markdown content of one file in the skill
* @param {string} [repoRoot] - Root whose plugins/ holds the plugins
* @param {Object} [options]
* @param {string} [options.pluginInstallPath] - Absolute path to plugin install dir
* @returns {string}
*/
function transformSkillBodyForOpenCode(content, repoRoot, options = {}) {
const { pluginInstallPath } = options;
if (pluginInstallPath) {
content = namePluginRoot(content, pluginInstallPath);
content = pointVersionedPathsAtInstallLayout(content, pluginInstallPath, discovery.discoverPlugins(repoRoot));
}
return transformBodyForOpenCode(content, repoRoot);
}

Expand All @@ -276,6 +299,8 @@ function transformForCodex(content, options) {
content = `---\nname: ${skillName}\ndescription: ${yamlDescription}\n---\n\n${content}`;
}

content = pointVersionedPathsAtInstallLayout(content, pluginInstallPath);

// Transform PLUGIN_ROOT to actual installed path (or placeholder) for Codex
content = content.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, pluginInstallPath);
content = content.replace(/\$CLAUDE_PLUGIN_ROOT/g, pluginInstallPath);
Expand Down Expand Up @@ -363,7 +388,9 @@ function transformRuleForCursor(content, options) {
* Transform skill content for Cursor.
*
* Minimal transform - Cursor reads SKILL.md frontmatter natively so we
* preserve it. Only replaces PLUGIN_ROOT paths and strips namespace prefixes.
* preserve it. Points plugin paths at the install path (pointSkillAtInstallPath)
* and strips namespace prefixes. Apply it to every markdown file in the skill
* directory.
*
* @param {string} content - Source SKILL.md content
* @param {Object} options
Expand All @@ -373,11 +400,7 @@ function transformRuleForCursor(content, options) {
function transformSkillForCursor(content, options) {
const { pluginInstallPath } = options;

// Replace PLUGIN_ROOT paths with actual install path
content = content.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, () => pluginInstallPath);
content = content.replace(/\$CLAUDE_PLUGIN_ROOT/g, () => pluginInstallPath);
content = content.replace(/\$\{PLUGIN_ROOT\}/g, () => pluginInstallPath);
content = content.replace(/\$PLUGIN_ROOT/g, () => pluginInstallPath);
content = pointSkillAtInstallPath(content, pluginInstallPath);

// Strip plugin namespacing (e.g. next-task:agent-name -> agent-name)
content = content.replace(/(?:next-task|deslop|ship|sync-docs|audit-project|enhance|perf|repo-map|drift-detect|consult|debate|learn|web-ctl):([a-z][a-z0-9-]*)/g, '$1');
Expand All @@ -404,6 +427,8 @@ function transformCommandForCursor(content, options) {
content = content.replace(/^---\n[\s\S]*?\n---\n?/, '');
}

content = pointVersionedPathsAtInstallLayout(content, pluginInstallPath);

// Replace PLUGIN_ROOT paths with actual install path
content = content.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, () => pluginInstallPath);
content = content.replace(/\$CLAUDE_PLUGIN_ROOT/g, () => pluginInstallPath);
Expand All @@ -429,11 +454,111 @@ function transformCommandForCursor(content, options) {
return content;
}

/**
* Point plugin paths written for Claude Code's versioned plugin cache at the
* agentsys install layout that the other platforms use.
*
* Claude Code caches a plugin at `<cache>/<plugin>/<version>/`, so plugin
* sources reach a sibling plugin through `${CLAUDE_PLUGIN_ROOT}/../../<other>/*\/`
* and fall back to globs such as `**\/<plugin>/*\/skills/...`. The agentsys
* installer puts each plugin at `~/.agentsys/plugins/<plugin>/` with no version
* directory, so the sibling path becomes `<plugins dir>/<other>/` and the glob
* gets `**`, which matches with or without a version directory.
*
* Only globs on a known plugin name change: the plugin itself, the plugins
* installed next to it, and `pluginNames`. A glob such as `**\/src/*\/index.ts`
* keeps its single directory level.
*
* @param {string} content
* @param {string} [pluginInstallPath] - Install path of the plugin the content belongs to (`<root>/plugins/<plugin>`)
* @param {string[]} [pluginNames] - Other plugin names, such as discoverPlugins(repoRoot)
* @returns {string}
*/
function pointVersionedPathsAtInstallLayout(content, pluginInstallPath, pluginNames = []) {
const known = new Set(pluginNames);
if (pluginInstallPath) {
known.add(path.basename(pluginInstallPath));
const pluginsDir = path.dirname(pluginInstallPath);
if (pluginsDir !== '.') {
content = content.replace(
/(?:\$\{CLAUDE_PLUGIN_ROOT\}|\$CLAUDE_PLUGIN_ROOT|\$\{PLUGIN_ROOT\}|\$PLUGIN_ROOT)\/\.\.\/\.\.\/([A-Za-z0-9_-]+)\/\*\//g,
(match, plugin) => `${pluginsDir}/${plugin}/`
);
for (const name of discovery.discoverPlugins(path.dirname(pluginsDir))) known.add(name);
}
}
return content.replace(/\*\*\/([A-Za-z0-9_-]+)\/\*\//g, (match, name) => (known.has(name) ? `**/${name}/**/` : match));
}

/**
* Name the plugin root where a skill locates it relative to itself.
*
* Inside a plugin, "two directories up from this skill" is the plugin root.
* Other platforms load skills from their own skills directory (for example
* `~/.kiro/skills/<name>/`, where two directories up is `~/.kiro`), so the
* phrase is replaced with the plugin's install path.
*
* @param {string} content
* @param {string} [pluginInstallPath]
* @returns {string}
*/
function namePluginRoot(content, pluginInstallPath) {
if (!pluginInstallPath) return content;
return content.replace(
/two directories up from (?:this|the) skill(?: \(`\$\{CLAUDE_PLUGIN_ROOT\}` in Claude Code\))?/g,
() => `\`${pluginInstallPath}\``
);
}

/**
* Point every way a skill file locates its plugin at the plugin's install path:
* plugin-root wording, versioned-cache paths, and PLUGIN_ROOT variables.
*
* @param {string} content
* @param {string} pluginInstallPath
* @returns {string}
*/
function pointSkillAtInstallPath(content, pluginInstallPath) {
content = namePluginRoot(content, pluginInstallPath);
content = pointVersionedPathsAtInstallLayout(content, pluginInstallPath);

content = content.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, () => pluginInstallPath);
content = content.replace(/\$CLAUDE_PLUGIN_ROOT/g, () => pluginInstallPath);
content = content.replace(/\$\{PLUGIN_ROOT\}/g, () => pluginInstallPath);
content = content.replace(/\$PLUGIN_ROOT/g, () => pluginInstallPath);
return content;
}

/**
* Transform skill content for Codex.
*
* Codex reads SKILL.md frontmatter natively, so it is preserved. Points plugin
* paths at the install path (pointSkillAtInstallPath) and maps
* AskUserQuestion to Codex's request_user_input, as transformForCodex does for
* commands. Apply it to every markdown file in the skill directory.
*
* @param {string} content - Markdown content of one file in the skill
* @param {Object} options
* @param {string} options.pluginInstallPath - Absolute path to plugin install dir
* @returns {string} Transformed skill content
*/
function transformSkillForCodex(content, options) {
const { pluginInstallPath } = options;

content = pointSkillAtInstallPath(content, pluginInstallPath);
content = content.replace(/AskUserQuestion/g, 'request_user_input');
content = content.replace(/^[ \t]*multiSelect:.*\n?/gm, '');

return content;
}

/**
* Transform skill content for Kiro.
*
* Minimal transform - Kiro reads standard SKILL.md format natively so we
* preserve it. Only replaces PLUGIN_ROOT paths and strips namespace prefixes.
* preserve it. Points plugin paths at the install path
* (pointSkillAtInstallPath) and strips namespace prefixes. Apply it to every
* markdown file in the skill directory.
*
* @param {string} content - Source SKILL.md content
* @param {Object} options
Expand All @@ -443,10 +568,7 @@ function transformCommandForCursor(content, options) {
function transformSkillForKiro(content, options) {
const { pluginInstallPath } = options;

content = content.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, () => pluginInstallPath);
content = content.replace(/\$CLAUDE_PLUGIN_ROOT/g, () => pluginInstallPath);
content = content.replace(/\$\{PLUGIN_ROOT\}/g, () => pluginInstallPath);
content = content.replace(/\$PLUGIN_ROOT/g, () => pluginInstallPath);
content = pointSkillAtInstallPath(content, pluginInstallPath);

content = content.replace(/(?:next-task|deslop|ship|sync-docs|audit-project|enhance|perf|repo-map|drift-detect|consult|debate|learn|web-ctl):([a-z][a-z0-9-]*)/g, '$1');

Expand Down Expand Up @@ -476,6 +598,8 @@ function transformCommandForKiro(content, options) {

content = frontmatter + content;

content = pointVersionedPathsAtInstallLayout(content, pluginInstallPath);

content = content.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, () => pluginInstallPath);
content = content.replace(/\$CLAUDE_PLUGIN_ROOT/g, () => pluginInstallPath);
content = content.replace(/\$\{PLUGIN_ROOT\}/g, () => pluginInstallPath);
Expand Down Expand Up @@ -587,6 +711,8 @@ function transformAgentForKiro(content, options) {
}
}

body = pointVersionedPathsAtInstallLayout(body, pluginInstallPath);

if (pluginInstallPath) {
body = body.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, () => pluginInstallPath);
body = body.replace(/\$CLAUDE_PLUGIN_ROOT/g, () => pluginInstallPath);
Expand Down Expand Up @@ -660,6 +786,7 @@ module.exports = {
transformAgentFrontmatterForOpenCode,
transformSkillBodyForOpenCode,
transformForCodex,
transformSkillForCodex,
transformRuleForCursor,
transformSkillForCursor,
transformCommandForCursor,
Expand Down
Loading
Loading