diff --git a/README.md b/README.md index b190a52eb..6263bfb16 100644 --- a/README.md +++ b/README.md @@ -408,8 +408,9 @@ cd ECC | Kimi Code CLI | `./install.sh --profile minimal --target kimi` | Project-local `.kimi-code/` install · [Get Kimi Code](https://www.kimi.ai/code?aff=ecc) | | CodeBuddy | `./install.sh --profile minimal --target codebuddy` | Project-local `.codebuddy/` install | | JoyCode | `./install.sh --profile minimal --target joycode` | Project-local `.joycode/` install | +| GitHub Copilot | `./install.sh --profile full --target copilot` | Installs skills to `.github/skills/` and agents to `.github/agents/` for Copilot CLI | -GitHub Copilot support is already included in this repository. `.github/copilot-instructions.md` provides the instruction layer, `.github/prompts/` contains the reusable `/plan`, `/tdd`, `/security-review`, `/build-fix`, and `/refactor` prompts, and `.vscode/settings.json` enables `chat.promptFiles`. +GitHub Copilot support is already included in this repository. `.github/copilot-instructions.md` provides the instruction layer, `.github/prompts/` contains the reusable `/plan`, `/tdd`, `/security-review`, `/build-fix`, and `/refactor` prompts, and `.vscode/settings.json` enables `chat.promptFiles`. GitHub Copilot CLI additionally discovers the `.agents/skills/` subset from a plain clone with no setup; see [Platform Support](#platform-support) for the full catalog and agents. For a harness without a native ECC target, use the [manual adaptation guide](docs/MANUAL-ADAPTATION-GUIDE.md). It explains how to carry a small set of ECC skills and workflow instructions into chat-style tools without pretending hooks or native skill discovery are available. @@ -1291,7 +1292,8 @@ Treat `stable`, `beta`, `experimental`, and `instruction-only` below as capabili | Codex | Supported native plugin | Codex marketplace plugin or repo config | Native hooks require an explicit trust decision and do not use Claude's hook profiles. The legacy sync is compatibility-only. | | Cursor | Beta project adapter | Selective installer into `.cursor/` | Agent discovery varies by Cursor build, and ECC's installer paths do not yet expose identical hook sets ([#2419](https://github.com/affaan-m/ECC/issues/2419)). | | OpenCode | Beta built plugin | Build plugin, then selective installer | ECC ships a subset of the catalog; connect a provider and select a model in OpenCode ([#2617](https://github.com/affaan-m/ECC/issues/2617)). | -| GitHub Copilot | Instruction-only | Checked-in instructions and prompt files | No ECC hooks, runtime agents, delegation, or native skill discovery. | +| GitHub Copilot Chat | Instruction-only | Checked-in instructions and prompt files | ECC hooks are not adapted. | +| GitHub Copilot CLI | Beta project adapter | `--target copilot` places skills and adapted agents in `.github/` | ECC hooks, rules, and command shims are not installed by this target. | | Gemini, Zed, Antigravity, Qwen, Hermes, OpenClaw, Kimi, CodeBuddy, JoyCode | Experimental/minimal adapters | Harness-specific selective target | File placement and instruction portability are tested; full Claude feature parity is not claimed. |
@@ -1400,16 +1402,16 @@ See [affaan-m/ECC#2065](https://github.com/affaan-m/ECC/issues/2065). | Capability | Claude Code | Codex | Cursor | OpenCode | GitHub Copilot | |---|---|---|---|---|---| | Instructions | Native | Native `AGENTS.md` | Project rules | Plugin instructions | Native instruction file | -| Skills | Native installed set | Native plugin set | Build-dependent/project set | Built subset | Prompt/instruction references only | -| Agents/delegation | Native agents | Codex multi-agent roles; Claude agent files are not installed as roles | Build-dependent project agents | Plugin agents | Not supported | -| ECC hooks | Native plugin hooks | Native reviewed subset with explicit trust | Cursor hook adapter; install-path differences remain | Plugin events | Not supported | +| Skills | Native installed set | Native plugin set | Build-dependent/project set | Built subset | Native discovery (Copilot CLI) from `.github/skills/`, `.agents/skills/`, `.claude/skills/` | +| Agents/delegation | Native agents | Codex multi-agent roles; Claude agent files are not installed as roles | Build-dependent project agents | Plugin agents | Copilot CLI custom agents via `.github/agents/` and `--agent` | +| ECC hooks | Native plugin hooks | Native reviewed subset with explicit trust | Cursor hook adapter; install-path differences remain | Plugin events | Not ported; Copilot CLI has its own plugin hook system | | MCP configuration | Available, explicit activation | Native plugin manifest; legacy sync can merge TOML | Explicit project/user config | Provider/plugin config | Not supplied by ECC | -| Parity with Claude Code | Primary reference | Partial | Partial | Partial | Not a parity target | +| Parity with Claude Code | Primary reference | Partial | Partial | Partial | Partial | **Key architectural decisions:** - **AGENTS.md** at root is the universal cross-tool file (read by Claude Code, Cursor, Codex, and OpenCode; GitHub Copilot uses `.github/copilot-instructions.md` instead) - **DRY adapter pattern** lets Cursor reuse Claude Code's hook scripts without duplication -- **Skills format** (SKILL.md with YAML frontmatter) works across Claude Code, Codex, and OpenCode +- **Skills format** (SKILL.md with YAML frontmatter) works across Claude Code, Codex, OpenCode, and GitHub Copilot CLI - Codex's narrower native hook set is supplemented by `AGENTS.md`, optional `model_instructions_file` overrides, and sandbox permissions
@@ -1653,7 +1655,7 @@ For the full ECC OpenCode setup, either:
GitHub Copilot support in depth -ECC provides **GitHub Copilot support** for VS Code via Copilot Chat's native instruction and prompt file system. No extra tooling required. +ECC supports **GitHub Copilot** on two surfaces: Copilot Chat in VS Code, via its native instruction and prompt file system, and **GitHub Copilot CLI**, which discovers the shared skills already present in this repository. The optional `copilot` install target places canonical skills and adapted agents in the CLI's native project directories. #### What's included for GitHub Copilot @@ -1674,6 +1676,99 @@ To use the workflow prompts in Copilot Chat: 2. Click the **paperclip / attach** icon and select **Prompt...**, or type `/` and choose a prompt. 3. Select the prompt (e.g. `plan`, `tdd`, `security-review`). +#### Copilot CLI: skills and agents + +GitHub Copilot CLI discovers `SKILL.md` skills from `.github/skills/`, +`.agents/skills/`, `.claude/skills/`, `~/.copilot/skills/`, and +`~/.agents/skills/`, and custom agents from `.github/agents/`. ECC's skill and +skill frontmatter is compatible. The managed `copilot` target adapts agent +frontmatter to `name` and `description`, removing Claude-specific model and +tool keys. Raw manual agent copies can retain unsupported metadata. + +Because ECC ships a skill subset in `.agents/skills/`, a plain clone of this +repository is picked up by Copilot CLI with no configuration at all: + +```bash +git clone https://github.com/affaan-m/ECC.git && cd ECC +copilot skill list +# The inventory depends on the checkout and CLI version. +``` + +In the contributor's September 2026 checkout, Copilot CLI 1.0.83 reported all 42 under `Project skills` with `source: +"project"` and `enabled: true`. The three from `.claude/commands/` are Claude +command files that Copilot surfaces as skills; they are reported with `path` +set to the `.claude/commands` directory rather than to a per-skill subdirectory. + +To check this with a temporary npm-distributed CLI, run the pinned build +against a throwaway clone: + +```bash +git clone --depth 1 https://github.com/affaan-m/ECC.git && cd ECC +npx -y @github/copilot@1.0.82 skill list --json | jq '[.[] | select(.source=="project")] | length' +# Count the current inventory rather than assuming a fixed total. +``` + +That checkout's 39 + 3 split was reported by 1.0.82 from npm and by 1.0.83 installed +locally, so the behaviour is not specific to one build or install method. + +To expose the full catalog, place `skills/` where Copilot CLI looks: + +```bash +mkdir -p .github/skills +cp -r skills/. .github/skills/ +copilot skill list --json > copilot-skills.json +jq '[.[] | select(.path | contains(".github/skills"))] | length' copilot-skills.json +# Compare with the current canonical skill catalog. +``` + +Filter on the path rather than on `source`: the CLI can also discover skills +from other project locations and de-duplicates entries with the same skill +name. Counts depend on the checkout and CLI version. The file redirection also +avoids truncated large JSON output observed with Copilot CLI 1.0.82 on macOS. + +Copilot registers a skill under the `name` in its `SKILL.md` frontmatter, not +its directory name, so catalog folders whose names differ from their frontmatter +(for example `scientific-db-pubmed-database`, which declares +`name: pubmed-database`) still load — they are just listed under the frontmatter +name. + +Agents work the same way: + +```bash +./install.sh --profile full --target copilot +copilot --agent architect -p "Review this module's boundaries" +``` + +The contributor verified native discovery with Copilot CLI 1.0.83. Manually +copied agent files that declare a Claude model +name (`model: opus`, `model: sonnet`) emit a warning and fall back to the +session's default model, so remove or remap `model:` if you want to pin one. + +#### Installing with the `copilot` target + +The steps above copy files by hand. The `copilot` install target does the same +placement and additionally rewrites agent frontmatter so no warning is emitted: + +```bash +./install.sh --profile full --target copilot +# Windows: ./install.ps1 --profile full --target copilot +``` + +That writes skills to `.github/skills/`, agents to `.github/agents/`, and an +install-state file to `.github/ecc-install-state.json`. Nothing is written +outside `.github/`. + +The `full` profile selects the modules declared for Copilot. It can place fewer +skills than a manual copy of the entire canonical catalog because modules +that require another host's runtime are excluded. List the resulting native +inventory to see what this checkout installs; a count is not a capability grant. + +Claude-only agent frontmatter (`model`, `tools`, `color`) is dropped during the +copy: Copilot resolves the model from your own configuration, and it governs +tool access per session through `--allow-tool`/`--deny-tool` rather than through +agent frontmatter. ECC's rules, commands, and hooks are not installed by this +target. + #### Feature coverage | ECC Feature | Copilot equivalent | @@ -1686,12 +1781,24 @@ To use the workflow prompts in Copilot Chat: | Build error resolution | `build-fix` prompt | | Refactoring | `refactor` prompt | | Commit message format | Per-task instruction in `settings.json` | -| Hooks / automation | Not supported (Copilot has no hook system) | -| Agents / delegation | Not supported (Copilot has no subagent API) | +| Skills | Native discovery in Copilot CLI (`.github/skills/`, `.agents/skills/`) | +| Agents / delegation | Copilot CLI custom agents (`.github/agents/`, `--agent`) | +| Hooks / automation | ECC's hooks are not ported (Copilot CLI has its own plugin hook system) | #### Limitations -GitHub Copilot does not have a hook system or a subagent API, so ECC's hook automations (auto-format, TypeScript check, session persistence, dev-server guard) and agent delegation are unavailable. The instruction and prompt layer still brings the full ECC coding philosophy (standards, security, TDD, and workflow) into every Copilot Chat session. +ECC's hook automations (auto-format, TypeScript check, session persistence, +dev-server guard) are written against Claude Code's hook event model and are +not ported to Copilot. Copilot CLI does expose a hook system through its plugin +format, but ECC does not currently ship a Copilot plugin, so none of ECC's hooks +run there. + +ECC also does not install rules, commands, or hooks for Copilot. The `copilot` +target installs skills and agents only. Skills that shell out to bundled +scripts assume the same interpreters as on Claude Code. Copilot Chat in VS Code +does not read `.github/skills/` or `.github/agents/`; those paths are Copilot +CLI surfaces. The instruction and prompt layer is what carries ECC's coding +philosophy into Copilot Chat sessions.
diff --git a/manifests/install-modules.json b/manifests/install-modules.json index 2838a13f9..85d4e3b1b 100644 --- a/manifests/install-modules.json +++ b/manifests/install-modules.json @@ -12,6 +12,7 @@ "claude", "claude-project", "cursor", + "copilot", "antigravity", "codebuddy", "joycode", @@ -42,6 +43,7 @@ "cursor", "antigravity", "codex", + "copilot", "codebuddy", "joycode", "qwen", @@ -69,6 +71,7 @@ "claude", "claude-project", "cursor", + "copilot", "antigravity", "opencode", "codebuddy", @@ -131,6 +134,7 @@ "claude", "claude-project", "cursor", + "copilot", "antigravity", "codex", "gemini", @@ -232,6 +236,7 @@ "cursor", "antigravity", "codex", + "copilot", "opencode", "codebuddy", "joycode", @@ -268,6 +273,7 @@ "cursor", "antigravity", "codex", + "copilot", "opencode", "codebuddy", "joycode", @@ -294,6 +300,7 @@ "cursor", "antigravity", "codex", + "copilot", "gemini", "opencode", "codebuddy", @@ -371,6 +378,7 @@ "cursor", "antigravity", "codex", + "copilot", "opencode", "codebuddy", "joycode", @@ -438,6 +446,7 @@ "cursor", "antigravity", "codex", + "copilot", "opencode", "codebuddy", "joycode", @@ -484,6 +493,7 @@ "cursor", "antigravity", "codex", + "copilot", "opencode", "codebuddy", "joycode", @@ -518,6 +528,7 @@ "cursor", "antigravity", "codex", + "copilot", "opencode", "codebuddy", "joycode", @@ -557,6 +568,7 @@ "cursor", "antigravity", "codex", + "copilot", "opencode", "codebuddy", "joycode", @@ -601,6 +613,7 @@ "cursor", "antigravity", "codex", + "copilot", "opencode", "codebuddy", "joycode", @@ -629,6 +642,7 @@ "cursor", "antigravity", "codex", + "copilot", "opencode", "codebuddy", "joycode", @@ -686,6 +700,7 @@ "cursor", "antigravity", "codex", + "copilot", "gemini", "opencode", "codebuddy", @@ -717,6 +732,7 @@ "cursor", "antigravity", "codex", + "copilot", "gemini", "opencode", "codebuddy", @@ -749,6 +765,7 @@ "cursor", "antigravity", "codex", + "copilot", "opencode", "codebuddy", "joycode", @@ -784,6 +801,7 @@ "claude-project", "cursor", "codex", + "copilot", "opencode", "codebuddy", "joycode", @@ -842,6 +860,7 @@ "cursor", "antigravity", "codex", + "copilot", "opencode", "codebuddy", "joycode", @@ -903,6 +922,7 @@ "cursor", "antigravity", "codex", + "copilot", "opencode", "codebuddy", "joycode", @@ -945,6 +965,7 @@ "cursor", "antigravity", "codex", + "copilot", "opencode", "codebuddy", "joycode", @@ -974,6 +995,7 @@ "cursor", "antigravity", "codex", + "copilot", "opencode", "codebuddy", "joycode", @@ -1011,6 +1033,7 @@ "cursor", "antigravity", "codex", + "copilot", "opencode", "codebuddy", "joycode", @@ -1038,6 +1061,7 @@ "cursor", "antigravity", "codex", + "copilot", "opencode", "codebuddy", "joycode", diff --git a/schemas/ecc-install-config.schema.json b/schemas/ecc-install-config.schema.json index 29b57538f..975cc4512 100644 --- a/schemas/ecc-install-config.schema.json +++ b/schemas/ecc-install-config.schema.json @@ -23,6 +23,7 @@ "cursor", "antigravity", "codex", + "copilot", "gemini", "opencode", "codebuddy", diff --git a/schemas/install-modules.schema.json b/schemas/install-modules.schema.json index 620f616dc..cbdc0eb5c 100644 --- a/schemas/install-modules.schema.json +++ b/schemas/install-modules.schema.json @@ -53,6 +53,7 @@ "cursor", "antigravity", "codex", + "copilot", "gemini", "opencode", "codebuddy", diff --git a/scripts/install-apply.js b/scripts/install-apply.js index 722f7d6b6..8c55049a4 100755 --- a/scripts/install-apply.js +++ b/scripts/install-apply.js @@ -39,6 +39,7 @@ Targets: cursor - Install rules, hooks, and bundled Cursor configs to ./.cursor/ antigravity - Install rules, workflows, skills, and agents to ./.agents/ codex - Install shared agents/config into ~/.codex/ + copilot - Install skills and agents into ./.github/ for GitHub Copilot CLI (ECC hooks not configured) gemini - Install project-local Gemini config into ./.gemini/ opencode - Install into OPENCODE_CONFIG_DIR, XDG_CONFIG_HOME/opencode, or ~/.config/opencode/ codebuddy - Install commands, agents, skills, and flattened rules into ./.codebuddy/ diff --git a/scripts/lib/harness-capabilities.js b/scripts/lib/harness-capabilities.js index c42f0488e..8e10e622f 100644 --- a/scripts/lib/harness-capabilities.js +++ b/scripts/lib/harness-capabilities.js @@ -114,6 +114,23 @@ const HARNESS_CAPABILITIES = deepFreeze([ hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'), aliases: ['google-antigravity'], }, + { + id: 'copilot', + label: 'GitHub Copilot', + targetIds: ['copilot'], + channel: 'managed-project', + installMode: 'managed-project', + guidedReady: false, + availability: 'advanced', + destination: './.github', + scopes: [scope('project', 'copilot', './.github')], + hooks: hooks( + 'not-configured', + false, + 'ECC hooks target Claude Code events and are not installed for Copilot.' + ), + aliases: ['github-copilot', 'copilot-cli'], + }, { id: 'gemini', label: 'Gemini CLI', diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index 98abf8e20..d0750cf86 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -42,6 +42,7 @@ const { validateManagedHooks, } = require('./install/claude-settings'); const { adaptAntigravityAgent } = require('./install/antigravity-agent'); +const { adaptCopilotAgent } = require('./install/copilot-agent'); const { buildInstallIndex, rewriteRelativeLinks } = require('./install/link-rewrite'); const { getInstallTargetAdapter, listInstallTargetAdapters } = require('./install-targets/registry'); const { resolveInvocationEnvironment } = require('./invocation-environment'); @@ -245,6 +246,9 @@ function transformCopyFileContent(operation, content) { if (operation.contentTransform === 'opencode-disable-plugin-entrypoint') { return getDisabledOpenCodePluginContent(); } + if (operation.contentTransform === 'copilot-agent-frontmatter') { + return adaptCopilotAgent(content, operation.sourceRelativePath); + } throw new Error(`Unknown install content transform: ${operation.contentTransform}`); } @@ -1919,9 +1923,11 @@ function prepareRepairMigration(plan, record) { statePreview: buildAdapterDerivedStatePreview(plan.statePreview, record), }; const initialMigration = prepareClaudeSkillMigration(trustedPlan); - const guardedMigration = record.adapter.id === 'codex-home' - ? prepareUserOwnedFileGuard(trustedPlan, initialMigration) - : initialMigration; + // Repair normalizes recorded target metadata to the adapter-derived roots. + // Preserve its recorded operations as the ownership evidence. + const guardedMigration = prepareUserOwnedFileGuard( + trustedPlan, initialMigration, buildAdapterDerivedStatePreview(record.state, record) + ); const migration = trustedPlan.target === 'opencode' ? require('./install/apply').prepareHookConsentMigration(trustedPlan, guardedMigration) : guardedMigration; @@ -2240,9 +2246,7 @@ function repairInstalledStates(options = {}) { const { assertOpenCodeActivationUnchanged } = require('./install/apply'); assertOpenCodeActivationUnchanged(desiredPlan, operation, activationSnapshot); } - if (record.adapter.id === 'codex-home') { - assertNoNewUserOwnedFile(migration, operation, desiredPlan); - } + assertNoNewUserOwnedFile(migration, operation, desiredPlan); const repairedPath = executeRepairOperation( context.repoRoot, operation, diff --git a/scripts/lib/install-manifests.js b/scripts/lib/install-manifests.js index cef86ffaa..36d7c8834 100644 --- a/scripts/lib/install-manifests.js +++ b/scripts/lib/install-manifests.js @@ -5,7 +5,7 @@ const { getInstallTargetAdapter, planInstallTargetScaffold } = require('./instal const { resolveInvocationEnvironment } = require('./invocation-environment'); const DEFAULT_REPO_ROOT = path.join(__dirname, '../..'); -const SUPPORTED_INSTALL_TARGETS = ['claude', 'claude-project', 'cursor', 'antigravity', 'codex', 'gemini', 'opencode', 'codebuddy', 'joycode', 'qwen', 'zed', 'hermes', 'openclaw', 'kimi', 'adal']; +const SUPPORTED_INSTALL_TARGETS = ['claude', 'claude-project', 'cursor', 'antigravity', 'codex', 'copilot', 'gemini', 'opencode', 'codebuddy', 'joycode', 'qwen', 'zed', 'hermes', 'openclaw', 'kimi', 'adal']; const COMPONENT_FAMILY_PREFIXES = { baseline: 'baseline:', language: 'lang:', diff --git a/scripts/lib/install-targets/copilot-project.js b/scripts/lib/install-targets/copilot-project.js new file mode 100644 index 000000000..72bccf4e3 --- /dev/null +++ b/scripts/lib/install-targets/copilot-project.js @@ -0,0 +1,97 @@ +const path = require('path'); + +const { + createInstallTargetAdapter, + createManagedOperation, + createManagedScaffoldOperation, + normalizeRelativePath, +} = require('./helpers'); + +// GitHub Copilot CLI discovers skills from `.github/skills/` and custom agents +// from `.github/agents/`. Those two directories are the entire supported +// surface for this target: ECC's hooks target Claude Code's event model, and +// commands rely on slash-command argument substitution Copilot does not +// provide, so neither is installed here. +const SUPPORTED_SOURCE_PREFIXES = ['agents', 'skills']; + +function hasPrefix(normalizedPath, prefix) { + return normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`); +} + +function supportsCopilotSourcePath(sourceRelativePath) { + const normalizedPath = normalizeRelativePath(sourceRelativePath); + if (normalizedPath.split('/').includes('..')) return false; + return SUPPORTED_SOURCE_PREFIXES.some(prefix => hasPrefix(normalizedPath, prefix)); +} + +function stripPrefix(normalizedPath, prefix) { + return normalizedPath === prefix ? '' : normalizedPath.slice(`${prefix}/`.length); +} + +function planSourcePathOperations(module, sourceRelativePath, targetRoot) { + const normalizedSourcePath = normalizeRelativePath(sourceRelativePath); + + if (hasPrefix(normalizedSourcePath, 'agents')) { + return [ + createManagedOperation({ + moduleId: module.id, + sourceRelativePath: normalizedSourcePath, + destinationPath: path.join( + targetRoot, + 'agents', + stripPrefix(normalizedSourcePath, 'agents') + ), + strategy: 'preserve-relative-path', + contentTransform: 'copilot-agent-frontmatter', + }), + ]; + } + + if (hasPrefix(normalizedSourcePath, 'skills')) { + return [ + createManagedScaffoldOperation( + module.id, + normalizedSourcePath, + path.join(targetRoot, 'skills', stripPrefix(normalizedSourcePath, 'skills')), + 'preserve-relative-path' + ), + ]; + } + + return []; +} + +module.exports = createInstallTargetAdapter({ + id: 'copilot-project', + target: 'copilot', + kind: 'project', + rootSegments: ['.github'], + installStatePathSegments: ['ecc-install-state.json'], + supportsModule(module) { + // Selection gating stays permissive so modules that only act as dependency + // anchors (rules-core, commands-core, platform-configs) still resolve. + // planOperations() is what narrows the install to agents and skills, so an + // unsupported path contributes zero operations rather than skipping the + // module and every module that depends on it. + const paths = Array.isArray(module && module.paths) ? module.paths : []; + return paths.length > 0; + }, + planOperations(input, adapter) { + const modules = Array.isArray(input.modules) + ? input.modules + : (input.module ? [input.module] : []); + const { repoRoot, projectRoot, homeDir } = input; + const targetRoot = adapter.resolveRoot({ repoRoot, projectRoot, homeDir }); + + return modules.flatMap(module => { + const paths = Array.isArray(module.paths) ? module.paths : []; + return paths + .filter(supportsCopilotSourcePath) + .flatMap(sourceRelativePath => planSourcePathOperations( + module, + sourceRelativePath, + targetRoot + )); + }); + }, +}); diff --git a/scripts/lib/install-targets/registry.js b/scripts/lib/install-targets/registry.js index 8dd7cf848..0bc1df46a 100644 --- a/scripts/lib/install-targets/registry.js +++ b/scripts/lib/install-targets/registry.js @@ -4,6 +4,7 @@ const claudeHome = require('./claude-home'); const claudeProject = require('./claude-project'); const codebuddyProject = require('./codebuddy-project'); const codexHome = require('./codex-home'); +const copilotProject = require('./copilot-project'); const cursorProject = require('./cursor-project'); const geminiProject = require('./gemini-project'); const hermesHome = require('./hermes-home'); @@ -21,6 +22,7 @@ const ADAPTERS = Object.freeze([ cursorProject, antigravityProject, codexHome, + copilotProject, geminiProject, hermesHome, opencodeHome, diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index 209e53784..d2e597d13 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -52,6 +52,7 @@ const { } = require('./excluded-paths-reconciliation'); const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite'); const { adaptAntigravityAgent } = require('./antigravity-agent'); +const { adaptCopilotAgent } = require('./copilot-agent'); function isMarkdownPath(filePath) { return /\.(md|mdx|markdown)$/i.test(String(filePath || '')); @@ -70,6 +71,9 @@ function transformInstallContent(operation, content) { if (operation.contentTransform === 'opencode-disable-plugin-entrypoint') { return getDisabledOpenCodePluginContent(); } + if (operation.contentTransform === 'copilot-agent-frontmatter') { + return adaptCopilotAgent(content, operation.sourceRelativePath); + } throw new Error(`Unknown install content transform: ${operation.contentTransform}`); } diff --git a/scripts/lib/install/copilot-agent.js b/scripts/lib/install/copilot-agent.js new file mode 100644 index 000000000..0d82d25ac --- /dev/null +++ b/scripts/lib/install/copilot-agent.js @@ -0,0 +1,70 @@ +'use strict'; + +// GitHub Copilot CLI custom agents are markdown files with `name` and +// `description` frontmatter. Claude Code's agent files additionally carry +// `model`, `tools`, and `color`, none of which transfer cleanly: +// +// model Copilot CLI resolves the session model from user configuration and +// plan entitlement. Carrying a Claude model id ("opus", "sonnet") +// makes Copilot emit "specifies model ... which is not available" on +// every invocation and silently fall back. Dropping the key selects +// the session default with no warning. +// tools Claude tool names (Read, Grep, Bash, ...) are not Copilot CLI tool +// names. Copilot governs tool access at the session level through +// --allow-tool/--deny-tool, so carrying the Claude list would assert +// a restriction Copilot does not actually apply. +// color Claude Code presentation only. +// +// The transform is therefore an allowlist rather than a denylist: unknown keys +// added to ECC agents later cannot silently leak into the Copilot copy. +const SUPPORTED_FRONTMATTER_KEYS = Object.freeze(['name', 'description']); + +const REQUIRED_FRONTMATTER_KEYS = Object.freeze(['name', 'description']); + +function splitFrontmatter(source, label) { + const match = String(source || '').match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/); + if (!match) { + throw new Error(`Cannot adapt Copilot agent ${label}: missing YAML frontmatter`); + } + + // Keep YAML loading behind the transform boundary. Public help commands load + // the installer graph without executing a transform, including in hermetic + // packed-artifact checks where runtime dependencies are intentionally absent. + const frontmatter = require('js-yaml').load(match[1]); + if (!frontmatter || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) { + throw new Error(`Cannot adapt Copilot agent ${label}: frontmatter must be an object`); + } + + return { + frontmatter, + body: source.slice(match[0].length), + }; +} + +function adaptCopilotAgent(source, label = '') { + const { frontmatter, body } = splitFrontmatter(source, label); + + for (const key of REQUIRED_FRONTMATTER_KEYS) { + const value = frontmatter[key]; + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`Cannot adapt Copilot agent ${label}: missing required frontmatter "${key}"`); + } + } + + const adapted = {}; + for (const key of SUPPORTED_FRONTMATTER_KEYS) { + if (Object.hasOwn(frontmatter, key)) { + adapted[key] = frontmatter[key]; + } + } + + const serialized = require('js-yaml') + .dump(adapted, { lineWidth: -1, noRefs: true }) + .trimEnd(); + return `---\n${serialized}\n---\n${body}`; +} + +module.exports = { + adaptCopilotAgent, + SUPPORTED_FRONTMATTER_KEYS, +}; diff --git a/scripts/lib/install/ownership-guard.js b/scripts/lib/install/ownership-guard.js index 965761cdc..e159dc447 100644 --- a/scripts/lib/install/ownership-guard.js +++ b/scripts/lib/install/ownership-guard.js @@ -37,10 +37,10 @@ function comparablePath(filePath) { * * All managed targets share this ownership boundary (#2964). */ -function prepareUserOwnedFileGuard(plan, migration) { - const previousState = pathExists(plan.installStatePath) - ? readInstallState(plan.installStatePath) - : null; +function prepareUserOwnedFileGuard(plan, migration, recordedState) { + const previousState = recordedState === undefined + ? (pathExists(plan.installStatePath) ? readInstallState(plan.installStatePath) : null) + : recordedState; if (previousState && ( previousState.target.id !== plan.adapter.id || comparablePath(previousState.target.root) !== comparablePath(plan.targetRoot) diff --git a/tests/lib/copilot-install-target.test.js b/tests/lib/copilot-install-target.test.js new file mode 100644 index 000000000..153d54639 --- /dev/null +++ b/tests/lib/copilot-install-target.test.js @@ -0,0 +1,206 @@ +/** + * Contract tests for the GitHub Copilot install target. + * + * Covers the frontmatter transform in isolation and the adapter's operation + * planning, including the containment guarantee that nothing is written + * outside the project's .github/ directory. + */ + +'use strict'; + +const assert = require('assert'); +const path = require('path'); + +const REPO_ROOT = path.resolve(__dirname, '..', '..'); +const { adaptCopilotAgent } = require(path.join(REPO_ROOT, 'scripts', 'lib', 'install', 'copilot-agent')); +const copilotAdapter = require(path.join(REPO_ROOT, 'scripts', 'lib', 'install-targets', 'copilot-project')); +const { getInstallTargetAdapter } = require(path.join(REPO_ROOT, 'scripts', 'lib', 'install-targets', 'registry')); +const { SUPPORTED_INSTALL_TARGETS } = require(path.join(REPO_ROOT, 'scripts', 'lib', 'install-manifests')); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + passed += 1; + } catch (error) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${error.message}`); + failed += 1; + } +} + +const CLAUDE_AGENT = [ + '---', + 'name: architect', + 'description: Software architecture specialist.', + 'tools: Read, Grep, Glob', + 'model: opus', + 'color: teal', + '---', + '', + '## Role', + '', + 'You are an architect.', + '', +].join('\n'); + +function planFor(modules) { + return copilotAdapter.planOperations({ + repoRoot: '/repo', + projectRoot: '/project', + homeDir: '/home/user', + modules, + }); +} + +console.log('=== Testing Copilot install target ===\n'); + +test('registry resolves the copilot target and adapter id', () => { + assert.strictEqual(getInstallTargetAdapter('copilot').id, 'copilot-project'); + assert.strictEqual(getInstallTargetAdapter('copilot-project').id, 'copilot-project'); + assert.strictEqual(copilotAdapter.target, 'copilot'); + assert.strictEqual(copilotAdapter.kind, 'project'); +}); + +test('copilot is a supported install target', () => { + assert.ok(SUPPORTED_INSTALL_TARGETS.includes('copilot')); +}); + +test('transform keeps only name and description', () => { + const adapted = adaptCopilotAgent(CLAUDE_AGENT, 'agents/architect.md'); + assert.match(adapted, /^---\n/); + assert.match(adapted, /name: architect/); + assert.match(adapted, /description: Software architecture specialist\./); + assert.doesNotMatch(adapted, /^model:/m); + assert.doesNotMatch(adapted, /^tools:/m); + assert.doesNotMatch(adapted, /^color:/m); +}); + +test('transform preserves the agent body verbatim', () => { + const adapted = adaptCopilotAgent(CLAUDE_AGENT, 'agents/architect.md'); + assert.ok(adapted.includes('## Role')); + assert.ok(adapted.includes('You are an architect.')); +}); + +test('transform rejects a file without frontmatter', () => { + assert.throws( + () => adaptCopilotAgent('# no frontmatter\n', 'agents/broken.md'), + /missing YAML frontmatter/ + ); +}); + +test('transform rejects frontmatter that is not an object', () => { + assert.throws( + () => adaptCopilotAgent('---\n- a\n- b\n---\nbody\n', 'agents/broken.md'), + /frontmatter must be an object/ + ); +}); + +test('transform requires name and description', () => { + assert.throws( + () => adaptCopilotAgent('---\nname: only-name\n---\nbody\n', 'agents/broken.md'), + /missing required frontmatter "description"/ + ); + assert.throws( + () => adaptCopilotAgent('---\ndescription: only description\n---\nbody\n', 'agents/broken.md'), + /missing required frontmatter "name"/ + ); +}); + +test('agents are planned into .github/agents with the frontmatter transform', () => { + const operations = planFor([{ id: 'agents-core', paths: ['agents'] }]); + assert.strictEqual(operations.length, 1); + assert.strictEqual(operations[0].contentTransform, 'copilot-agent-frontmatter'); + assert.strictEqual(operations[0].destinationPath, path.join('/project', '.github', 'agents')); +}); + +test('skills are planned into .github/skills without a transform', () => { + const operations = planFor([{ id: 'workflow-quality', paths: ['skills/tdd-workflow'] }]); + assert.strictEqual(operations.length, 1); + assert.strictEqual(operations[0].contentTransform, undefined); + assert.strictEqual( + operations[0].destinationPath, + path.join('/project', '.github', 'skills', 'tdd-workflow') + ); +}); + +test('unsupported source paths produce no operations', () => { + const operations = planFor([ + { id: 'rules-core', paths: ['rules'] }, + { id: 'commands-core', paths: ['commands', 'scripts/harness-audit.js'] }, + { id: 'hooks-runtime', paths: ['hooks', 'scripts/hooks'] }, + { id: 'platform-configs', paths: ['.claude-plugin', '.codex', 'mcp-configs'] }, + ]); + assert.strictEqual(operations.length, 0); +}); + +test('supportsModule stays permissive so dependency anchors still resolve', () => { + // rules-core, commands-core and platform-configs ship no agents or skills but + // are dependencies of the skill modules. Gating them out here would cascade + // and skip every module that depends on them. + assert.strictEqual(copilotAdapter.supportsModule({ id: 'rules-core', paths: ['rules'] }), true); + assert.strictEqual(copilotAdapter.supportsModule({ id: 'empty', paths: [] }), false); +}); + +test('every planned operation is contained within .github', () => { + const operations = planFor([ + { id: 'agents-core', paths: ['agents', '.agents', 'AGENTS.md'] }, + { id: 'framework-language', paths: ['skills/api-design', 'skills/react-patterns'] }, + { id: 'rules-core', paths: ['rules'] }, + ]); + const root = path.join('/project', '.github'); + assert.ok(operations.length > 0); + for (const operation of operations) { + const relative = path.relative(root, operation.destinationPath); + assert.ok( + relative && !relative.startsWith('..') && !path.isAbsolute(relative), + `operation escapes .github: ${operation.destinationPath}` + ); + } +}); + +test('AGENTS.md and .agents are not installed by this target', () => { + const operations = planFor([{ id: 'agents-core', paths: ['.agents', 'AGENTS.md'] }]); + assert.strictEqual(operations.length, 0); +}); + +test('traversal segments produce no planned operations', () => { + for (const value of ['agents/../../escape.md', 'skills/../escape.md', 'agents\\..\\..\\escape.md']) { + assert.deepStrictEqual(planFor([{ id: 'agents-core', paths: [value] }]), []); + } +}); + +test('repair and uninstall preserve user-owned agents across project adapters', () => { + const fs = require('fs'); + const os = require('os'); + const { createManifestInstallPlan, applyInstallPlan } = require('../../scripts/lib/install-executor'); + const { repairInstalledStates, uninstallInstalledStates } = require('../../scripts/lib/install-lifecycle'); + for (const [target, segments] of [['copilot', ['.github', 'agents']], ['antigravity', ['.agents', 'agents']]]) { + const project = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-ownership-')); + try { + const agent = path.join(project, ...segments, 'architect.md'); + fs.mkdirSync(path.dirname(agent), { recursive: true }); + fs.writeFileSync(agent, 'USER OWNED SENTINEL\n'); + const homeDir = path.join(project, 'home'); + const plan = createManifestInstallPlan({ target, moduleIds: ['agents-core'], projectRoot: project, homeDir, env: {} }); + applyInstallPlan(plan); + assert.strictEqual(fs.readFileSync(agent, 'utf8'), 'USER OWNED SENTINEL\n', 'install must preserve user file'); + const options = { repoRoot: REPO_ROOT, projectRoot: project, homeDir, targets: [target], env: {} }; + const repair = repairInstalledStates(options); + assert.strictEqual(repair.summary.errorCount, 0, JSON.stringify(repair)); + assert.strictEqual(fs.readFileSync(agent, 'utf8'), 'USER OWNED SENTINEL\n', 'repair must preserve user file'); + const state = JSON.parse(fs.readFileSync(plan.installStatePath, 'utf8')); + assert.ok(!state.operations.some(operation => operation.destinationPath === agent), 'repair must not claim ownership'); + uninstallInstalledStates(options); + assert.strictEqual(fs.readFileSync(agent, 'utf8'), 'USER OWNED SENTINEL\n', 'uninstall must preserve user file'); + } finally { + fs.rmSync(project, { recursive: true, force: true }); + } + } +}); + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exit(failed === 0 ? 0 : 1); diff --git a/tests/lib/harness-capabilities.test.js b/tests/lib/harness-capabilities.test.js index 4591403d4..9949de678 100644 --- a/tests/lib/harness-capabilities.test.js +++ b/tests/lib/harness-capabilities.test.js @@ -33,12 +33,12 @@ function runTests() { let passed = 0; let failed = 0; - if (test('represents all 15 registered targets exactly once across 14 harnesses', () => { + if (test('represents all 16 registered targets exactly once across 15 harnesses', () => { const catalogTargetIds = HARNESS_CAPABILITIES.flatMap(harness => harness.targetIds); const adapterTargetIds = listInstallTargetAdapters().map(adapter => adapter.target); - assert.strictEqual(HARNESS_CAPABILITIES.length, 14); - assert.strictEqual(new Set(catalogTargetIds).size, 15); + assert.strictEqual(HARNESS_CAPABILITIES.length, 15); + assert.strictEqual(new Set(catalogTargetIds).size, 16); assert.deepStrictEqual([...catalogTargetIds].sort(), [...SUPPORTED_INSTALL_TARGETS].sort()); assert.deepStrictEqual([...catalogTargetIds].sort(), [...adapterTargetIds].sort()); })) passed++; else failed++; @@ -128,8 +128,26 @@ function runTests() { assert.doesNotMatch(kimiHooks.note, /provider.*unsupported|Kimi.*unsupported/i); })) passed++; else failed++; - if (test('does not advertise unregistered Copilot, Kiro, or Pi harnesses', () => { - for (const id of ['copilot', 'kiro', 'pi']) { + if (test('registers Copilot as an advanced project harness', () => { + const copilot = getHarnessCapability('copilot'); + assert.ok(copilot); + assert.deepStrictEqual(copilot.targetIds, ['copilot']); + assert.strictEqual(copilot.installMode, 'managed-project'); + assert.strictEqual(copilot.destination, './.github'); + assert.strictEqual(copilot.guidedReady, false); + assert.strictEqual(copilot.availability, 'advanced'); + assert.strictEqual(copilot.hooks.mode, 'not-configured'); + assert.strictEqual(copilot.hooks.eccConfigured, false); + assert.match(copilot.hooks.note, /ECC hooks .*not installed for Copilot/i); + // Copilot stays out of the guided wizard, which is Claude/Codex/Kimi only. + assert.throws( + () => normalizeHarnessSelection('copilot'), + /Unknown guided harness selection|advanced/i + ); + })) passed++; else failed++; + + if (test('does not advertise unregistered Kiro or Pi harnesses', () => { + for (const id of ['kiro', 'pi']) { assert.strictEqual(getHarnessCapability(id), null); assert.throws( () => normalizeHarnessSelection(id), @@ -171,7 +189,7 @@ function runTests() { const first = listHarnessCapabilities(); first.pop(); - assert.strictEqual(listHarnessCapabilities().length, 14); + assert.strictEqual(listHarnessCapabilities().length, 15); const guided = listGuidedHarnesses(); guided.reverse();