diff --git a/README.md b/README.md index 3d733df9..679fda7b 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A Claude Code plugin marketplace featuring the **Compound Engineering Plugin** ## OpenCode, Codex, Droid, Cursor, Pi & Gemini (experimental) Install -This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Cursor, Pi, and Gemini CLI. +This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Cursor, Pi, Gemini CLI and GitHub Copilot. ```bash # convert the compound-engineering plugin into OpenCode format @@ -34,6 +34,9 @@ bunx @every-env/compound-plugin install compound-engineering --to pi # convert to Gemini CLI format bunx @every-env/compound-plugin install compound-engineering --to gemini + +# convert to GitHub Copilot format +bunx @every-env/compound-plugin install compound-engineering --to copilot ``` Local dev: @@ -48,6 +51,7 @@ Droid output is written to `~/.factory/` with commands, droids (agents), and ski Cursor output is written to `.cursor/` with rules (`.mdc`), commands, skills, and `mcp.json`. Agents become "Agent Requested" rules (`alwaysApply: false`) so Cursor's AI activates them on demand. Works with both the Cursor IDE and Cursor CLI (`cursor-agent`) — they share the same `.cursor/` config directory. Pi output is written to `~/.pi/agent/` by default with prompts, skills, extensions, and `compound-engineering/mcporter.json` for MCPorter interoperability. Gemini output is written to `.gemini/` with skills (from agents), commands (`.toml`), and `settings.json` (MCP servers). Namespaced commands create directory structure (`workflows:plan` → `commands/workflows/plan.toml`). Skills use the identical SKILL.md standard and pass through unchanged. +Copilot output is written to `.github/` with agents (`.agent.md`), skills (`SKILL.md`), and `copilot-mcp-config.json`. Agents get Copilot frontmatter (`description`, `tools: ["*"]`, `infer: true`), commands are converted to agent skills, and MCP server env vars are prefixed with `COPILOT_MCP_`. All provider targets are experimental and may change as the formats evolve. @@ -70,6 +74,9 @@ bunx @every-env/compound-plugin sync --target droid # Sync to Cursor (skills + MCP servers) bunx @every-env/compound-plugin sync --target cursor + +# Sync to GitHub Copilot (skills + MCP servers) +bunx @every-env/compound-plugin sync --target copilot ``` This syncs: diff --git a/bun.lock b/bun.lock index 26361fc8..3a07728e 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "compound-plugin", diff --git a/docs/brainstorms/2026-02-14-copilot-converter-target-brainstorm.md b/docs/brainstorms/2026-02-14-copilot-converter-target-brainstorm.md new file mode 100644 index 00000000..9bdec419 --- /dev/null +++ b/docs/brainstorms/2026-02-14-copilot-converter-target-brainstorm.md @@ -0,0 +1,117 @@ +--- +date: 2026-02-14 +topic: copilot-converter-target +--- + +# Add GitHub Copilot Converter Target + +## What We're Building + +A new converter target that transforms the compound-engineering Claude Code plugin into GitHub Copilot's native format. This follows the same established pattern as the existing converters (Cursor, Codex, OpenCode, Droid, Pi) and outputs files that Copilot can consume directly from `.github/` (repo-level) or `~/.copilot/` (user-wide). + +Copilot's customization system (as of early 2026) supports: custom agents (`.agent.md`), agent skills (`SKILL.md`), prompt files (`.prompt.md`), custom instructions (`copilot-instructions.md`), and MCP servers (via repo settings). + +## Why This Approach + +The repository already has a robust multi-target converter infrastructure with a consistent `TargetHandler` pattern. Adding Copilot as a new target follows this proven pattern rather than inventing something new. Copilot's format is close enough to Claude Code's that the conversion is straightforward, and the SKILL.md format is already cross-compatible. + +### Approaches Considered + +1. **Full converter target (chosen)** — Follow the existing pattern with types, converter, writer, and target registration. Most consistent with codebase conventions. +2. **Minimal agent-only converter** — Only convert agents, skip commands/skills. Too limited; users would lose most of the plugin's value. +3. **Documentation-only approach** — Just document how to manually set up Copilot. Doesn't compound — every user would repeat the work. + +## Key Decisions + +### Component Mapping + +| Claude Code Component | Copilot Equivalent | Notes | +|----------------------|-------------------|-------| +| **Agents** (`.md`) | **Custom Agents** (`.agent.md`) | Full frontmatter mapping: description, tools, target, infer | +| **Commands** (`.md`) | **Agent Skills** (`SKILL.md`) | Commands become skills since Copilot has no direct command equivalent. `allowed-tools` dropped silently. | +| **Skills** (`SKILL.md`) | **Agent Skills** (`SKILL.md`) | Copy as-is — format is already cross-compatible | +| **MCP Servers** | **Repo settings JSON** | Generate a `copilot-mcp-config.json` users paste into GitHub repo settings | +| **Hooks** | **Skipped with warning** | Copilot doesn't have a hooks equivalent | + +### Agent Frontmatter Mapping + +| Claude Field | Copilot Field | Mapping | +|-------------|--------------|---------| +| `name` | `name` | Direct pass-through | +| `description` | `description` (required) | Direct pass-through, generate fallback if missing | +| `capabilities` | Body text | Fold into body as "## Capabilities" section (like Cursor) | +| `model` | `model` | Pass through (works in IDE, may be ignored on github.com) | +| — | `tools` | Default to `["*"]` (all tools). Claude agents have unrestricted tool access, so Copilot agents should too. | +| — | `target` | Omit (defaults to `both` — IDE + github.com) | +| — | `infer` | Set to `true` (auto-selection enabled) | + +### Output Directories + +- **Repository-level (default):** `.github/agents/`, `.github/skills/` +- **User-wide (with --personal flag):** `~/.copilot/skills/` (only skills supported at this level) + +### Content Transformation + +Apply transformations similar to Cursor converter: + +1. **Task agent calls:** `Task agent-name(args)` → `Use the agent-name skill to: args` +2. **Slash commands:** `/workflows:plan` → `/plan` (flatten namespace) +3. **Path rewriting:** `.claude/` → `.github/` (Copilot's repo-level config path) +4. **Agent references:** `@agent-name` → `the agent-name agent` + +### MCP Server Handling + +Generate a `copilot-mcp-config.json` file with the structure Copilot expects: + +```json +{ + "mcpServers": { + "server-name": { + "type": "local", + "command": "npx", + "args": ["package"], + "tools": ["*"], + "env": { + "KEY": "COPILOT_MCP_KEY" + } + } + } +} +``` + +Note: Copilot requires env vars to use the `COPILOT_MCP_` prefix. The converter should transform env var names accordingly and include a comment/note about this. + +## Files to Create/Modify + +### New Files + +- `src/types/copilot.ts` — Type definitions (CopilotAgent, CopilotSkill, CopilotBundle, etc.) +- `src/converters/claude-to-copilot.ts` — Converter with `transformContentForCopilot()` +- `src/targets/copilot.ts` — Writer with `writeCopilotBundle()` +- `docs/specs/copilot.md` — Format specification document + +### Modified Files + +- `src/targets/index.ts` — Register copilot target handler +- `src/commands/sync.ts` — Add "copilot" to valid sync targets + +### Test Files + +- `tests/copilot-converter.test.ts` — Converter tests following existing patterns + +### Character Limit + +Copilot imposes a 30,000 character limit on agent body content. If an agent body exceeds this after folding in capabilities, the converter should truncate with a warning to stderr. + +### Agent File Extension + +Use `.agent.md` (not plain `.md`). This is the canonical Copilot convention and makes agent files immediately identifiable. + +## Open Questions + +- Should the converter generate a `copilot-setup-steps.yml` workflow file for MCP servers that need special dependencies (e.g., `uv`, `pipx`)? +- Should `.github/copilot-instructions.md` be generated with any base instructions from the plugin? + +## Next Steps + +→ `/workflows:plan` for implementation details diff --git a/docs/brainstorms/2026-02-17-copilot-skill-naming-brainstorm.md b/docs/brainstorms/2026-02-17-copilot-skill-naming-brainstorm.md new file mode 100644 index 00000000..c04e97d5 --- /dev/null +++ b/docs/brainstorms/2026-02-17-copilot-skill-naming-brainstorm.md @@ -0,0 +1,30 @@ +--- +date: 2026-02-17 +topic: copilot-skill-naming +--- + +# Copilot Skill Naming: Preserve Namespace + +## What We're Building + +Change the Copilot converter to preserve command namespaces when converting commands to skills. Currently `workflows:plan` flattens to `plan`, which is too generic and clashes with Copilot's own features in the chat suggestion UI. + +## Why This Approach + +The `flattenCommandName` function strips everything before the last colon, producing names like `plan`, `review`, `work` that are too generic for Copilot's skill discovery UI. Replacing colons with hyphens (`workflows:plan` -> `workflows-plan`) preserves context while staying within valid filename characters. + +## Key Decisions + +- **Replace colons with hyphens** instead of stripping the prefix: `workflows:plan` -> `workflows-plan` +- **Copilot only** — other converters (Cursor, Droid, etc.) keep their current flattening behavior +- **Content transformation too** — slash command references in body text also use hyphens: `/workflows:plan` -> `/workflows-plan` + +## Changes Required + +1. `src/converters/claude-to-copilot.ts` — change `flattenCommandName` to replace colons with hyphens +2. `src/converters/claude-to-copilot.ts` — update `transformContentForCopilot` slash command rewriting +3. `tests/copilot-converter.test.ts` — update affected tests + +## Next Steps + +-> Implement directly (small, well-scoped change) diff --git a/docs/plans/2026-02-14-feat-add-copilot-converter-target-plan.md b/docs/plans/2026-02-14-feat-add-copilot-converter-target-plan.md new file mode 100644 index 00000000..a87d0bde --- /dev/null +++ b/docs/plans/2026-02-14-feat-add-copilot-converter-target-plan.md @@ -0,0 +1,328 @@ +--- +title: "feat: Add GitHub Copilot converter target" +type: feat +date: 2026-02-14 +status: complete +--- + +# feat: Add GitHub Copilot Converter Target + +## Overview + +Add GitHub Copilot as a converter target following the established `TargetHandler` pattern. This converts the compound-engineering Claude Code plugin into Copilot's native format: custom agents (`.agent.md`), agent skills (`SKILL.md`), and MCP server configuration JSON. + +**Brainstorm:** `docs/brainstorms/2026-02-14-copilot-converter-target-brainstorm.md` + +## Problem Statement + +The CLI tool (`compound`) already supports converting Claude Code plugins to 5 target formats (OpenCode, Codex, Droid, Cursor, Pi). GitHub Copilot is a widely-used AI coding assistant that now supports custom agents, skills, and MCP servers — but there's no converter target for it. + +## Proposed Solution + +Follow the existing converter pattern exactly: + +1. Define types (`src/types/copilot.ts`) +2. Implement converter (`src/converters/claude-to-copilot.ts`) +3. Implement writer (`src/targets/copilot.ts`) +4. Register target (`src/targets/index.ts`) +5. Add sync support (`src/sync/copilot.ts`, `src/commands/sync.ts`) +6. Write tests and documentation + +### Component Mapping + +| Claude Code | Copilot | Output Path | +|-------------|---------|-------------| +| Agents (`.md`) | Custom Agents (`.agent.md`) | `.github/agents/{name}.agent.md` | +| Commands (`.md`) | Agent Skills (`SKILL.md`) | `.github/skills/{name}/SKILL.md` | +| Skills (`SKILL.md`) | Agent Skills (`SKILL.md`) | `.github/skills/{name}/SKILL.md` | +| MCP Servers | Config JSON | `.github/copilot-mcp-config.json` | +| Hooks | Skipped | Warning to stderr | + +## Technical Approach + +### Phase 1: Types + +**File:** `src/types/copilot.ts` + +```typescript +export type CopilotAgent = { + name: string + content: string // Full .agent.md content with frontmatter +} + +export type CopilotGeneratedSkill = { + name: string + content: string // SKILL.md content with frontmatter +} + +export type CopilotSkillDir = { + name: string + sourceDir: string +} + +export type CopilotMcpServer = { + type: string + command?: string + args?: string[] + url?: string + tools: string[] + env?: Record + headers?: Record +} + +export type CopilotBundle = { + agents: CopilotAgent[] + generatedSkills: CopilotGeneratedSkill[] + skillDirs: CopilotSkillDir[] + mcpConfig?: Record +} +``` + +### Phase 2: Converter + +**File:** `src/converters/claude-to-copilot.ts` + +**Agent conversion:** +- Frontmatter: `description` (required, fallback to `"Converted from Claude agent {name}"`), `tools: ["*"]`, `infer: true` +- Pass through `model` if present +- Fold `capabilities` into body as `## Capabilities` section (same as Cursor) +- Use `formatFrontmatter()` utility +- Warn if body exceeds 30,000 characters (`.length`) + +**Command → Skill conversion:** +- Convert to SKILL.md format with frontmatter: `name`, `description` +- Flatten namespaced names: `workflows:plan` → `plan` +- Drop `allowed-tools`, `model`, `disable-model-invocation` silently +- Include `argument-hint` as `## Arguments` section in body + +**Skill pass-through:** +- Map to `CopilotSkillDir` as-is (same as Cursor) + +**MCP server conversion:** +- Transform env var names: `API_KEY` → `COPILOT_MCP_API_KEY` +- Skip vars already prefixed with `COPILOT_MCP_` +- Add `type: "local"` for command-based servers, `type: "sse"` for URL-based +- Set `tools: ["*"]` for all servers + +**Content transformation (`transformContentForCopilot`):** + +| Pattern | Input | Output | +|---------|-------|--------| +| Task calls | `Task repo-research-analyst(desc)` | `Use the repo-research-analyst skill to: desc` | +| Slash commands | `/workflows:plan` | `/plan` | +| Path rewriting | `.claude/` | `.github/` | +| Home path rewriting | `~/.claude/` | `~/.copilot/` | +| Agent references | `@security-sentinel` | `the security-sentinel agent` | + +**Hooks:** Warn to stderr if present, skip. + +### Phase 3: Writer + +**File:** `src/targets/copilot.ts` + +**Path resolution:** +- If `outputRoot` basename is `.github`, write directly into it (avoid `.github/.github/` double-nesting) +- Otherwise, nest under `.github/` + +**Write operations:** +- Agents → `.github/agents/{name}.agent.md` (note: `.agent.md` extension) +- Generated skills (from commands) → `.github/skills/{name}/SKILL.md` +- Skill dirs → `.github/skills/{name}/` (copy via `copyDir`) +- MCP config → `.github/copilot-mcp-config.json` (backup existing with `backupFile`) + +### Phase 4: Target Registration + +**File:** `src/targets/index.ts` + +Add import and register: + +```typescript +import { convertClaudeToCopilot } from "../converters/claude-to-copilot" +import { writeCopilotBundle } from "./copilot" + +// In targets record: +copilot: { + name: "copilot", + implemented: true, + convert: convertClaudeToCopilot as TargetHandler["convert"], + write: writeCopilotBundle as TargetHandler["write"], +}, +``` + +### Phase 5: Sync Support + +**File:** `src/sync/copilot.ts` + +Follow the Cursor sync pattern (`src/sync/cursor.ts`): +- Symlink skills to `.github/skills/` using `forceSymlink` +- Validate skill names with `isValidSkillName` +- Convert MCP servers with `COPILOT_MCP_` prefix transformation +- Merge MCP config into existing `.github/copilot-mcp-config.json` + +**File:** `src/commands/sync.ts` + +- Add `"copilot"` to `validTargets` array +- Add case in `resolveOutputRoot()`: `case "copilot": return path.join(process.cwd(), ".github")` +- Add import and switch case for `syncToCopilot` +- Update meta description to include "Copilot" + +### Phase 6: Tests + +**File:** `tests/copilot-converter.test.ts` + +Test cases (following `tests/cursor-converter.test.ts` pattern): + +``` +describe("convertClaudeToCopilot") + ✓ converts agents to .agent.md with Copilot frontmatter + ✓ agent description is required, fallback generated if missing + ✓ agent with empty body gets default body + ✓ agent capabilities are prepended to body + ✓ agent model field is passed through + ✓ agent tools defaults to ["*"] + ✓ agent infer defaults to true + ✓ warns when agent body exceeds 30k characters + ✓ converts commands to skills with SKILL.md format + ✓ flattens namespaced command names + ✓ command name collision after flattening is deduplicated + ✓ command allowedTools is silently dropped + ✓ command with argument-hint gets Arguments section + ✓ passes through skill directories + ✓ skill and generated skill name collision is deduplicated + ✓ converts MCP servers with COPILOT_MCP_ prefix + ✓ MCP env vars already prefixed are not double-prefixed + ✓ MCP servers get type field (local vs sse) + ✓ warns when hooks are present + ✓ no warning when hooks are absent + ✓ plugin with zero agents produces empty agents array + ✓ plugin with only skills works + +describe("transformContentForCopilot") + ✓ rewrites .claude/ paths to .github/ + ✓ rewrites ~/.claude/ paths to ~/.copilot/ + ✓ transforms Task agent calls to skill references + ✓ flattens slash commands + ✓ transforms @agent references to agent references +``` + +**File:** `tests/copilot-writer.test.ts` + +Test cases (following `tests/cursor-writer.test.ts` pattern): + +``` +describe("writeCopilotBundle") + ✓ writes agents, generated skills, copied skills, and MCP config + ✓ agents use .agent.md file extension + ✓ writes directly into .github output root without double-nesting + ✓ handles empty bundles gracefully + ✓ writes multiple agents as separate .agent.md files + ✓ backs up existing copilot-mcp-config.json before overwriting + ✓ creates skill directories with SKILL.md +``` + +**File:** `tests/sync-copilot.test.ts` + +Test cases (following `tests/sync-cursor.test.ts` pattern): + +``` +describe("syncToCopilot") + ✓ symlinks skills to .github/skills/ + ✓ skips skills with invalid names + ✓ merges MCP config with existing file + ✓ transforms MCP env var names to COPILOT_MCP_ prefix + ✓ writes MCP config with restricted permissions (0o600) +``` + +### Phase 7: Documentation + +**File:** `docs/specs/copilot.md` + +Follow `docs/specs/cursor.md` format: +- Last verified date +- Primary sources (GitHub Docs URLs) +- Config locations table +- Agents section (`.agent.md` format, frontmatter fields) +- Skills section (`SKILL.md` format) +- MCP section (config structure, env var prefix requirement) +- Character limits (30k agent body) + +**File:** `README.md` + +- Add "copilot" to the list of supported targets +- Add usage example: `compound convert --to copilot ./plugins/compound-engineering` +- Add sync example: `compound sync copilot` + +## Acceptance Criteria + +### Converter +- [x] Agents convert to `.agent.md` with `description`, `tools: ["*"]`, `infer: true` +- [x] Agent `model` passes through when present +- [x] Agent `capabilities` fold into body as `## Capabilities` +- [x] Missing description generates fallback +- [x] Empty body generates fallback +- [x] Body exceeding 30k chars triggers stderr warning +- [x] Commands convert to SKILL.md format +- [x] Command names flatten (`workflows:plan` → `plan`) +- [x] Name collisions deduplicated with `-2`, `-3` suffix +- [x] Command `allowed-tools` dropped silently +- [x] Skills pass through as `CopilotSkillDir` +- [x] MCP env vars prefixed with `COPILOT_MCP_` +- [x] Already-prefixed env vars not double-prefixed +- [x] MCP servers get `type` field (`local` or `sse`) +- [x] Hooks trigger warning, skip conversion +- [x] Content transformation: Task calls, slash commands, paths, @agent refs + +### Writer +- [x] Agents written to `.github/agents/{name}.agent.md` +- [x] Generated skills written to `.github/skills/{name}/SKILL.md` +- [x] Skill dirs copied to `.github/skills/{name}/` +- [x] MCP config written to `.github/copilot-mcp-config.json` +- [x] Existing MCP config backed up before overwrite +- [x] No double-nesting when outputRoot is `.github` +- [x] Empty bundles handled gracefully + +### CLI Integration +- [x] `compound convert --to copilot` works +- [x] `compound sync copilot` works +- [x] Copilot registered in `src/targets/index.ts` +- [x] Sync resolves output to `.github/` in current directory + +### Tests +- [x] `tests/copilot-converter.test.ts` — all converter tests pass +- [x] `tests/copilot-writer.test.ts` — all writer tests pass +- [x] `tests/sync-copilot.test.ts` — all sync tests pass + +### Documentation +- [x] `docs/specs/copilot.md` — format specification +- [x] `README.md` — updated with copilot target + +## Files to Create + +| File | Purpose | +|------|---------| +| `src/types/copilot.ts` | Type definitions | +| `src/converters/claude-to-copilot.ts` | Converter logic | +| `src/targets/copilot.ts` | Writer logic | +| `src/sync/copilot.ts` | Sync handler | +| `tests/copilot-converter.test.ts` | Converter tests | +| `tests/copilot-writer.test.ts` | Writer tests | +| `tests/sync-copilot.test.ts` | Sync tests | +| `docs/specs/copilot.md` | Format specification | + +## Files to Modify + +| File | Change | +|------|--------| +| `src/targets/index.ts` | Register copilot target | +| `src/commands/sync.ts` | Add copilot to valid targets, output root, switch case | +| `README.md` | Add copilot to supported targets | + +## References + +- [Custom agents configuration - GitHub Docs](https://docs.github.com/en/copilot/reference/custom-agents-configuration) +- [About Agent Skills - GitHub Docs](https://docs.github.com/en/copilot/concepts/agents/about-agent-skills) +- [MCP and coding agent - GitHub Docs](https://docs.github.com/en/copilot/concepts/agents/coding-agent/mcp-and-coding-agent) +- Existing converter: `src/converters/claude-to-cursor.ts` +- Existing writer: `src/targets/cursor.ts` +- Existing sync: `src/sync/cursor.ts` +- Existing tests: `tests/cursor-converter.test.ts`, `tests/cursor-writer.test.ts` diff --git a/docs/specs/copilot.md b/docs/specs/copilot.md new file mode 100644 index 00000000..bee2990a --- /dev/null +++ b/docs/specs/copilot.md @@ -0,0 +1,122 @@ +# GitHub Copilot Spec (Agents, Skills, MCP) + +Last verified: 2026-02-14 + +## Primary sources + +``` +https://docs.github.com/en/copilot/reference/custom-agents-configuration +https://docs.github.com/en/copilot/concepts/agents/about-agent-skills +https://docs.github.com/en/copilot/concepts/agents/coding-agent/mcp-and-coding-agent +``` + +## Config locations + +| Scope | Path | +|-------|------| +| Project agents | `.github/agents/*.agent.md` | +| Project skills | `.github/skills/*/SKILL.md` | +| Project instructions | `.github/copilot-instructions.md` | +| Path-specific instructions | `.github/instructions/*.instructions.md` | +| Project prompts | `.github/prompts/*.prompt.md` | +| Org/enterprise agents | `.github-private/agents/*.agent.md` | +| Personal skills | `~/.copilot/skills/*/SKILL.md` | +| Directory instructions | `AGENTS.md` (nearest ancestor wins) | + +## Agents (.agent.md files) + +- Custom agents are Markdown files with YAML frontmatter stored in `.github/agents/`. +- File extension is `.agent.md` (or `.md`). Filenames may only contain: `.`, `-`, `_`, `a-z`, `A-Z`, `0-9`. +- `description` is the only required frontmatter field. + +### Frontmatter fields + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `name` | No | Derived from filename | Display name | +| `description` | **Yes** | — | What the agent does | +| `tools` | No | `["*"]` | Tool access list. `[]` disables all tools. | +| `target` | No | both | `vscode`, `github-copilot`, or omit for both | +| `infer` | No | `true` | Auto-select based on task context | +| `model` | No | Platform default | AI model (works in IDE, may be ignored on github.com) | +| `mcp-servers` | No | — | MCP config (org/enterprise agents only) | +| `metadata` | No | — | Arbitrary key-value annotations | + +### Character limit + +Agent body content is limited to **30,000 characters**. + +### Tool names + +| Name | Aliases | Purpose | +|------|---------|---------| +| `execute` | `shell`, `Bash` | Run shell commands | +| `read` | `Read` | Read files | +| `edit` | `Edit`, `Write` | Modify files | +| `search` | `Grep`, `Glob` | Search files | +| `agent` | `Task` | Invoke other agents | +| `web` | `WebSearch`, `WebFetch` | Web access | + +## Skills (SKILL.md) + +- Skills follow the open SKILL.md standard (same format as Claude Code and Cursor). +- A skill is a directory containing `SKILL.md` plus optional `scripts/`, `references/`, and `assets/`. +- YAML frontmatter requires `name` and `description` fields. +- Skills are loaded on-demand when Copilot determines relevance. + +### Discovery locations + +| Scope | Path | +|-------|------| +| Project | `.github/skills/*/SKILL.md` | +| Project (Claude-compatible) | `.claude/skills/*/SKILL.md` | +| Project (auto-discovery) | `.agents/skills/*/SKILL.md` | +| Personal | `~/.copilot/skills/*/SKILL.md` | + +## MCP (Model Context Protocol) + +- MCP configuration is set via **Repository Settings > Copilot > Coding agent > MCP configuration** on GitHub. +- Repository-level agents **cannot** define MCP servers inline; use repository settings instead. +- Org/enterprise agents can embed MCP server definitions in frontmatter. +- All env var names must use the `COPILOT_MCP_` prefix. +- Only MCP tools are supported (not resources or prompts). + +### Config structure + +```json +{ + "mcpServers": { + "server-name": { + "type": "local", + "command": "npx", + "args": ["package"], + "tools": ["*"], + "env": { + "API_KEY": "COPILOT_MCP_API_KEY" + } + } + } +} +``` + +### Server types + +| Type | Fields | +|------|--------| +| Local/stdio | `type: "local"`, `command`, `args`, `tools`, `env` | +| Remote/SSE | `type: "sse"`, `url`, `tools`, `headers` | + +## Prompts (.prompt.md) + +- Reusable prompt files stored in `.github/prompts/`. +- Available in VS Code, Visual Studio, and JetBrains IDEs only (not on github.com). +- Invoked via `/promptname` in chat. +- Support variable syntax: `${input:name}`, `${file}`, `${selection}`. + +## Precedence + +1. Repository-level agents +2. Organization-level agents (`.github-private`) +3. Enterprise-level agents (`.github-private`) + +Within a repo, `AGENTS.md` files in directories provide nearest-ancestor-wins instructions. diff --git a/src/commands/install.ts b/src/commands/install.ts index 35506e8c..c2412bb9 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -25,7 +25,7 @@ export default defineCommand({ to: { type: "string", default: "opencode", - description: "Target format (opencode | codex | droid | cursor | pi | gemini)", + description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini)", }, output: { type: "string", @@ -187,6 +187,10 @@ function resolveTargetOutputRoot( const base = hasExplicitOutput ? outputRoot : process.cwd() return path.join(base, ".gemini") } + if (targetName === "copilot") { + const base = hasExplicitOutput ? outputRoot : process.cwd() + return path.join(base, ".github") + } return outputRoot } diff --git a/src/commands/sync.ts b/src/commands/sync.ts index e5b576e1..f4537048 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -7,9 +7,10 @@ import { syncToCodex } from "../sync/codex" import { syncToPi } from "../sync/pi" import { syncToDroid } from "../sync/droid" import { syncToCursor } from "../sync/cursor" +import { syncToCopilot } from "../sync/copilot" import { expandHome } from "../utils/resolve-home" -const validTargets = ["opencode", "codex", "pi", "droid", "cursor"] as const +const validTargets = ["opencode", "codex", "pi", "droid", "cursor", "copilot"] as const type SyncTarget = (typeof validTargets)[number] function isValidTarget(value: string): value is SyncTarget { @@ -42,19 +43,21 @@ function resolveOutputRoot(target: SyncTarget): string { return path.join(os.homedir(), ".factory") case "cursor": return path.join(process.cwd(), ".cursor") + case "copilot": + return path.join(process.cwd(), ".github") } } export default defineCommand({ meta: { name: "sync", - description: "Sync Claude Code config (~/.claude/) to OpenCode, Codex, Pi, Droid, or Cursor", + description: "Sync Claude Code config (~/.claude/) to OpenCode, Codex, Pi, Droid, Cursor, or Copilot", }, args: { target: { type: "string", required: true, - description: "Target: opencode | codex | pi | droid | cursor", + description: "Target: opencode | codex | pi | droid | cursor | copilot", }, claudeHome: { type: "string", @@ -100,6 +103,9 @@ export default defineCommand({ case "cursor": await syncToCursor(config, outputRoot) break + case "copilot": + await syncToCopilot(config, outputRoot) + break } console.log(`✓ Synced to ${args.target}: ${outputRoot}`) diff --git a/src/converters/claude-to-copilot.ts b/src/converters/claude-to-copilot.ts new file mode 100644 index 00000000..6a7722cc --- /dev/null +++ b/src/converters/claude-to-copilot.ts @@ -0,0 +1,210 @@ +import { formatFrontmatter } from "../utils/frontmatter" +import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude" +import type { + CopilotAgent, + CopilotBundle, + CopilotGeneratedSkill, + CopilotMcpServer, +} from "../types/copilot" +import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode" + +export type ClaudeToCopilotOptions = ClaudeToOpenCodeOptions + +const COPILOT_BODY_CHAR_LIMIT = 30_000 + +export function convertClaudeToCopilot( + plugin: ClaudePlugin, + _options: ClaudeToCopilotOptions, +): CopilotBundle { + const usedAgentNames = new Set() + const usedSkillNames = new Set() + + const agents = plugin.agents.map((agent) => convertAgent(agent, usedAgentNames)) + + // Reserve skill names first so generated skills (from commands) don't collide + const skillDirs = plugin.skills.map((skill) => { + usedSkillNames.add(skill.name) + return { + name: skill.name, + sourceDir: skill.sourceDir, + } + }) + + const generatedSkills = plugin.commands.map((command) => + convertCommandToSkill(command, usedSkillNames), + ) + + const mcpConfig = convertMcpServers(plugin.mcpServers) + + if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) { + console.warn("Warning: Copilot does not support hooks. Hooks were skipped during conversion.") + } + + return { agents, generatedSkills, skillDirs, mcpConfig } +} + +function convertAgent(agent: ClaudeAgent, usedNames: Set): CopilotAgent { + const name = uniqueName(normalizeName(agent.name), usedNames) + const description = agent.description ?? `Converted from Claude agent ${agent.name}` + + const frontmatter: Record = { + description, + tools: ["*"], + infer: true, + } + + if (agent.model) { + frontmatter.model = agent.model + } + + let body = transformContentForCopilot(agent.body.trim()) + if (agent.capabilities && agent.capabilities.length > 0) { + const capabilities = agent.capabilities.map((c) => `- ${c}`).join("\n") + body = `## Capabilities\n${capabilities}\n\n${body}`.trim() + } + if (body.length === 0) { + body = `Instructions converted from the ${agent.name} agent.` + } + + if (body.length > COPILOT_BODY_CHAR_LIMIT) { + console.warn( + `Warning: Agent "${agent.name}" body exceeds ${COPILOT_BODY_CHAR_LIMIT} characters (${body.length}). Copilot may truncate it.`, + ) + } + + const content = formatFrontmatter(frontmatter, body) + return { name, content } +} + +function convertCommandToSkill( + command: ClaudeCommand, + usedNames: Set, +): CopilotGeneratedSkill { + const name = uniqueName(flattenCommandName(command.name), usedNames) + + const frontmatter: Record = { + name, + } + if (command.description) { + frontmatter.description = command.description + } + + const sections: string[] = [] + + if (command.argumentHint) { + sections.push(`## Arguments\n${command.argumentHint}`) + } + + const transformedBody = transformContentForCopilot(command.body.trim()) + sections.push(transformedBody) + + const body = sections.filter(Boolean).join("\n\n").trim() + const content = formatFrontmatter(frontmatter, body) + return { name, content } +} + +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 + result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => { + const skillName = normalizeName(agentName) + return `${prefix}Use the ${skillName} skill to: ${args.trim()}` + }) + + // 2. Transform slash command references (replace colons with hyphens) + const slashCommandPattern = /(? { + if (commandName.includes("/")) return match + if (["dev", "tmp", "etc", "usr", "var", "bin", "home"].includes(commandName)) return match + const normalized = flattenCommandName(commandName) + return `/${normalized}` + }) + + // 3. Rewrite .claude/ paths to .github/ and ~/.claude/ to ~/.copilot/ + result = result + .replace(/~\/\.claude\//g, "~/.copilot/") + .replace(/\.claude\//g, ".github/") + + // 4. 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) => { + return `the ${normalizeName(agentName)} agent` + }) + + return result +} + +function convertMcpServers( + servers?: Record, +): Record | undefined { + if (!servers || Object.keys(servers).length === 0) return undefined + + const result: Record = {} + for (const [name, server] of Object.entries(servers)) { + const entry: CopilotMcpServer = { + type: server.command ? "local" : "sse", + tools: ["*"], + } + + if (server.command) { + entry.command = server.command + if (server.args && server.args.length > 0) entry.args = server.args + } else if (server.url) { + entry.url = server.url + if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers + } + + if (server.env && Object.keys(server.env).length > 0) { + entry.env = prefixEnvVars(server.env) + } + + result[name] = entry + } + return result +} + +function prefixEnvVars(env: Record): Record { + const result: Record = {} + for (const [key, value] of Object.entries(env)) { + if (key.startsWith("COPILOT_MCP_")) { + result[key] = value + } else { + result[`COPILOT_MCP_${key}`] = value + } + } + return result +} + +function flattenCommandName(name: string): string { + return normalizeName(name) +} + +function normalizeName(value: string): string { + const trimmed = value.trim() + if (!trimmed) return "item" + const normalized = trimmed + .toLowerCase() + .replace(/[\\/]+/g, "-") + .replace(/[:\s]+/g, "-") + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") + return normalized || "item" +} + +function uniqueName(base: string, used: Set): string { + if (!used.has(base)) { + used.add(base) + return base + } + let index = 2 + while (used.has(`${base}-${index}`)) { + index += 1 + } + const name = `${base}-${index}` + used.add(name) + return name +} diff --git a/src/sync/copilot.ts b/src/sync/copilot.ts new file mode 100644 index 00000000..b4eccdc3 --- /dev/null +++ b/src/sync/copilot.ts @@ -0,0 +1,100 @@ +import fs from "fs/promises" +import path from "path" +import type { ClaudeHomeConfig } from "../parsers/claude-home" +import type { ClaudeMcpServer } from "../types/claude" +import { forceSymlink, isValidSkillName } from "../utils/symlink" + +type CopilotMcpServer = { + type: string + command?: string + args?: string[] + url?: string + tools: string[] + env?: Record + headers?: Record +} + +type CopilotMcpConfig = { + mcpServers: Record +} + +export async function syncToCopilot( + config: ClaudeHomeConfig, + outputRoot: string, +): Promise { + const skillsDir = path.join(outputRoot, "skills") + await fs.mkdir(skillsDir, { recursive: true }) + + for (const skill of config.skills) { + if (!isValidSkillName(skill.name)) { + console.warn(`Skipping skill with invalid name: ${skill.name}`) + continue + } + const target = path.join(skillsDir, skill.name) + await forceSymlink(skill.sourceDir, target) + } + + if (Object.keys(config.mcpServers).length > 0) { + const mcpPath = path.join(outputRoot, "copilot-mcp-config.json") + const existing = await readJsonSafe(mcpPath) + const converted = convertMcpForCopilot(config.mcpServers) + const merged: CopilotMcpConfig = { + mcpServers: { + ...(existing.mcpServers ?? {}), + ...converted, + }, + } + await fs.writeFile(mcpPath, JSON.stringify(merged, null, 2), { mode: 0o600 }) + } +} + +async function readJsonSafe(filePath: string): Promise> { + try { + const content = await fs.readFile(filePath, "utf-8") + return JSON.parse(content) as Partial + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return {} + } + throw err + } +} + +function convertMcpForCopilot( + servers: Record, +): Record { + const result: Record = {} + for (const [name, server] of Object.entries(servers)) { + const entry: CopilotMcpServer = { + type: server.command ? "local" : "sse", + tools: ["*"], + } + + if (server.command) { + entry.command = server.command + if (server.args && server.args.length > 0) entry.args = server.args + } else if (server.url) { + entry.url = server.url + if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers + } + + if (server.env && Object.keys(server.env).length > 0) { + entry.env = prefixEnvVars(server.env) + } + + result[name] = entry + } + return result +} + +function prefixEnvVars(env: Record): Record { + const result: Record = {} + for (const [key, value] of Object.entries(env)) { + if (key.startsWith("COPILOT_MCP_")) { + result[key] = value + } else { + result[`COPILOT_MCP_${key}`] = value + } + } + return result +} diff --git a/src/targets/copilot.ts b/src/targets/copilot.ts new file mode 100644 index 00000000..d0d1b1ca --- /dev/null +++ b/src/targets/copilot.ts @@ -0,0 +1,48 @@ +import path from "path" +import { backupFile, copyDir, ensureDir, writeJson, writeText } from "../utils/files" +import type { CopilotBundle } from "../types/copilot" + +export async function writeCopilotBundle(outputRoot: string, bundle: CopilotBundle): Promise { + const paths = resolveCopilotPaths(outputRoot) + await ensureDir(paths.githubDir) + + if (bundle.agents.length > 0) { + const agentsDir = path.join(paths.githubDir, "agents") + for (const agent of bundle.agents) { + await writeText(path.join(agentsDir, `${agent.name}.agent.md`), agent.content + "\n") + } + } + + if (bundle.generatedSkills.length > 0) { + const skillsDir = path.join(paths.githubDir, "skills") + for (const skill of bundle.generatedSkills) { + await writeText(path.join(skillsDir, skill.name, "SKILL.md"), skill.content + "\n") + } + } + + 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)) + } + } + + if (bundle.mcpConfig && Object.keys(bundle.mcpConfig).length > 0) { + const mcpPath = path.join(paths.githubDir, "copilot-mcp-config.json") + const backupPath = await backupFile(mcpPath) + if (backupPath) { + console.log(`Backed up existing copilot-mcp-config.json to ${backupPath}`) + } + await writeJson(mcpPath, { mcpServers: bundle.mcpConfig }) + } +} + +function resolveCopilotPaths(outputRoot: string) { + const base = path.basename(outputRoot) + // If already pointing at .github, write directly into it + if (base === ".github") { + return { githubDir: outputRoot } + } + // Otherwise nest under .github + return { githubDir: path.join(outputRoot, ".github") } +} diff --git a/src/targets/index.ts b/src/targets/index.ts index b76dfc1d..ffcaeffe 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -4,18 +4,21 @@ import type { CodexBundle } from "../types/codex" import type { DroidBundle } from "../types/droid" import type { CursorBundle } from "../types/cursor" import type { PiBundle } from "../types/pi" +import type { CopilotBundle } from "../types/copilot" import type { GeminiBundle } from "../types/gemini" import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode" import { convertClaudeToCodex } from "../converters/claude-to-codex" import { convertClaudeToDroid } from "../converters/claude-to-droid" import { convertClaudeToCursor } from "../converters/claude-to-cursor" import { convertClaudeToPi } from "../converters/claude-to-pi" +import { convertClaudeToCopilot } from "../converters/claude-to-copilot" import { convertClaudeToGemini } from "../converters/claude-to-gemini" import { writeOpenCodeBundle } from "./opencode" import { writeCodexBundle } from "./codex" import { writeDroidBundle } from "./droid" import { writeCursorBundle } from "./cursor" import { writePiBundle } from "./pi" +import { writeCopilotBundle } from "./copilot" import { writeGeminiBundle } from "./gemini" export type TargetHandler = { @@ -56,6 +59,12 @@ export const targets: Record = { convert: convertClaudeToPi as TargetHandler["convert"], write: writePiBundle as TargetHandler["write"], }, + copilot: { + name: "copilot", + implemented: true, + convert: convertClaudeToCopilot as TargetHandler["convert"], + write: writeCopilotBundle as TargetHandler["write"], + }, gemini: { name: "gemini", implemented: true, diff --git a/src/types/copilot.ts b/src/types/copilot.ts new file mode 100644 index 00000000..8d1ae125 --- /dev/null +++ b/src/types/copilot.ts @@ -0,0 +1,31 @@ +export type CopilotAgent = { + name: string + content: string +} + +export type CopilotGeneratedSkill = { + name: string + content: string +} + +export type CopilotSkillDir = { + name: string + sourceDir: string +} + +export type CopilotMcpServer = { + type: string + command?: string + args?: string[] + url?: string + tools: string[] + env?: Record + headers?: Record +} + +export type CopilotBundle = { + agents: CopilotAgent[] + generatedSkills: CopilotGeneratedSkill[] + skillDirs: CopilotSkillDir[] + mcpConfig?: Record +} diff --git a/src/utils/frontmatter.ts b/src/utils/frontmatter.ts index a799c948..dfe85bfc 100644 --- a/src/utils/frontmatter.ts +++ b/src/utils/frontmatter.ts @@ -58,7 +58,7 @@ function formatYamlValue(value: unknown): string { if (raw.includes("\n")) { return `|\n${raw.split("\n").map((line) => ` ${line}`).join("\n")}` } - if (raw.includes(":") || raw.startsWith("[") || raw.startsWith("{")) { + if (raw.includes(":") || raw.startsWith("[") || raw.startsWith("{") || raw === "*") { return JSON.stringify(raw) } return raw diff --git a/tests/copilot-converter.test.ts b/tests/copilot-converter.test.ts new file mode 100644 index 00000000..22f7973d --- /dev/null +++ b/tests/copilot-converter.test.ts @@ -0,0 +1,467 @@ +import { describe, expect, test, spyOn } from "bun:test" +import { convertClaudeToCopilot, transformContentForCopilot } from "../src/converters/claude-to-copilot" +import { parseFrontmatter } from "../src/utils/frontmatter" +import type { ClaudePlugin } from "../src/types/claude" + +const fixturePlugin: ClaudePlugin = { + root: "/tmp/plugin", + manifest: { name: "fixture", version: "1.0.0" }, + agents: [ + { + name: "Security Reviewer", + description: "Security-focused code review agent", + capabilities: ["Threat modeling", "OWASP"], + model: "claude-sonnet-4-20250514", + body: "Focus on vulnerabilities.", + sourcePath: "/tmp/plugin/agents/security-reviewer.md", + }, + ], + commands: [ + { + name: "workflows:plan", + description: "Planning command", + argumentHint: "[FOCUS]", + model: "inherit", + allowedTools: ["Read"], + body: "Plan the work.", + sourcePath: "/tmp/plugin/commands/workflows/plan.md", + }, + ], + skills: [ + { + name: "existing-skill", + description: "Existing skill", + sourceDir: "/tmp/plugin/skills/existing-skill", + skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md", + }, + ], + hooks: undefined, + mcpServers: undefined, +} + +const defaultOptions = { + agentMode: "subagent" as const, + inferTemperature: false, + permissions: "none" as const, +} + +describe("convertClaudeToCopilot", () => { + test("converts agents to .agent.md with Copilot frontmatter", () => { + const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) + + expect(bundle.agents).toHaveLength(1) + const agent = bundle.agents[0] + expect(agent.name).toBe("security-reviewer") + + 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.body).toContain("Capabilities") + expect(parsed.body).toContain("Threat modeling") + expect(parsed.body).toContain("Focus on vulnerabilities.") + }) + + test("agent description is required, fallback generated if missing", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "basic-agent", + body: "Do things.", + sourcePath: "/tmp/plugin/agents/basic.md", + }, + ], + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.data.description).toBe("Converted from Claude agent basic-agent") + }) + + test("agent with empty body gets default body", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "empty-agent", + description: "Empty agent", + body: "", + sourcePath: "/tmp/plugin/agents/empty.md", + }, + ], + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.body).toContain("Instructions converted from the empty-agent agent.") + }) + + test("agent capabilities are prepended to body", () => { + const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.body).toMatch(/## Capabilities\n- Threat modeling\n- OWASP/) + }) + + test("agent model field is passed through", () => { + const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.data.model).toBe("claude-sonnet-4-20250514") + }) + + test("agent without model omits model field", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "no-model", + description: "No model agent", + body: "Content.", + sourcePath: "/tmp/plugin/agents/no-model.md", + }, + ], + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.data.model).toBeUndefined() + }) + + test("agent tools defaults to [*]", () => { + const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.data.tools).toEqual(["*"]) + }) + + test("agent infer defaults to true", () => { + const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.data.infer).toBe(true) + }) + + test("warns when agent body exceeds 30k characters", () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}) + + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "large-agent", + description: "Large agent", + body: "x".repeat(31_000), + sourcePath: "/tmp/plugin/agents/large.md", + }, + ], + commands: [], + skills: [], + } + + convertClaudeToCopilot(plugin, defaultOptions) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("exceeds 30000 characters"), + ) + + warnSpy.mockRestore() + }) + + test("converts commands to skills with SKILL.md format", () => { + const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) + + expect(bundle.generatedSkills).toHaveLength(1) + const skill = bundle.generatedSkills[0] + expect(skill.name).toBe("workflows-plan") + + const parsed = parseFrontmatter(skill.content) + expect(parsed.data.name).toBe("workflows-plan") + expect(parsed.data.description).toBe("Planning command") + expect(parsed.body).toContain("Plan the work.") + }) + + test("preserves namespaced command names with hyphens", () => { + const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) + expect(bundle.generatedSkills[0].name).toBe("workflows-plan") + }) + + test("command name collision after normalization is deduplicated", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + commands: [ + { + name: "workflows:plan", + description: "Workflow plan", + body: "Plan body.", + sourcePath: "/tmp/plugin/commands/workflows/plan.md", + }, + { + name: "workflows:plan", + description: "Duplicate plan", + body: "Duplicate body.", + sourcePath: "/tmp/plugin/commands/workflows/plan2.md", + }, + ], + agents: [], + skills: [], + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + const names = bundle.generatedSkills.map((s) => s.name) + expect(names).toEqual(["workflows-plan", "workflows-plan-2"]) + }) + + test("namespaced and non-namespaced commands produce distinct names", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + commands: [ + { + name: "workflows:plan", + description: "Workflow plan", + body: "Plan body.", + sourcePath: "/tmp/plugin/commands/workflows/plan.md", + }, + { + name: "plan", + description: "Top-level plan", + body: "Top plan body.", + sourcePath: "/tmp/plugin/commands/plan.md", + }, + ], + agents: [], + skills: [], + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + const names = bundle.generatedSkills.map((s) => s.name) + expect(names).toEqual(["workflows-plan", "plan"]) + }) + + test("command allowedTools is silently dropped", () => { + const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) + const skill = bundle.generatedSkills[0] + expect(skill.content).not.toContain("allowedTools") + expect(skill.content).not.toContain("allowed-tools") + }) + + test("command with argument-hint gets Arguments section", () => { + const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) + const skill = bundle.generatedSkills[0] + expect(skill.content).toContain("## Arguments") + expect(skill.content).toContain("[FOCUS]") + }) + + test("passes through skill directories", () => { + const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) + + expect(bundle.skillDirs).toHaveLength(1) + expect(bundle.skillDirs[0].name).toBe("existing-skill") + expect(bundle.skillDirs[0].sourceDir).toBe("/tmp/plugin/skills/existing-skill") + }) + + test("skill and generated skill name collision is deduplicated", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + commands: [ + { + name: "existing-skill", + description: "Colliding command", + body: "This collides with skill name.", + sourcePath: "/tmp/plugin/commands/existing-skill.md", + }, + ], + agents: [], + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + // The command should get deduplicated since the skill name is reserved + expect(bundle.generatedSkills[0].name).toBe("existing-skill-2") + expect(bundle.skillDirs[0].name).toBe("existing-skill") + }) + + test("converts MCP servers with COPILOT_MCP_ prefix", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [], + commands: [], + skills: [], + mcpServers: { + playwright: { + command: "npx", + args: ["-y", "@anthropic/mcp-playwright"], + env: { DISPLAY: ":0", API_KEY: "secret" }, + }, + }, + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + expect(bundle.mcpConfig).toBeDefined() + expect(bundle.mcpConfig!.playwright.type).toBe("local") + expect(bundle.mcpConfig!.playwright.command).toBe("npx") + expect(bundle.mcpConfig!.playwright.args).toEqual(["-y", "@anthropic/mcp-playwright"]) + expect(bundle.mcpConfig!.playwright.tools).toEqual(["*"]) + expect(bundle.mcpConfig!.playwright.env).toEqual({ + COPILOT_MCP_DISPLAY: ":0", + COPILOT_MCP_API_KEY: "secret", + }) + }) + + test("MCP env vars already prefixed are not double-prefixed", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [], + commands: [], + skills: [], + mcpServers: { + server: { + command: "node", + args: ["server.js"], + env: { COPILOT_MCP_TOKEN: "abc" }, + }, + }, + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + expect(bundle.mcpConfig!.server.env).toEqual({ COPILOT_MCP_TOKEN: "abc" }) + }) + + test("MCP servers get type field (local vs sse)", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [], + commands: [], + skills: [], + mcpServers: { + local: { command: "npx", args: ["server"] }, + remote: { url: "https://mcp.example.com/sse" }, + }, + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + expect(bundle.mcpConfig!.local.type).toBe("local") + expect(bundle.mcpConfig!.remote.type).toBe("sse") + }) + + test("MCP headers pass through for remote servers", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [], + commands: [], + skills: [], + mcpServers: { + remote: { + url: "https://mcp.example.com/sse", + headers: { Authorization: "Bearer token" }, + }, + }, + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + expect(bundle.mcpConfig!.remote.url).toBe("https://mcp.example.com/sse") + expect(bundle.mcpConfig!.remote.headers).toEqual({ Authorization: "Bearer token" }) + }) + + test("warns when hooks are present", () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}) + + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [], + commands: [], + skills: [], + hooks: { + hooks: { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "echo test" }] }], + }, + }, + } + + convertClaudeToCopilot(plugin, defaultOptions) + expect(warnSpy).toHaveBeenCalledWith( + "Warning: Copilot does not support hooks. Hooks were skipped during conversion.", + ) + + warnSpy.mockRestore() + }) + + test("no warning when hooks are absent", () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}) + + convertClaudeToCopilot(fixturePlugin, defaultOptions) + expect(warnSpy).not.toHaveBeenCalled() + + warnSpy.mockRestore() + }) + + test("plugin with zero agents produces empty agents array", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [], + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + expect(bundle.agents).toHaveLength(0) + }) + + test("plugin with only skills works", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [], + commands: [], + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + expect(bundle.agents).toHaveLength(0) + expect(bundle.generatedSkills).toHaveLength(0) + expect(bundle.skillDirs).toHaveLength(1) + }) +}) + +describe("transformContentForCopilot", () => { + test("rewrites .claude/ paths to .github/", () => { + const input = "Read `.claude/compound-engineering.local.md` for config." + const result = transformContentForCopilot(input) + expect(result).toContain(".github/compound-engineering.local.md") + expect(result).not.toContain(".claude/") + }) + + test("rewrites ~/.claude/ paths to ~/.copilot/", () => { + const input = "Global config at ~/.claude/settings.json" + const result = transformContentForCopilot(input) + expect(result).toContain("~/.copilot/settings.json") + expect(result).not.toContain("~/.claude/") + }) + + test("transforms Task agent calls to skill references", () => { + const input = `Run agents: + +- Task repo-research-analyst(feature_description) +- Task learnings-researcher(feature_description) + +Task best-practices-researcher(topic)` + + const result = transformContentForCopilot(input) + expect(result).toContain("Use the repo-research-analyst skill to: feature_description") + expect(result).toContain("Use the learnings-researcher skill to: feature_description") + expect(result).toContain("Use the best-practices-researcher skill to: topic") + expect(result).not.toContain("Task repo-research-analyst(") + }) + + test("replaces colons with hyphens in slash commands", () => { + const input = `1. Run /deepen-plan to enhance +2. Start /workflows:work to implement +3. File at /tmp/output.md` + + const result = transformContentForCopilot(input) + expect(result).toContain("/deepen-plan") + expect(result).toContain("/workflows-work") + expect(result).not.toContain("/workflows:work") + // File paths preserved + expect(result).toContain("/tmp/output.md") + }) + + test("transforms @agent references to agent references", () => { + const input = "Have @security-sentinel and @dhh-rails-reviewer check the code." + const result = transformContentForCopilot(input) + expect(result).toContain("the security-sentinel agent") + expect(result).toContain("the dhh-rails-reviewer agent") + expect(result).not.toContain("@security-sentinel") + }) +}) diff --git a/tests/copilot-writer.test.ts b/tests/copilot-writer.test.ts new file mode 100644 index 00000000..6c430a13 --- /dev/null +++ b/tests/copilot-writer.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test } from "bun:test" +import { promises as fs } from "fs" +import path from "path" +import os from "os" +import { writeCopilotBundle } from "../src/targets/copilot" +import type { CopilotBundle } from "../src/types/copilot" + +async function exists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +describe("writeCopilotBundle", () => { + test("writes agents, generated skills, copied skills, and MCP config", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-test-")) + const bundle: CopilotBundle = { + agents: [ + { + name: "security-reviewer", + content: "---\ndescription: Security\ntools:\n - '*'\ninfer: true\n---\n\nReview code.", + }, + ], + generatedSkills: [ + { + name: "plan", + content: "---\nname: plan\ndescription: Planning\n---\n\nPlan the work.", + }, + ], + skillDirs: [ + { + name: "skill-one", + sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"), + }, + ], + mcpConfig: { + playwright: { + type: "local", + command: "npx", + args: ["-y", "@anthropic/mcp-playwright"], + tools: ["*"], + }, + }, + } + + await writeCopilotBundle(tempRoot, bundle) + + expect(await exists(path.join(tempRoot, ".github", "agents", "security-reviewer.agent.md"))).toBe(true) + expect(await exists(path.join(tempRoot, ".github", "skills", "plan", "SKILL.md"))).toBe(true) + expect(await exists(path.join(tempRoot, ".github", "skills", "skill-one", "SKILL.md"))).toBe(true) + expect(await exists(path.join(tempRoot, ".github", "copilot-mcp-config.json"))).toBe(true) + + const agentContent = await fs.readFile( + path.join(tempRoot, ".github", "agents", "security-reviewer.agent.md"), + "utf8", + ) + expect(agentContent).toContain("Review code.") + + const skillContent = await fs.readFile( + path.join(tempRoot, ".github", "skills", "plan", "SKILL.md"), + "utf8", + ) + expect(skillContent).toContain("Plan the work.") + + const mcpContent = JSON.parse( + await fs.readFile(path.join(tempRoot, ".github", "copilot-mcp-config.json"), "utf8"), + ) + expect(mcpContent.mcpServers.playwright.command).toBe("npx") + }) + + test("agents use .agent.md file extension", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-ext-")) + const bundle: CopilotBundle = { + agents: [{ name: "test-agent", content: "Agent content" }], + generatedSkills: [], + skillDirs: [], + } + + await writeCopilotBundle(tempRoot, bundle) + + expect(await exists(path.join(tempRoot, ".github", "agents", "test-agent.agent.md"))).toBe(true) + // Should NOT create a plain .md file + expect(await exists(path.join(tempRoot, ".github", "agents", "test-agent.md"))).toBe(false) + }) + + test("writes directly into .github output root without double-nesting", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-home-")) + const githubRoot = path.join(tempRoot, ".github") + const bundle: CopilotBundle = { + agents: [{ name: "reviewer", content: "Reviewer agent content" }], + generatedSkills: [{ name: "plan", content: "Plan content" }], + skillDirs: [], + } + + await writeCopilotBundle(githubRoot, bundle) + + expect(await exists(path.join(githubRoot, "agents", "reviewer.agent.md"))).toBe(true) + expect(await exists(path.join(githubRoot, "skills", "plan", "SKILL.md"))).toBe(true) + // Should NOT double-nest under .github/.github + expect(await exists(path.join(githubRoot, ".github"))).toBe(false) + }) + + test("handles empty bundles gracefully", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-empty-")) + const bundle: CopilotBundle = { + agents: [], + generatedSkills: [], + skillDirs: [], + } + + await writeCopilotBundle(tempRoot, bundle) + expect(await exists(tempRoot)).toBe(true) + }) + + test("writes multiple agents as separate .agent.md files", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-multi-")) + const githubRoot = path.join(tempRoot, ".github") + const bundle: CopilotBundle = { + agents: [ + { name: "security-sentinel", content: "Security rules" }, + { name: "performance-oracle", content: "Performance rules" }, + { name: "code-simplicity-reviewer", content: "Simplicity rules" }, + ], + generatedSkills: [], + skillDirs: [], + } + + await writeCopilotBundle(githubRoot, bundle) + + expect(await exists(path.join(githubRoot, "agents", "security-sentinel.agent.md"))).toBe(true) + expect(await exists(path.join(githubRoot, "agents", "performance-oracle.agent.md"))).toBe(true) + expect(await exists(path.join(githubRoot, "agents", "code-simplicity-reviewer.agent.md"))).toBe(true) + }) + + test("backs up existing copilot-mcp-config.json before overwriting", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-backup-")) + const githubRoot = path.join(tempRoot, ".github") + await fs.mkdir(githubRoot, { recursive: true }) + + // Write an existing config + const mcpPath = path.join(githubRoot, "copilot-mcp-config.json") + await fs.writeFile(mcpPath, JSON.stringify({ mcpServers: { old: { type: "local", command: "old-cmd", tools: ["*"] } } })) + + const bundle: CopilotBundle = { + agents: [], + generatedSkills: [], + skillDirs: [], + mcpConfig: { + newServer: { type: "local", command: "new-cmd", tools: ["*"] }, + }, + } + + await writeCopilotBundle(githubRoot, bundle) + + // New config should have the new content + const newContent = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(newContent.mcpServers.newServer.command).toBe("new-cmd") + + // A backup file should exist + const files = await fs.readdir(githubRoot) + const backupFiles = files.filter((f) => f.startsWith("copilot-mcp-config.json.bak.")) + expect(backupFiles.length).toBeGreaterThanOrEqual(1) + }) + + test("creates skill directories with SKILL.md", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-genskill-")) + const bundle: CopilotBundle = { + agents: [], + generatedSkills: [ + { + name: "deploy", + content: "---\nname: deploy\ndescription: Deploy skill\n---\n\nDeploy steps.", + }, + ], + skillDirs: [], + } + + await writeCopilotBundle(tempRoot, bundle) + + const skillPath = path.join(tempRoot, ".github", "skills", "deploy", "SKILL.md") + expect(await exists(skillPath)).toBe(true) + + const content = await fs.readFile(skillPath, "utf8") + expect(content).toContain("Deploy steps.") + }) +}) diff --git a/tests/sync-copilot.test.ts b/tests/sync-copilot.test.ts new file mode 100644 index 00000000..70822634 --- /dev/null +++ b/tests/sync-copilot.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test } from "bun:test" +import { promises as fs } from "fs" +import path from "path" +import os from "os" +import { syncToCopilot } from "../src/sync/copilot" +import type { ClaudeHomeConfig } from "../src/parsers/claude-home" + +describe("syncToCopilot", () => { + test("symlinks skills to .github/skills/", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-")) + const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one") + + const config: ClaudeHomeConfig = { + skills: [ + { + name: "skill-one", + sourceDir: fixtureSkillDir, + skillPath: path.join(fixtureSkillDir, "SKILL.md"), + }, + ], + mcpServers: {}, + } + + await syncToCopilot(config, tempRoot) + + const linkedSkillPath = path.join(tempRoot, "skills", "skill-one") + const linkedStat = await fs.lstat(linkedSkillPath) + expect(linkedStat.isSymbolicLink()).toBe(true) + }) + + test("skips skills with invalid names", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-invalid-")) + + const config: ClaudeHomeConfig = { + skills: [ + { + name: "../escape-attempt", + sourceDir: "/tmp/bad-skill", + skillPath: "/tmp/bad-skill/SKILL.md", + }, + ], + mcpServers: {}, + } + + await syncToCopilot(config, tempRoot) + + const skillsDir = path.join(tempRoot, "skills") + const entries = await fs.readdir(skillsDir).catch(() => []) + expect(entries).toHaveLength(0) + }) + + test("merges MCP config with existing file", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-merge-")) + const mcpPath = path.join(tempRoot, "copilot-mcp-config.json") + + await fs.writeFile( + mcpPath, + JSON.stringify({ + mcpServers: { + existing: { type: "local", command: "node", args: ["server.js"], tools: ["*"] }, + }, + }, null, 2), + ) + + const config: ClaudeHomeConfig = { + skills: [], + mcpServers: { + context7: { url: "https://mcp.context7.com/mcp" }, + }, + } + + await syncToCopilot(config, tempRoot) + + const merged = JSON.parse(await fs.readFile(mcpPath, "utf8")) as { + mcpServers: Record + } + + expect(merged.mcpServers.existing?.command).toBe("node") + expect(merged.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp") + }) + + test("transforms MCP env var names to COPILOT_MCP_ prefix", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-env-")) + + const config: ClaudeHomeConfig = { + skills: [], + mcpServers: { + server: { + command: "echo", + args: ["hello"], + env: { API_KEY: "secret", COPILOT_MCP_TOKEN: "already-prefixed" }, + }, + }, + } + + await syncToCopilot(config, tempRoot) + + const mcpPath = path.join(tempRoot, "copilot-mcp-config.json") + const mcpConfig = JSON.parse(await fs.readFile(mcpPath, "utf8")) as { + mcpServers: Record }> + } + + expect(mcpConfig.mcpServers.server?.env).toEqual({ + COPILOT_MCP_API_KEY: "secret", + COPILOT_MCP_TOKEN: "already-prefixed", + }) + }) + + test("writes MCP config with restricted permissions", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-perms-")) + + const config: ClaudeHomeConfig = { + skills: [], + mcpServers: { + server: { command: "echo", args: ["hello"] }, + }, + } + + await syncToCopilot(config, tempRoot) + + const mcpPath = path.join(tempRoot, "copilot-mcp-config.json") + const stat = await fs.stat(mcpPath) + // Check owner read+write permission (0o600 = 33216 in decimal, masked to file perms) + const perms = stat.mode & 0o777 + expect(perms).toBe(0o600) + }) + + test("does not write MCP config when no MCP servers", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-nomcp-")) + const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one") + + const config: ClaudeHomeConfig = { + skills: [ + { + name: "skill-one", + sourceDir: fixtureSkillDir, + skillPath: path.join(fixtureSkillDir, "SKILL.md"), + }, + ], + mcpServers: {}, + } + + await syncToCopilot(config, tempRoot) + + const mcpExists = await fs.access(path.join(tempRoot, "copilot-mcp-config.json")).then(() => true).catch(() => false) + expect(mcpExists).toBe(false) + }) +})