diff --git a/src/converters/claude-to-copilot.ts b/src/converters/claude-to-copilot.ts index 22d4aebc..594f3a23 100644 --- a/src/converters/claude-to-copilot.ts +++ b/src/converters/claude-to-copilot.ts @@ -50,8 +50,7 @@ function convertAgent(agent: ClaudeAgent, usedNames: Set): CopilotAgent const frontmatter: Record = { description, - tools: ["*"], - infer: true, + "user-invocable": true, } let body = transformContentForCopilot(agent.body.trim()) @@ -123,12 +122,20 @@ export function transformContentForCopilot(body: string): string { return `/${normalized}` }) - // 3. Rewrite .claude/ paths to .github/ and ~/.claude/ to ~/.copilot/ + // 3. Replace plugin colon-namespaced command references (e.g. ce:plan → ce-plan, ce:* → ce-*) + // Scoped to `ce:` prefix which is the compound-engineering plugin namespace. + // The lookbehind ensures we only match at word boundaries or after common delimiters, + // avoiding corruption of URLs, code identifiers, or unrelated namespace:value patterns. + // Note: / is intentionally excluded — slash commands are already handled in step 2. + // Captures colons in the name segment so multi-colon refs like ce:work:beta → ce-work-beta. + result = result.replace(/(?<=^|[\s,.()`'"])ce:([a-z*][a-z0-9_*:-]*)/gim, (_, name: string) => `ce-${name.replace(/:/g, "-")}`) + + // 4. Rewrite .claude/ paths to .github/ and ~/.claude/ to ~/.copilot/ result = result .replace(/~\/\.claude\//g, "~/.copilot/") .replace(/\.claude\//g, ".github/") - // 4. Transform @agent-name references + // 5. Transform @agent-name references const agentRefPattern = /@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))/gi result = result.replace(agentRefPattern, (_match, agentName: string) => { diff --git a/tests/copilot-converter.test.ts b/tests/copilot-converter.test.ts index 9c88fa93..6ba260de 100644 --- a/tests/copilot-converter.test.ts +++ b/tests/copilot-converter.test.ts @@ -55,8 +55,9 @@ describe("convertClaudeToCopilot", () => { const parsed = parseFrontmatter(agent.content) expect(parsed.data.description).toBe("Security-focused code review agent") - expect(parsed.data.tools).toEqual(["*"]) - expect(parsed.data.infer).toBe(true) + expect(parsed.data.tools).toBeUndefined() + expect(parsed.data.infer).toBeUndefined() + expect(parsed.data["user-invocable"]).toBe(true) expect(parsed.body).toContain("Capabilities") expect(parsed.body).toContain("Threat modeling") expect(parsed.body).toContain("Focus on vulnerabilities.") @@ -109,20 +110,21 @@ describe("convertClaudeToCopilot", () => { expect(parsed.data.model).toBeUndefined() }) - test("agent tools defaults to [*]", () => { + test("agent omits tools (Copilot uses defaults when omitted)", () => { const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) const parsed = parseFrontmatter(bundle.agents[0].content) - expect(parsed.data.tools).toEqual(["*"]) + expect(parsed.data.tools).toBeUndefined() }) - test("agent infer defaults to true", () => { + test("agent replaces infer with user-invocable", () => { const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) const parsed = parseFrontmatter(bundle.agents[0].content) - expect(parsed.data.infer).toBe(true) + expect(parsed.data.infer).toBeUndefined() + expect(parsed.data["user-invocable"]).toBe(true) }) test("warns when agent body exceeds 30k characters", () => { - const warnSpy = spyOn(console, "warn").mockImplementation(() => {}) + const warnSpy = spyOn(console, "warn").mockImplementation(() => { }) const plugin: ClaudePlugin = { ...fixturePlugin, @@ -341,7 +343,7 @@ describe("convertClaudeToCopilot", () => { }) test("warns when hooks are present", () => { - const warnSpy = spyOn(console, "warn").mockImplementation(() => {}) + const warnSpy = spyOn(console, "warn").mockImplementation(() => { }) const plugin: ClaudePlugin = { ...fixturePlugin, @@ -364,7 +366,7 @@ describe("convertClaudeToCopilot", () => { }) test("no warning when hooks are absent", () => { - const warnSpy = spyOn(console, "warn").mockImplementation(() => {}) + const warnSpy = spyOn(console, "warn").mockImplementation(() => { }) convertClaudeToCopilot(fixturePlugin, defaultOptions) expect(warnSpy).not.toHaveBeenCalled() @@ -468,6 +470,35 @@ Task best-practices-researcher(topic)` expect(result).not.toContain("@security-sentinel") }) + test("replaces ce: namespace with ce- in body text", () => { + const input = "prefer ce:brainstorm first. Then run ce:plan and ce:review. Use ce:* skills." + const result = transformContentForCopilot(input) + expect(result).toBe("prefer ce-brainstorm first. Then run ce-plan and ce-review. Use ce-* skills.") + expect(result).not.toContain("ce:") + }) + + test("replaces multi-colon ce: references fully", () => { + const input = "run ce:work:beta and ce:review:deep" + const result = transformContentForCopilot(input) + expect(result).toBe("run ce-work-beta and ce-review-deep") + expect(result).not.toContain(":") + }) + + test("ce: replacement does not corrupt non-command patterns", () => { + const input = "Use source: explicit and Confidence: high. See https://example.com/ace:thing" + const result = transformContentForCopilot(input) + expect(result).toContain("source: explicit") + expect(result).toContain("Confidence: high") + expect(result).toContain("ace:thing") + }) + + test("ce: replacement does not corrupt URLs", () => { + const input = "See https://example.com/ce:plan and http://docs.example.com/ce:review/overview" + const result = transformContentForCopilot(input) + expect(result).toContain("https://example.com/ce:plan") + expect(result).toContain("http://docs.example.com/ce:review/overview") + }) + test("generated skill deduplicates against sanitized pass-through skill names", () => { const plugin: ClaudePlugin = { ...fixturePlugin, diff --git a/tests/copilot-writer.test.ts b/tests/copilot-writer.test.ts index aaee54ca..75648ad7 100644 --- a/tests/copilot-writer.test.ts +++ b/tests/copilot-writer.test.ts @@ -21,7 +21,7 @@ describe("writeCopilotBundle", () => { agents: [ { name: "security-reviewer", - content: "---\ndescription: Security\ntools:\n - '*'\ninfer: true\n---\n\nReview code.", + content: "---\ndescription: Security\nuser-invocable: true\n---\n\nReview code.", }, ], generatedSkills: [