From 9a12478f9108919aa7b6619f4c2129dd2908a8fd Mon Sep 17 00:00:00 2001 From: mingchuno Date: Wed, 21 Oct 2026 17:22:31 +0100 Subject: [PATCH] feat: Add GitHub Copilot CLI support with profiles Add a --for copilot target that manages personal skills in ~/.copilot/skills by moving them to .disabled/. The shared ~/.agents/skills root used by Codex and Amp is left untouched. Copilot profiles are stored in ~/.copilot/skillctl/profiles.json and validate every move before changing any folder. Profile logic shared with Codex now lives in src/skill-profiles.ts. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 2 +- README.md | 32 ++++++++-- package.json | 3 +- src/adapters/copilot.ts | 61 +++++++++++++++++++ src/adapters/index.ts | 2 + src/cli/inspection.ts | 2 +- src/cli/profiles.ts | 2 + src/cli/skills.ts | 3 + src/codex-profiles.ts | 88 +++------------------------- src/commands.ts | 5 +- src/profile-target.ts | 2 +- src/skill-profiles.ts | 97 +++++++++++++++++++++++++++++++ tests/adapters.test.ts | 79 ++++++++++++++++--------- tests/commands.test.ts | 69 ++++++++++++++++++++-- tests/test_cli_boundaries.test.ts | 1 + 15 files changed, 325 insertions(+), 123 deletions(-) create mode 100644 src/adapters/copilot.ts create mode 100644 src/skill-profiles.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1d91f65..203ff23 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,7 +49,7 @@ src/ adapters/ # one file per supported CLI types.ts # CliAdapter interface all adapters implement helpers.ts # shared dir-scanning / enable-disable helpers - claude.ts, antigravity.ts, codex.ts, aider.ts, amp.ts, factory.ts + claude.ts, antigravity.ts, codex.ts, copilot.ts, aider.ts, amp.ts, factory.ts index.ts # adapter registry (getAdapter, getAllAdapters) scanner.ts # skill discovery disable.ts # enable/disable logic diff --git a/README.md b/README.md index 6b514bc..53815ab 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![node](https://img.shields.io/node/v/@mingchuno/skillswitch?style=flat-square&color=F3F2EE&labelColor=0B0B0D)](package.json) [![PRs welcome](https://img.shields.io/badge/PRs-welcome-F3F2EE?style=flat-square&labelColor=0B0B0D)](CONTRIBUTING.md) -Manage AI CLI skills across Claude Code, Antigravity CLI, Codex CLI, Aider, Amp, and Factory Droid — profiles, disable/enable, catalog generation. +Manage AI CLI skills across Claude Code, Antigravity CLI, Codex CLI, GitHub Copilot CLI, Aider, Amp, and Factory Droid — profiles, disable/enable, catalog generation. ## Quick start @@ -24,7 +24,7 @@ Running 100+ Claude Code skills? Your context window is leaking. Claude Code inj ## What is Skillswitch? -**Skillswitch is a command-line skill manager for AI coding CLIs** — Claude Code, Antigravity CLI, Codex CLI, Aider, Amp, and Factory Droid. It disables the skills you're not using so they stop consuming your context window, and lets you snapshot enable/disable sets as named profiles you switch between per project. +**Skillswitch is a command-line skill manager for AI coding CLIs** — Claude Code, Antigravity CLI, Codex CLI, GitHub Copilot CLI, Aider, Amp, and Factory Droid. It disables the skills you're not using so they stop consuming your context window, and lets you snapshot enable/disable sets as named profiles you switch between per project. ### Why Skillswitch @@ -34,7 +34,7 @@ Claude Code injects the name and description of every installed skill into every - **vs. deleting skills manually** — deleting is destructive and you lose the skill. Skillswitch moves skills to `.disabled/` and restores them on `enable`. Nothing is lost. - **vs. editing config by hand** — hand-editing `blocklist.json` or moving files works until you want a *different* set per project. Skillswitch stores profiles and switches the whole set in one command with a `--dry-run` preview. -- **vs. one-CLI tools** — most helpers target Claude Code only. Skillswitch uses the same commands across six CLIs via `--for `. +- **vs. one-CLI tools** — most helpers target Claude Code only. Skillswitch uses the same commands across seven CLIs via `--for `. ### When to use Skillswitch @@ -49,9 +49,9 @@ Claude Code injects the name and description of every installed skill into every **Does it send my data anywhere?** No telemetry, no auth, no network. It only reads and updates local files. -**Which CLIs does it support?** Claude Code, Antigravity CLI, Codex CLI, Aider, Amp (Sourcegraph), and Factory Droid. Default target is Claude Code; use `--for ` for the rest. +**Which CLIs does it support?** Claude Code, Antigravity CLI, Codex CLI, GitHub Copilot CLI, Aider, Amp (Sourcegraph), and Factory Droid. Default target is Claude Code; use `--for ` for the rest. -**Profiles support Claude Code and Codex app / CLI.** Catalog and audit support Claude Code only. List, search, status, disable and enable support all listed targets. +**Profiles support Claude Code, Codex app / CLI and GitHub Copilot CLI.** Catalog and audit support Claude Code only. List, search, status, disable and enable support all listed targets. **How do profiles work?** `skillswitch profile create dev` snapshots your current enabled set. `skillswitch profile use dev` activates it. `profile diff` and `--dry-run` show what changes before it happens. @@ -72,6 +72,7 @@ pnpm dlx @mingchuno/skillswitch | Claude Code | `--for claude` (default) | `~/.claude/skills/` + plugins | | Antigravity CLI | `--for agy` | `~/.gemini/config/skills/` + `~/.gemini/antigravity-cli/skills/` | | Codex app / CLI | `--for codex` | `~/.agents/skills/` | +| GitHub Copilot CLI | `--for copilot` | `~/.copilot/skills/` | | Aider | `--for aider` | `~/.aider/skills/` (managed externally) | | Amp (Sourcegraph) | `--for amp` | `~/.config/amp/skills/` + `~/.agents/skills/` | | Factory Droid | `--for droid` | `~/.factory/droids/` + `~/.factory/commands/` | @@ -111,6 +112,7 @@ skillswitch audit # find duplicates and stale disabled s # Other CLIs — use --for skillswitch --for agy list skillswitch --for codex disable tdd +skillswitch --for copilot disable tdd skillswitch --for amp search coding skillswitch --for droid status skillswitch --for aider list @@ -239,13 +241,31 @@ Skills parked by older versions in `~/.agents/skills/.disabled/` are still liste as disabled. Restore those folders to `~/.agents/skills/` before enabling them; Codex operations no longer move folders. +## Copilot profiles + +```bash +skillswitch --for copilot profile create full +skillswitch --for copilot disable research --exact +skillswitch --for copilot profile create dev +skillswitch --for copilot profile use full --dry-run +skillswitch --for copilot profile use full +# Run /skills reload in an open Copilot CLI session, or start a new one. +``` + +Copilot profiles manage personal skill folders in `~/.copilot/skills` by moving +them to `~/.copilot/skills/.disabled/`. Copilot also reads `~/.agents/skills`, +but that root is shared with Codex and Amp, so skillswitch leaves it alone: +those skills are not listed, toggled or saved in Copilot profiles. Repository +(`.github/skills`) and plugin skills are also outside this scope. Every move is +checked before any folder changes; missing profile skills prevent activation. + ## How it works - **Standalone skills** (`~/.claude/skills/*.md`): disabled by moving to `.disabled/` subdirectory — always reversible, nothing deleted - **Plugin skills** (`~/.claude/plugins/`): disabled by writing to Claude Code's native `blocklist.json` - **Codex**: path-specific `[[skills.config]]` entries in `~/.codex/config.toml`; shared skill folders stay in place - **Other CLIs**: skill files moved to `.disabled/` subdirectory inside their skills directory -- **Profiles** stored independently in `~/.claude/skillctl/profiles.json` and `$CODEX_HOME/skillctl/profiles.json` (default `~/.codex/skillctl/profiles.json`) +- **Profiles** stored independently in `~/.claude/skillctl/profiles.json`, `$CODEX_HOME/skillctl/profiles.json` (default `~/.codex/skillctl/profiles.json`) and `~/.copilot/skillctl/profiles.json` Moves reject existing destinations, invalid names and symlinked `.disabled/` directories. Existing copies remain untouched. Corrupt or invalid profile and diff --git a/package.json b/package.json index dfd2504..55ace1d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mingchuno/skillswitch", "version": "0.5.0", - "description": "Manage AI CLI skills across Claude Code, Antigravity CLI, Codex CLI, Aider, Amp, and Factory Droid — profiles, disable/enable, catalog generation", + "description": "Manage AI CLI skills across Claude Code, Antigravity CLI, Codex CLI, GitHub Copilot CLI, Aider, Amp, and Factory Droid — profiles, disable/enable, catalog generation", "type": "module", "bin": { "skillswitch": "./dist/cli.js" @@ -48,6 +48,7 @@ "antigravity", "agy", "codex", + "copilot", "aider", "amp", "skills", diff --git a/src/adapters/copilot.ts b/src/adapters/copilot.ts new file mode 100644 index 0000000..6677416 --- /dev/null +++ b/src/adapters/copilot.ts @@ -0,0 +1,61 @@ +// src/adapters/copilot.ts — GitHub Copilot CLI adapter +// Skills: ~/.copilot/skills//SKILL.md (personal skills) +// Copilot also reads ~/.agents/skills, which Codex and Amp share; that root is left untouched. +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { assertMoveAllowed } from '../moves.js'; +import type { ProfileTarget } from '../profile-target.js'; +import { skillProfiles } from '../skill-profiles.js'; +import { disableDirSkill, enableDirSkill, scanDirSkills } from './helpers.js'; +import type { AdapterSkill, CliAdapter } from './types.js'; + +export class CopilotAdapter implements CliAdapter { + readonly cliName = 'copilot'; + readonly displayName = 'GitHub Copilot CLI'; + readonly skillsDirs: string[]; + readonly profiles: ProfileTarget; + + constructor(readonly configDir = path.join(os.homedir(), '.copilot')) { + this.skillsDirs = [path.join(configDir, 'skills')]; + this.profiles = skillProfiles({ + label: 'Copilot', + directory: configDir, + scope: + 'Personal skills in ~/.copilot/skills only. Shared ~/.agents/skills, repo and plugin skills are outside this profile.', + scanSkills: () => this.scanSkills(), + validateSkillChanges: (changes) => this.validateSkillChanges(changes), + setSkillsEnabled: (changes) => this.setSkillsEnabled(changes), + }); + } + + isInstalled(): boolean { + return fs.existsSync(this.configDir); + } + + scanSkills(): AdapterSkill[] { + return scanDirSkills(this.skillsDirs[0]); + } + + validateSkillChanges(changes: ReadonlyMap): void { + for (const [name, enable] of changes) { + assertMoveAllowed(name, this.skillsDirs[0], enable, true); + } + } + + setSkillsEnabled(changes: ReadonlyMap): void { + this.validateSkillChanges(changes); + for (const [name, enable] of changes) { + if (enable) this.enableSkill(name); + else this.disableSkill(name); + } + } + + disableSkill(name: string): void { + disableDirSkill(name, this.skillsDirs[0]); + } + + enableSkill(name: string): void { + enableDirSkill(name, this.skillsDirs[0]); + } +} diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 8eb3fd8..db70a2a 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -7,6 +7,7 @@ import { AmpAdapter } from './amp.js'; import { AntigravityAdapter } from './antigravity.js'; import { ClaudeAdapter } from './claude.js'; import { CodexAdapter } from './codex.js'; +import { CopilotAdapter } from './copilot.js'; import { FactoryAdapter } from './factory.js'; import type { CliAdapter } from './types.js'; @@ -17,6 +18,7 @@ const ADAPTERS: Record CliAdapter> = { claude: (claudeDir) => new ClaudeAdapter(claudeDir ?? path.join(os.homedir(), '.claude')), agy: () => new AntigravityAdapter(), codex: () => new CodexAdapter(), + copilot: () => new CopilotAdapter(), droid: () => new FactoryAdapter(), amp: () => new AmpAdapter(), aider: () => new AiderAdapter(), diff --git a/src/cli/inspection.ts b/src/cli/inspection.ts index 9705d09..051f3b3 100644 --- a/src/cli/inspection.ts +++ b/src/cli/inspection.ts @@ -86,7 +86,7 @@ export function createInspectionHandlers(runtime: Pick { - return new Map([ - ...diff.toEnable.map((skill) => [skill, true] as const), - ...diff.toDisable.map((skill) => [skill, false] as const), - ]); -} +import { skillProfiles } from './skill-profiles.js'; export function codexProfiles(adapter: CodexAdapter): ProfileTarget { - const directory = adapter.configDir; - function getProfile(name: string) { - const profile = readProfileStore(directory).profiles[name]; - if (!profile) throw new Error(`Profile "${name}" does not exist`); - if (profile.plugins.length) throw new Error('Codex profiles do not support plugins'); - return profile; - } - - function validate(name: string) { - const profile = getProfile(name); - const inventory = new Set(adapter.scanSkills().map((skill) => skill.name)); - return { - validSkills: profile.skills.filter((skill) => inventory.has(skill)), - ghostSkills: profile.skills.filter((skill) => !inventory.has(skill)), - }; - } - - function diff(name: string) { - const profile = getProfile(name); - const selected = new Set(profile.skills); - const inventory = adapter.scanSkills(); - adapter.assertProfileSelection(selected); - const missing = profile.skills.filter( - (skill) => !inventory.some((entry) => entry.name === skill), - ); - if (missing.length) throw new Error(`Profile has missing skills: ${missing.join(', ')}`); - const changes = { - toEnable: inventory - .filter((skill) => selected.has(skill.name) && skill.status === 'disabled') - .map((skill) => skill.name), - toDisable: inventory - .filter((skill) => !selected.has(skill.name) && skill.status === 'active') - .map((skill) => skill.name), - toBlock: [], - toUnblock: [], - }; - adapter.validateSkillChanges(skillChanges(changes)); - return changes; - } - - return { - directory, + return skillProfiles({ + label: 'Codex', + directory: adapter.configDir, scope: 'User skills in ~/.agents/skills only; applies to Codex app and CLI. Repo, plugin and system skills are outside this profile.', - snapshot: () => ({ - skills: adapter - .scanSkills() - .filter((skill) => skill.status === 'active') - .map((skill) => skill.name), - plugins: [], - }), - diff, - validate, - async activate(name) { - const changes = diff(name); - adapter.setSkillsEnabled(skillChanges(changes)); - recordProfileActivation(name, directory); - return { enabled: changes.toEnable, disabled: changes.toDisable, pluginsBlocked: [] }; - }, - import(json) { - const profile: unknown = JSON.parse(json); - if ( - profile !== null && - typeof profile === 'object' && - 'plugins' in profile && - Array.isArray(profile.plugins) && - profile.plugins.length - ) { - throw new Error('Codex profiles do not support plugins'); - } - return importProfile(json, directory); - }, - }; + scanSkills: () => adapter.scanSkills(), + assertProfileSelection: (selected) => adapter.assertProfileSelection(selected), + validateSkillChanges: (changes) => adapter.validateSkillChanges(changes), + setSkillsEnabled: (changes) => adapter.setSkillsEnabled(changes), + }); } diff --git a/src/commands.ts b/src/commands.ts index 14d5d22..13a465e 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -23,7 +23,7 @@ export function createProgram(handlers: CommandHandlers, config: CommandConfig): program .name('skillswitch') .description( - `Manage AI CLI skills — Claude Code, Antigravity CLI, Codex CLI, Factory Droid, Amp, Aider`, + `Manage AI CLI skills — Claude Code, Antigravity CLI, Codex CLI, GitHub Copilot CLI, Factory Droid, Amp, Aider`, ) .version('0.5.0') // x-release-please-version .option('--claude-dir ', 'Override the Claude config directory (default: ~/.claude)') @@ -33,7 +33,8 @@ export function createProgram(handlers: CommandHandlers, config: CommandConfig): if ( actionCommand.name() === 'catalog' || actionCommand.name() === 'audit' || - (actionCommand.parent?.name() === 'profile' && context().target !== 'codex') + (actionCommand.parent?.name() === 'profile' && + !['codex', 'copilot'].includes(context().target)) ) { handlers.inspection.requireClaude(context()); } diff --git a/src/profile-target.ts b/src/profile-target.ts index 092003a..35554e0 100644 --- a/src/profile-target.ts +++ b/src/profile-target.ts @@ -23,7 +23,7 @@ export interface ProfileTarget { export function profileTarget(adapter: CliAdapter, claudeDir: string): ProfileTarget { if (adapter.profiles) return adapter.profiles; if (adapter.cliName !== 'claude') - throw new Error('Profiles currently support only Claude and Codex.'); + throw new Error('Profiles currently support only Claude, Codex and Copilot.'); return { directory: claudeDir, snapshot: () => ({ diff --git a/src/skill-profiles.ts b/src/skill-profiles.ts new file mode 100644 index 0000000..5ee311e --- /dev/null +++ b/src/skill-profiles.ts @@ -0,0 +1,97 @@ +import type { AdapterSkill } from './adapters/types.js'; +import type { ProfileTarget } from './profile-target.js'; +import { importProfile, readProfileStore, recordProfileActivation } from './profiles.js'; + +// Profiles for adapters that manage a flat set of skills without plugins. +export interface SkillProfileSource { + readonly label: string; + readonly directory: string; + readonly scope: string; + scanSkills(): AdapterSkill[]; + assertProfileSelection?(selected: ReadonlySet): void; + validateSkillChanges(changes: ReadonlyMap): void; + setSkillsEnabled(changes: ReadonlyMap): void; +} + +function skillChanges(diff: { toEnable: string[]; toDisable: string[] }): Map { + return new Map([ + ...diff.toEnable.map((skill) => [skill, true] as const), + ...diff.toDisable.map((skill) => [skill, false] as const), + ]); +} + +export function skillProfiles(source: SkillProfileSource): ProfileTarget { + const { directory, label } = source; + const pluginsUnsupported = `${label} profiles do not support plugins`; + function getProfile(name: string) { + const profile = readProfileStore(directory).profiles[name]; + if (!profile) throw new Error(`Profile "${name}" does not exist`); + if (profile.plugins.length) throw new Error(pluginsUnsupported); + return profile; + } + + function validate(name: string) { + const profile = getProfile(name); + const inventory = new Set(source.scanSkills().map((skill) => skill.name)); + return { + validSkills: profile.skills.filter((skill) => inventory.has(skill)), + ghostSkills: profile.skills.filter((skill) => !inventory.has(skill)), + }; + } + + function diff(name: string) { + const profile = getProfile(name); + const selected = new Set(profile.skills); + const inventory = source.scanSkills(); + source.assertProfileSelection?.(selected); + const missing = profile.skills.filter( + (skill) => !inventory.some((entry) => entry.name === skill), + ); + if (missing.length) throw new Error(`Profile has missing skills: ${missing.join(', ')}`); + const changes = { + toEnable: inventory + .filter((skill) => selected.has(skill.name) && skill.status === 'disabled') + .map((skill) => skill.name), + toDisable: inventory + .filter((skill) => !selected.has(skill.name) && skill.status === 'active') + .map((skill) => skill.name), + toBlock: [], + toUnblock: [], + }; + source.validateSkillChanges(skillChanges(changes)); + return changes; + } + + return { + directory, + scope: source.scope, + snapshot: () => ({ + skills: source + .scanSkills() + .filter((skill) => skill.status === 'active') + .map((skill) => skill.name), + plugins: [], + }), + diff, + validate, + async activate(name) { + const changes = diff(name); + source.setSkillsEnabled(skillChanges(changes)); + recordProfileActivation(name, directory); + return { enabled: changes.toEnable, disabled: changes.toDisable, pluginsBlocked: [] }; + }, + import(json) { + const profile: unknown = JSON.parse(json); + if ( + profile !== null && + typeof profile === 'object' && + 'plugins' in profile && + Array.isArray(profile.plugins) && + profile.plugins.length + ) { + throw new Error(pluginsUnsupported); + } + return importProfile(json, directory); + }, + }; +} diff --git a/tests/adapters.test.ts b/tests/adapters.test.ts index af88567..e2718e2 100644 --- a/tests/adapters.test.ts +++ b/tests/adapters.test.ts @@ -36,32 +36,35 @@ function writeSkill(dir: string, name: string, directory: boolean, description = return file; } -describe.each(['claude', 'codex', 'agy', 'amp', 'aider', 'droid'])('%s adapter', (cli) => { - it('detects installation and round trips skill contents', async () => { - const adapter = await adapterFor(cli); - if (cli !== 'agy') expect(adapter.isInstalled()).toBe(false); - const directory = ['codex', 'agy', 'amp'].includes(cli); - const file = writeSkill(adapter.skillsDirs[0], 'plan', directory); - if (cli === 'codex') fs.mkdirSync(path.join(home, '.codex')); - if (cli !== 'agy') expect(adapter.isInstalled()).toBe(true); - expect(adapter.scanSkills()).toEqual([ - expect.objectContaining({ name: 'plan', status: 'active' }), - ]); - const original = fs.readFileSync(file, 'utf8'); - adapter.disableSkill('plan'); - expect(fs.existsSync(file)).toBe(cli === 'codex'); - expect(adapter.scanSkills()[0].status).toBe('disabled'); - adapter.enableSkill('plan'); - expect(fs.readFileSync(file, 'utf8')).toBe(original); - expect(adapter.scanSkills()[0].status).toBe('active'); - }); - it('returns an empty inventory and rejects missing skills', async () => { - const adapter = await adapterFor(cli); - expect(adapter.scanSkills()).toEqual([]); - expect(() => adapter.disableSkill('missing')).toThrow(/not found/); - expect(() => adapter.enableSkill('missing')).toThrow(/not found|not.*disabled/); - }); -}); +describe.each(['claude', 'codex', 'copilot', 'agy', 'amp', 'aider', 'droid'])( + '%s adapter', + (cli) => { + it('detects installation and round trips skill contents', async () => { + const adapter = await adapterFor(cli); + if (cli !== 'agy') expect(adapter.isInstalled()).toBe(false); + const directory = ['codex', 'copilot', 'agy', 'amp'].includes(cli); + const file = writeSkill(adapter.skillsDirs[0], 'plan', directory); + if (cli === 'codex') fs.mkdirSync(path.join(home, '.codex')); + if (cli !== 'agy') expect(adapter.isInstalled()).toBe(true); + expect(adapter.scanSkills()).toEqual([ + expect.objectContaining({ name: 'plan', status: 'active' }), + ]); + const original = fs.readFileSync(file, 'utf8'); + adapter.disableSkill('plan'); + expect(fs.existsSync(file)).toBe(cli === 'codex'); + expect(adapter.scanSkills()[0].status).toBe('disabled'); + adapter.enableSkill('plan'); + expect(fs.readFileSync(file, 'utf8')).toBe(original); + expect(adapter.scanSkills()[0].status).toBe('active'); + }); + it('returns an empty inventory and rejects missing skills', async () => { + const adapter = await adapterFor(cli); + expect(adapter.scanSkills()).toEqual([]); + expect(() => adapter.disableSkill('missing')).toThrow(/not found/); + expect(() => adapter.enableSkill('missing')).toThrow(/not found|not.*disabled/); + }); + }, +); it.each(['amp'])('%s prefers local skills and finds shared skills for moves', async (cli) => { const adapter = await adapterFor(cli); @@ -77,6 +80,30 @@ it.each(['amp'])('%s prefers local skills and finds shared skills for moves', as adapter.enableSkill('ship'); expect(fs.existsSync(shared)).toBe(true); }); +it('Copilot manages only personal skills and leaves the shared root untouched', async () => { + const adapter = await adapterFor('copilot'); + expect(adapter.skillsDirs).toEqual([path.join(home, '.copilot', 'skills')]); + writeSkill(adapter.skillsDirs[0], 'plan', true); + const shared = writeSkill(path.join(home, '.agents', 'skills'), 'ship', true); + expect(adapter.scanSkills().map((skill) => skill.name)).toEqual(['plan']); + expect(() => adapter.disableSkill('ship')).toThrow(/not found/); + expect(fs.existsSync(shared)).toBe(true); +}); +it('Copilot validates every move before changing any skill', async () => { + const adapter = await adapterFor('copilot'); + if (!('setSkillsEnabled' in adapter) || typeof adapter.setSkillsEnabled !== 'function') + throw new Error('Missing Copilot batch toggles'); + const plan = writeSkill(adapter.skillsDirs[0], 'plan', true); + writeSkill(adapter.skillsDirs[0], 'ship', true); + writeSkill(path.join(adapter.skillsDirs[0], '.disabled'), 'ship', true); + const changes = new Map([ + ['plan', false], + ['ship', false], + ]); + expect(() => adapter.validateSkillChanges?.(changes)).toThrow(); + expect(() => adapter.setSkillsEnabled(changes)).toThrow(); + expect(fs.existsSync(plan)).toBe(true); +}); it('Factory scans and moves commands as well as droids', async () => { const adapter = await adapterFor('droid'); writeSkill(adapter.skillsDirs[0], 'plan', false); diff --git a/tests/commands.test.ts b/tests/commands.test.ts index bb108b3..0c9fa39 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { AntigravityAdapter } from '../src/adapters/antigravity.js'; import { ClaudeAdapter } from '../src/adapters/claude.js'; import { CodexAdapter } from '../src/adapters/codex.js'; +import { CopilotAdapter } from '../src/adapters/copilot.js'; import { disableDirSkill, disableFlatSkill, @@ -36,6 +37,7 @@ function createFixtureAdapter(cli: string): CliAdapter { if (cli === 'agy') return new AntigravityAdapter(dir, ''); const skillsDir = path.join(dir, 'adapters', cli); if (cli === 'codex') return new CodexAdapter(path.join(dir, 'codex'), skillsDir); + if (cli === 'copilot') return new CopilotAdapter(path.join(dir, 'copilot')); const directory = cli === 'amp'; const displayNames: Record = { codex: 'Codex CLI (OpenAI)', @@ -179,11 +181,11 @@ it('handles missing profiles and writes a catalog', async () => { expect(fs.readFileSync(path.join(dir, 'SKILLS.md'), 'utf8')).toContain('plan'); }); -it.each(['codex', 'agy', 'amp', 'aider', 'droid'])( +it.each(['codex', 'copilot', 'agy', 'amp', 'aider', 'droid'])( 'lists, searches, counts, and moves %s skills', async (cli) => { const adapter = runtime.adapters.get(cli); - const directory = ['codex', 'agy', 'amp'].includes(cli); + const directory = ['codex', 'copilot', 'agy', 'amp'].includes(cli); const file = directory ? path.join(adapter.skillsDirs[0], 'ship', 'SKILL.md') : path.join(adapter.skillsDirs[0], 'ship.md'); @@ -218,7 +220,7 @@ it.each(['codex', 'agy', 'amp', 'aider', 'droid'])( }, ); it('detects installed CLIs and prints Aider configuration from the isolated home', async () => { - expect(JSON.parse(await run('detect', '--json'))).toHaveLength(6); + expect(JSON.parse(await run('detect', '--json'))).toHaveLength(7); expect(await run('detect')).toContain('not found'); expect(await run('aider-config')).toContain('No active aider skills'); }); @@ -276,11 +278,13 @@ it.each([ ['codex', 'disable', true], ['codex', 'enable', false], ['codex', 'enable', true], + ['copilot', 'disable', true], + ['copilot', 'enable', true], ] as const)( '%s %s confirms multiple matches before any moves (accept=%s)', async (cli, direction, accepted) => { const adapter = runtime.adapters.get(cli); - const directory = cli === 'codex'; + const directory = cli !== 'claude'; if (cli === 'claude' && direction === 'enable') fs.rmSync(path.join(dir, 'skills', 'plan.md')); const base = direction === 'enable' && cli !== 'codex' @@ -367,7 +371,7 @@ it('uses configured directory defaults and explicit overrides without environmen it('rejects unsupported targets using the injected terminal', async () => { await expect(run('--for', 'agy', 'profile', 'list')).rejects.toThrow('CLI exit 1'); expect(errors).toBe( - 'Error: catalog and audit support only Claude; profiles support Claude and Codex.\n', + 'Error: catalog and audit support only Claude; profiles support Claude, Codex and Copilot.\n', ); }); it('preserves forced deletion output for a dangling active profile and leaves its store intact', async () => { @@ -452,6 +456,59 @@ it('Codex profiles snapshot native state, preview without writes, and switch ind expect(JSON.parse(await run('--for', 'codex', 'profile', 'list', '--json')).previous).toBe('dev'); }); +it('Copilot profiles snapshot personal skills, preview without moves, and switch independently of Claude', async () => { + const adapter = runtime.adapters.get('copilot'); + const files = ['plan', 'ship'].map((name) => { + const file = path.join(adapter.skillsDirs[0], name, 'SKILL.md'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `---\nname: ${name}\ndescription: ${name} work\n---\n`); + return file; + }); + await run('profile', 'create', 'dev'); + await run('--for', 'copilot', 'profile', 'create', 'full'); + expect(await run('--for', 'copilot', 'disable', 'ship', '--exact')).toContain('/skills reload'); + expect(await run('--for', 'copilot', 'profile', 'create', 'dev')).toContain('1 skills'); + expect(fs.existsSync(path.join(dir, 'copilot', 'skillctl', 'profiles.json'))).toBe(true); + await run('--for', 'copilot', 'enable', 'ship', '--exact'); + expect(await run('--for', 'copilot', 'profile', 'use', 'dev', '--dry-run')).toContain( + 'Disable : ship', + ); + expect(fs.existsSync(files[1])).toBe(true); + expect(await run('--for', 'copilot', 'profile', 'use', 'dev')).toContain('/skills reload'); + expect(fs.existsSync(files[1])).toBe(false); + expect(JSON.parse(await run('--for', 'copilot', 'status', '--json'))).toMatchObject({ + activeProfile: 'dev', + active: 1, + disabled: 1, + }); + expect(await run('--for', 'copilot', 'profile', 'diff', 'dev')).toContain('no changes'); + expect(JSON.parse(await run('profile', 'export', 'dev')).skills).toEqual(['plan']); + await run('--for', 'copilot', 'profile', 'use', 'full'); + expect(files.every((file) => fs.existsSync(file))).toBe(true); + expect(JSON.parse(await run('--for', 'copilot', 'profile', 'list', '--json')).previous).toBe( + 'dev', + ); +}); + +it('Copilot rejects plugin profiles and missing skills without moving files', async () => { + const adapter = runtime.adapters.get('copilot'); + const file = path.join(adapter.skillsDirs[0], 'plan', 'SKILL.md'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, '# Plan'); + const exported = path.join(dir, 'copilot-profile.json'); + fs.writeFileSync(exported, JSON.stringify({ name: 'dev', skills: ['plan'], plugins: ['x@y'] })); + await expect(run('--for', 'copilot', 'profile', 'import', exported)).rejects.toThrow('CLI exit'); + expect(errors).toContain('Copilot profiles do not support plugins'); + await run('--for', 'copilot', 'profile', 'create', 'dev'); + fs.rmSync(path.dirname(file), { recursive: true }); + const other = path.join(adapter.skillsDirs[0], 'ship', 'SKILL.md'); + fs.mkdirSync(path.dirname(other), { recursive: true }); + fs.writeFileSync(other, '# Ship'); + expect(await run('--for', 'copilot', 'profile', 'validate', 'dev')).toContain('ghost'); + await expect(run('--for', 'copilot', 'profile', 'use', 'dev')).rejects.toThrow('CLI exit'); + expect(fs.existsSync(other)).toBe(true); +}); + it('Codex supports the profile lifecycle and leaves stores unchanged on invalid imports and missing skills', async () => { const adapter = runtime.adapters.get('codex'); const file = path.join(adapter.skillsDirs[0], 'plan', 'SKILL.md'); @@ -604,7 +661,7 @@ it.each([ it.each(CLI_NAMES)('supports glob enable and disable for %s', async (cli) => { const adapter = runtime.adapters.get(cli); - const directory = ['agy', 'amp', 'codex'].includes(cli); + const directory = ['agy', 'amp', 'codex', 'copilot'].includes(cli); const file = directory ? path.join(adapter.skillsDirs[0], 'ship-one', 'SKILL.md') : path.join(adapter.skillsDirs[0], 'ship-one.md'); diff --git a/tests/test_cli_boundaries.test.ts b/tests/test_cli_boundaries.test.ts index 270baa9..07ca0bd 100644 --- a/tests/test_cli_boundaries.test.ts +++ b/tests/test_cli_boundaries.test.ts @@ -164,6 +164,7 @@ it('uses the environment directory unless an explicit directory overrides it', ( }); it.each([ ['codex', '.agents/skills', true], + ['copilot', '.copilot/skills', true], ['agy', '.gemini/config/skills', true], ['agy', '.gemini/antigravity-cli/skills', true], ['amp', '.config/amp/skills', true],