feat: fix skill transformation pipeline across all targets (#334)
This commit is contained in:
@@ -106,11 +106,15 @@ function convertCommandToSkill(
|
||||
export function transformContentForCopilot(body: string): string {
|
||||
let result = body
|
||||
|
||||
// 1. Transform Task agent calls
|
||||
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm
|
||||
// 1. Transform Task agent calls (supports namespaced names like compound-engineering:research:agent-name)
|
||||
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9:-]*)\(([^)]*)\)/gm
|
||||
result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
|
||||
const skillName = normalizeName(agentName)
|
||||
return `${prefix}Use the ${skillName} skill to: ${args.trim()}`
|
||||
const finalSegment = agentName.includes(":") ? agentName.split(":").pop()! : agentName
|
||||
const skillName = normalizeName(finalSegment)
|
||||
const trimmedArgs = args.trim()
|
||||
return trimmedArgs
|
||||
? `${prefix}Use the ${skillName} skill to: ${trimmedArgs}`
|
||||
: `${prefix}Use the ${skillName} skill`
|
||||
})
|
||||
|
||||
// 2. Transform slash command references (replace colons with hyphens)
|
||||
|
||||
@@ -119,15 +119,19 @@ function mapAgentTools(agent: ClaudeAgent): string[] | undefined {
|
||||
* 2. Task agent calls: Task agent-name(args) → Task agent-name: args
|
||||
* 3. Agent references: @agent-name → the agent-name droid
|
||||
*/
|
||||
function transformContentForDroid(body: string): string {
|
||||
export function transformContentForDroid(body: string): string {
|
||||
let result = body
|
||||
|
||||
// 1. Transform Task agent calls
|
||||
// Match: Task repo-research-analyst(feature_description)
|
||||
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm
|
||||
// Match: Task repo-research-analyst(args) or Task compound-engineering:research:repo-research-analyst(args)
|
||||
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9:-]*)\(([^)]*)\)/gm
|
||||
result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
|
||||
const name = normalizeName(agentName)
|
||||
return `${prefix}Task ${name}: ${args.trim()}`
|
||||
const finalSegment = agentName.includes(":") ? agentName.split(":").pop()! : agentName
|
||||
const name = normalizeName(finalSegment)
|
||||
const trimmedArgs = args.trim()
|
||||
return trimmedArgs
|
||||
? `${prefix}Task ${name}: ${trimmedArgs}`
|
||||
: `${prefix}Task ${name}`
|
||||
})
|
||||
|
||||
// 2. Transform slash command references
|
||||
|
||||
@@ -86,11 +86,15 @@ function convertCommand(command: ClaudeCommand, usedNames: Set<string>): GeminiC
|
||||
export function transformContentForGemini(body: string): string {
|
||||
let result = body
|
||||
|
||||
// 1. Transform Task agent calls
|
||||
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm
|
||||
// 1. Transform Task agent calls (supports namespaced names like compound-engineering:research:agent-name)
|
||||
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9:-]*)\(([^)]*)\)/gm
|
||||
result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
|
||||
const skillName = normalizeName(agentName)
|
||||
return `${prefix}Use the ${skillName} skill to: ${args.trim()}`
|
||||
const finalSegment = agentName.includes(":") ? agentName.split(":").pop()! : agentName
|
||||
const skillName = normalizeName(finalSegment)
|
||||
const trimmedArgs = args.trim()
|
||||
return trimmedArgs
|
||||
? `${prefix}Use the ${skillName} skill to: ${trimmedArgs}`
|
||||
: `${prefix}Use the ${skillName} skill`
|
||||
})
|
||||
|
||||
// 2. Rewrite .claude/ paths to .gemini/
|
||||
|
||||
@@ -135,10 +135,15 @@ function convertCommandToSkill(
|
||||
export function transformContentForKiro(body: string, knownAgentNames: string[] = []): string {
|
||||
let result = body
|
||||
|
||||
// 1. Transform Task agent calls
|
||||
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm
|
||||
// 1. Transform Task agent calls (supports namespaced names like compound-engineering:research:agent-name)
|
||||
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9:-]*)\(([^)]*)\)/gm
|
||||
result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
|
||||
return `${prefix}Use the use_subagent tool to delegate to the ${normalizeName(agentName)} agent: ${args.trim()}`
|
||||
const finalSegment = agentName.includes(":") ? agentName.split(":").pop()! : agentName
|
||||
const agentRef = normalizeName(finalSegment)
|
||||
const trimmedArgs = args.trim()
|
||||
return trimmedArgs
|
||||
? `${prefix}Use the use_subagent tool to delegate to the ${agentRef} agent: ${trimmedArgs}`
|
||||
: `${prefix}Use the use_subagent tool to delegate to the ${agentRef} agent`
|
||||
})
|
||||
|
||||
// 2. Rewrite .claude/ paths to .kiro/ (with word-boundary-like lookbehind)
|
||||
|
||||
@@ -90,16 +90,19 @@ function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): PiGeneratedSk
|
||||
}
|
||||
}
|
||||
|
||||
function transformContentForPi(body: string): string {
|
||||
export function transformContentForPi(body: string): string {
|
||||
let result = body
|
||||
|
||||
// Task repo-research-analyst(feature_description)
|
||||
// Task repo-research-analyst(feature_description) or Task compound-engineering:research:repo-research-analyst(args)
|
||||
// -> Run subagent with agent="repo-research-analyst" and task="feature_description"
|
||||
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm
|
||||
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9:-]*)\(([^)]*)\)/gm
|
||||
result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
|
||||
const skillName = normalizeName(agentName)
|
||||
const finalSegment = agentName.includes(":") ? agentName.split(":").pop()! : agentName
|
||||
const skillName = normalizeName(finalSegment)
|
||||
const trimmedArgs = args.trim().replace(/\s+/g, " ")
|
||||
return `${prefix}Run subagent with agent=\"${skillName}\" and task=\"${trimmedArgs}\".`
|
||||
return trimmedArgs
|
||||
? `${prefix}Run subagent with agent=\"${skillName}\" and task=\"${trimmedArgs}\".`
|
||||
: `${prefix}Run subagent with agent=\"${skillName}\".`
|
||||
})
|
||||
|
||||
// Claude-specific tool references
|
||||
|
||||
@@ -122,10 +122,15 @@ export function transformContentForWindsurf(body: string, knownAgentNames: strin
|
||||
// In Windsurf, @skill-name is the native invocation syntax for skills.
|
||||
// Since agents are now mapped to skills, @agent-name already works correctly.
|
||||
|
||||
// 4. Transform Task agent calls to skill references
|
||||
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm
|
||||
// 4. Transform Task agent calls to skill references (supports namespaced names)
|
||||
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9:-]*)\(([^)]*)\)/gm
|
||||
result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
|
||||
return `${prefix}Use the @${normalizeName(agentName)} skill: ${args.trim()}`
|
||||
const finalSegment = agentName.includes(":") ? agentName.split(":").pop()! : agentName
|
||||
const skillRef = normalizeName(finalSegment)
|
||||
const trimmedArgs = args.trim()
|
||||
return trimmedArgs
|
||||
? `${prefix}Use the @${skillRef} skill: ${trimmedArgs}`
|
||||
: `${prefix}Use the @${skillRef} skill`
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import { backupFile, ensureDir, readText, writeText } from "../utils/files"
|
||||
import { backupFile, copySkillDir, ensureDir, writeText } from "../utils/files"
|
||||
import type { CodexBundle } from "../types/codex"
|
||||
import type { ClaudeMcpServer } from "../types/claude"
|
||||
import { transformContentForCodex } from "../utils/codex-content"
|
||||
@@ -19,10 +18,12 @@ export async function writeCodexBundle(outputRoot: string, bundle: CodexBundle):
|
||||
if (bundle.skillDirs.length > 0) {
|
||||
const skillsRoot = path.join(codexRoot, "skills")
|
||||
for (const skill of bundle.skillDirs) {
|
||||
await copyCodexSkillDir(
|
||||
await copySkillDir(
|
||||
skill.sourceDir,
|
||||
path.join(skillsRoot, skill.name),
|
||||
bundle.invocationTargets,
|
||||
(content) => transformContentForCodex(content, bundle.invocationTargets, {
|
||||
unknownSlashBehavior: "preserve",
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -45,41 +46,6 @@ export async function writeCodexBundle(outputRoot: string, bundle: CodexBundle):
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCodexSkillDir(
|
||||
sourceDir: string,
|
||||
targetDir: string,
|
||||
invocationTargets?: CodexBundle["invocationTargets"],
|
||||
): Promise<void> {
|
||||
await ensureDir(targetDir)
|
||||
const entries = await fs.readdir(sourceDir, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
const sourcePath = path.join(sourceDir, entry.name)
|
||||
const targetPath = path.join(targetDir, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await copyCodexSkillDir(sourcePath, targetPath, invocationTargets)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!entry.isFile()) continue
|
||||
|
||||
if (entry.name === "SKILL.md") {
|
||||
const content = await readText(sourcePath)
|
||||
await writeText(
|
||||
targetPath,
|
||||
transformContentForCodex(content, invocationTargets, {
|
||||
unknownSlashBehavior: "preserve",
|
||||
}),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
await ensureDir(path.dirname(targetPath))
|
||||
await fs.copyFile(sourcePath, targetPath)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCodexRoot(outputRoot: string): string {
|
||||
return path.basename(outputRoot) === ".codex" ? outputRoot : path.join(outputRoot, ".codex")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "path"
|
||||
import { backupFile, copyDir, ensureDir, writeJson, writeText } from "../utils/files"
|
||||
import { backupFile, copySkillDir, ensureDir, writeJson, writeText } from "../utils/files"
|
||||
import { transformContentForCopilot } from "../converters/claude-to-copilot"
|
||||
import type { CopilotBundle } from "../types/copilot"
|
||||
|
||||
export async function writeCopilotBundle(outputRoot: string, bundle: CopilotBundle): Promise<void> {
|
||||
@@ -23,7 +24,7 @@ export async function writeCopilotBundle(outputRoot: string, bundle: CopilotBund
|
||||
if (bundle.skillDirs.length > 0) {
|
||||
const skillsDir = path.join(paths.githubDir, "skills")
|
||||
for (const skill of bundle.skillDirs) {
|
||||
await copyDir(skill.sourceDir, path.join(skillsDir, skill.name))
|
||||
await copySkillDir(skill.sourceDir, path.join(skillsDir, skill.name), transformContentForCopilot)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "path"
|
||||
import { copyDir, ensureDir, resolveCommandPath, writeText } from "../utils/files"
|
||||
import { copySkillDir, ensureDir, resolveCommandPath, writeText } from "../utils/files"
|
||||
import { transformContentForDroid } from "../converters/claude-to-droid"
|
||||
import type { DroidBundle } from "../types/droid"
|
||||
|
||||
export async function writeDroidBundle(outputRoot: string, bundle: DroidBundle): Promise<void> {
|
||||
@@ -24,7 +25,7 @@ export async function writeDroidBundle(outputRoot: string, bundle: DroidBundle):
|
||||
if (bundle.skillDirs.length > 0) {
|
||||
await ensureDir(paths.skillsDir)
|
||||
for (const skill of bundle.skillDirs) {
|
||||
await copyDir(skill.sourceDir, path.join(paths.skillsDir, skill.name))
|
||||
await copySkillDir(skill.sourceDir, path.join(paths.skillsDir, skill.name), transformContentForDroid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "path"
|
||||
import { backupFile, copyDir, ensureDir, pathExists, readJson, resolveCommandPath, writeJson, writeText } from "../utils/files"
|
||||
import { backupFile, copySkillDir, ensureDir, pathExists, readJson, resolveCommandPath, writeJson, writeText } from "../utils/files"
|
||||
import { transformContentForGemini } from "../converters/claude-to-gemini"
|
||||
import type { GeminiBundle } from "../types/gemini"
|
||||
|
||||
export async function writeGeminiBundle(outputRoot: string, bundle: GeminiBundle): Promise<void> {
|
||||
@@ -14,7 +15,7 @@ export async function writeGeminiBundle(outputRoot: string, bundle: GeminiBundle
|
||||
|
||||
if (bundle.skillDirs.length > 0) {
|
||||
for (const skill of bundle.skillDirs) {
|
||||
await copyDir(skill.sourceDir, path.join(paths.skillsDir, skill.name))
|
||||
await copySkillDir(skill.sourceDir, path.join(paths.skillsDir, skill.name), transformContentForGemini)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "path"
|
||||
import { backupFile, copyDir, ensureDir, pathExists, readJson, writeJson, writeText } from "../utils/files"
|
||||
import { backupFile, copySkillDir, ensureDir, pathExists, readJson, writeJson, writeText } from "../utils/files"
|
||||
import { transformContentForKiro } from "../converters/claude-to-kiro"
|
||||
import type { KiroBundle } from "../types/kiro"
|
||||
|
||||
export async function writeKiroBundle(outputRoot: string, bundle: KiroBundle): Promise<void> {
|
||||
@@ -50,7 +51,10 @@ export async function writeKiroBundle(outputRoot: string, bundle: KiroBundle): P
|
||||
continue
|
||||
}
|
||||
|
||||
await copyDir(skill.sourceDir, destDir)
|
||||
const knownAgentNames = bundle.agents.map((a) => a.name)
|
||||
await copySkillDir(skill.sourceDir, destDir, (content) =>
|
||||
transformContentForKiro(content, knownAgentNames),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import path from "path"
|
||||
import {
|
||||
backupFile,
|
||||
copyDir,
|
||||
copySkillDir,
|
||||
ensureDir,
|
||||
pathExists,
|
||||
readText,
|
||||
writeJson,
|
||||
writeText,
|
||||
} from "../utils/files"
|
||||
import { transformContentForPi } from "../converters/claude-to-pi"
|
||||
import type { PiBundle } from "../types/pi"
|
||||
|
||||
const PI_AGENTS_BLOCK_START = "<!-- BEGIN COMPOUND PI TOOL MAP -->"
|
||||
@@ -37,7 +38,7 @@ export async function writePiBundle(outputRoot: string, bundle: PiBundle): Promi
|
||||
}
|
||||
|
||||
for (const skill of bundle.skillDirs) {
|
||||
await copyDir(skill.sourceDir, path.join(paths.skillsDir, skill.name))
|
||||
await copySkillDir(skill.sourceDir, path.join(paths.skillsDir, skill.name), transformContentForPi)
|
||||
}
|
||||
|
||||
for (const skill of bundle.generatedSkills) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "path"
|
||||
import { backupFile, copyDir, ensureDir, pathExists, readJson, writeJsonSecure, writeText } from "../utils/files"
|
||||
import { backupFile, copySkillDir, ensureDir, pathExists, readJson, writeJsonSecure, writeText } from "../utils/files"
|
||||
import { formatFrontmatter } from "../utils/frontmatter"
|
||||
import { transformContentForWindsurf } from "../converters/claude-to-windsurf"
|
||||
import type { WindsurfBundle } from "../types/windsurf"
|
||||
import type { TargetScope } from "./index"
|
||||
|
||||
@@ -58,7 +59,10 @@ export async function writeWindsurfBundle(outputRoot: string, bundle: WindsurfBu
|
||||
continue
|
||||
}
|
||||
|
||||
await copyDir(skill.sourceDir, destDir)
|
||||
const knownAgentNames = bundle.agentSkills.map((s) => s.name)
|
||||
await copySkillDir(skill.sourceDir, destDir, (content) =>
|
||||
transformContentForWindsurf(content, knownAgentNames),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,14 +29,16 @@ export function transformContentForCodex(
|
||||
const skillTargets = targets?.skillTargets ?? {}
|
||||
const unknownSlashBehavior = options.unknownSlashBehavior ?? "prompt"
|
||||
|
||||
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9:-]*)\(([^)]+)\)/gm
|
||||
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9:-]*)\(([^)]*)\)/gm
|
||||
result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
|
||||
// For namespaced calls like "compound-engineering:research:repo-research-analyst",
|
||||
// use only the final segment as the skill name.
|
||||
const finalSegment = agentName.includes(":") ? agentName.split(":").pop()! : agentName
|
||||
const skillName = normalizeCodexName(finalSegment)
|
||||
const trimmedArgs = args.trim()
|
||||
return `${prefix}Use the $${skillName} skill to: ${trimmedArgs}`
|
||||
return trimmedArgs
|
||||
? `${prefix}Use the $${skillName} skill to: ${trimmedArgs}`
|
||||
: `${prefix}Use the $${skillName} skill`
|
||||
})
|
||||
|
||||
const slashCommandPattern = /(?<![:\w])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
|
||||
|
||||
@@ -104,3 +104,34 @@ export async function copyDir(sourceDir: string, targetDir: string): Promise<voi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a skill directory, optionally transforming SKILL.md content.
|
||||
* All other files are copied verbatim. Used by target writers to apply
|
||||
* platform-specific content transforms to pass-through skills.
|
||||
*/
|
||||
export async function copySkillDir(
|
||||
sourceDir: string,
|
||||
targetDir: string,
|
||||
transformSkillContent?: (content: string) => string,
|
||||
): Promise<void> {
|
||||
await ensureDir(targetDir)
|
||||
const entries = await fs.readdir(sourceDir, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
const sourcePath = path.join(sourceDir, entry.name)
|
||||
const targetPath = path.join(targetDir, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await copySkillDir(sourcePath, targetPath, transformSkillContent)
|
||||
} else if (entry.isFile()) {
|
||||
if (entry.name === "SKILL.md" && transformSkillContent) {
|
||||
const content = await readText(sourcePath)
|
||||
await writeText(targetPath, transformSkillContent(content))
|
||||
} else {
|
||||
await ensureDir(path.dirname(targetPath))
|
||||
await fs.copyFile(sourcePath, targetPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user