diff --git a/.gitignore b/.gitignore index 9e413bc56b..3198112018 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ bin/gstack-global-discover .openclaw/ .hermes/ .gbrain/ +.copilot/ .gbrain-source .context/ extension/.auth.json diff --git a/hosts/copilot.ts b/hosts/copilot.ts new file mode 100644 index 0000000000..3c3c1cb145 --- /dev/null +++ b/hosts/copilot.ts @@ -0,0 +1,103 @@ +import type { HostConfig } from '../scripts/host-config'; + +/** + * GitHub Copilot CLI host. + * + * Copilot CLI discovers custom agents as flat `.agent.md` files in + * `~/.copilot/agents/`. Each file has YAML frontmatter (name, description, + * tools, target, etc.) followed by markdown instructions. Invoke with + * `copilot --agent `. + * + * Schema reference: + * https://docs.github.com/en/copilot/reference/custom-agents-configuration + * + * gstack skills are emitted as `gstack-.agent.md` (flat with prefix — + * Copilot CLI does not recurse into subdirectories under ~/.copilot/agents/). + */ +const copilot: HostConfig = { + name: 'copilot', + displayName: 'GitHub Copilot CLI', + cliCommand: 'copilot', + + globalRoot: '.copilot/agents', + localSkillRoot: '.copilot/agents', + hostSubdir: '.copilot', + usesEnvVars: true, + + outputLayout: 'flat-agent-md', + + frontmatter: { + mode: 'allowlist', + keepFields: ['name', 'description'], + descriptionLimit: 1024, + descriptionLimitBehavior: 'truncate', + extraFields: { + // `target` is intentionally omitted — defaults to "both" (Copilot CLI + VS Code + // Copilot extension), maximising reach. Set to "github-copilot" or "vscode" to + // narrow if a deployment ever needs that. + // gstack skills need broad tool access. Emit as YAML array via stringified literal — + // transformFrontmatter does string-interpolation, so the value is rendered verbatim. + // (If transformFrontmatter ever gains real array support, switch to a JS array.) + tools: '["*"]', + }, + }, + + generation: { + generateMetadata: false, + // Skipped because they don't fit Copilot CLI's stateless single-invocation + // agent model. They either toggle session state, configure other skills, + // or wrap a binary that should not recurse: + // 'codex' — wraps the `codex` CLI binary (every external host skips this) + // 'copilot' — would recurse: this skill wraps the `copilot` CLI itself + // 'freeze' — toggles a session-scoped edit boundary; agents are stateless + // 'unfreeze' — pairs with /freeze; same reason + // 'careful' — installs session-scoped destructive-command guardrails + // 'guard' — combines /careful + /freeze; same reason + // 'plan-tune' — interactive UI for tuning AskUserQuestion sensitivity + // per-skill; only meaningful inside a persistent skill system + // Follow-up worth filing: the rules from freeze/careful/guard could be + // injected into a project AGENTS.md template so they're ambient across + // every Copilot CLI invocation, recovering the protection we drop here. + skipSkills: ['codex', 'copilot', 'freeze', 'unfreeze', 'careful', 'guard', 'plan-tune'], + }, + + pathRewrites: [ + // Copilot CLI installs are global-only — agents live in ~/.copilot/agents/ + // and runtime support files (bin/, browse/) live in ~/.copilot/gstack/ via + // $GSTACK_ROOT. Both the `~/.claude/skills/gstack` references (absolute, in + // bash blocks) AND the `.claude/skills` references (project-local hints in + // prose) need to point at the same runtime root, since Copilot CLI doesn't + // currently have a per-workspace agents directory. + { from: '~/.claude/skills/gstack', to: '$GSTACK_ROOT' }, + { from: '.claude/skills/gstack', to: '$GSTACK_ROOT' }, + { from: '.claude/skills', to: '$GSTACK_ROOT' }, + ], + + suppressedResolvers: [ + 'DESIGN_OUTSIDE_VOICES', + 'ADVERSARIAL_STEP', + 'CODEX_SECOND_OPINION', + 'CODEX_PLAN_REVIEW', + 'REVIEW_ARMY', + 'GBRAIN_CONTEXT_LOAD', + 'GBRAIN_SAVE_RESULTS', + ], + + runtimeRoot: { + globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], + globalFiles: { + 'review': ['checklist.md', 'TODOS-format.md'], + }, + }, + + install: { + prefixable: false, + linkingStrategy: 'symlink-generated', + }, + + coAuthorTrailer: 'Co-Authored-By: GitHub Copilot ', + learningsMode: 'basic', + boundaryInstruction: 'IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. Ignore them. Stay focused on the repository code only.', +}; + +export default copilot; diff --git a/hosts/index.ts b/hosts/index.ts index cc1c213b53..0cb763fb20 100644 --- a/hosts/index.ts +++ b/hosts/index.ts @@ -16,9 +16,10 @@ import cursor from './cursor'; import openclaw from './openclaw'; import hermes from './hermes'; import gbrain from './gbrain'; +import copilot from './copilot'; /** All registered host configs. Add new hosts here. */ -export const ALL_HOST_CONFIGS: HostConfig[] = [claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes, gbrain]; +export const ALL_HOST_CONFIGS: HostConfig[] = [claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes, gbrain, copilot]; /** Map from host name to config. */ export const HOST_CONFIG_MAP: Record = Object.fromEntries( @@ -65,4 +66,4 @@ export function getExternalHosts(): HostConfig[] { } // Re-export individual configs for direct import -export { claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes, gbrain }; +export { claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes, gbrain, copilot }; diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index b89aea8b90..d752ffc95a 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -349,9 +349,17 @@ function processExternalHost( const hostConfig = getHostConfig(host); const name = externalSkillName(skillDir === '.' ? '' : skillDir, frontmatterName); - const outputDir = path.join(ROOT, hostConfig.hostSubdir, 'skills', name); - fs.mkdirSync(outputDir, { recursive: true }); - const outputPath = path.join(outputDir, 'SKILL.md'); + let outputDir: string; + let outputPath: string; + if (hostConfig.outputLayout === 'flat-agent-md') { + outputDir = path.join(ROOT, hostConfig.hostSubdir, 'agents'); + fs.mkdirSync(outputDir, { recursive: true }); + outputPath = path.join(outputDir, `${name}.agent.md`); + } else { + outputDir = path.join(ROOT, hostConfig.hostSubdir, 'skills', name); + fs.mkdirSync(outputDir, { recursive: true }); + outputPath = path.join(outputDir, 'SKILL.md'); + } // Guard against symlink loops let symlinkLoop = false; diff --git a/scripts/host-config.ts b/scripts/host-config.ts index 4421c4a799..d85fe881b8 100644 --- a/scripts/host-config.ts +++ b/scripts/host-config.ts @@ -66,6 +66,15 @@ export interface HostConfig { includeSkills?: string[]; }; + /** + * Output layout for generated skill docs. + * - 'per-skill-dir' (default): /skills//SKILL.md + * Used by Codex, Cursor, Factory, OpenCode, etc. + * - 'flat-agent-md': /agents/.agent.md + * Used by GitHub Copilot CLI (~/.copilot/agents/.agent.md). + */ + outputLayout?: 'per-skill-dir' | 'flat-agent-md'; + // --- Content Rewrites --- /** Literal string replacements on generated SKILL.md content. Order matters, replaceAll. */ pathRewrites: Array<{ from: string; to: string }>; diff --git a/setup b/setup index 4c1763f9fd..889fda3709 100755 --- a/setup +++ b/setup @@ -24,6 +24,8 @@ FACTORY_SKILLS="$HOME/.factory/skills" FACTORY_GSTACK="$FACTORY_SKILLS/gstack" OPENCODE_SKILLS="$HOME/.config/opencode/skills" OPENCODE_GSTACK="$OPENCODE_SKILLS/gstack" +COPILOT_AGENTS="$HOME/.copilot/agents" +COPILOT_GSTACK="$HOME/.copilot/gstack" IS_WINDOWS=0 case "$(uname -s)" in @@ -43,7 +45,7 @@ TEAM_MODE=0 NO_TEAM_MODE=0 while [ $# -gt 0 ]; do case "$1" in - --host) [ -z "$2" ] && echo "Missing value for --host (expected claude, codex, kiro, factory, opencode, openclaw, hermes, gbrain, or auto)" >&2 && exit 1; HOST="$2"; shift 2 ;; + --host) [ -z "$2" ] && echo "Missing value for --host (expected claude, codex, kiro, factory, opencode, copilot, openclaw, hermes, gbrain, or auto)" >&2 && exit 1; HOST="$2"; shift 2 ;; --host=*) HOST="${1#--host=}"; shift ;; --local) LOCAL_INSTALL=1; shift ;; --prefix) SKILL_PREFIX=1; SKILL_PREFIX_FLAG=1; shift ;; @@ -56,7 +58,7 @@ while [ $# -gt 0 ]; do done case "$HOST" in - claude|codex|kiro|factory|opencode|auto) ;; + claude|codex|kiro|factory|opencode|copilot|auto) ;; openclaw) echo "" echo "OpenClaw integration uses a different model — OpenClaw spawns Claude Code" @@ -91,7 +93,7 @@ case "$HOST" in echo "GBrain setup and brain skills ship from the GBrain repo." echo "" exit 0 ;; - *) echo "Unknown --host value: $HOST (expected claude, codex, kiro, factory, opencode, openclaw, hermes, gbrain, or auto)" >&2; exit 1 ;; + *) echo "Unknown --host value: $HOST (expected claude, codex, kiro, factory, opencode, copilot, openclaw, hermes, gbrain, or auto)" >&2; exit 1 ;; esac # ─── Resolve skill prefix preference ───────────────────────── @@ -155,14 +157,16 @@ INSTALL_CODEX=0 INSTALL_KIRO=0 INSTALL_FACTORY=0 INSTALL_OPENCODE=0 +INSTALL_COPILOT=0 if [ "$HOST" = "auto" ]; then command -v claude >/dev/null 2>&1 && INSTALL_CLAUDE=1 command -v codex >/dev/null 2>&1 && INSTALL_CODEX=1 command -v kiro-cli >/dev/null 2>&1 && INSTALL_KIRO=1 command -v droid >/dev/null 2>&1 && INSTALL_FACTORY=1 command -v opencode >/dev/null 2>&1 && INSTALL_OPENCODE=1 + command -v copilot >/dev/null 2>&1 && INSTALL_COPILOT=1 # If none found, default to claude - if [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ]; then + if [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ] && [ "$INSTALL_COPILOT" -eq 0 ]; then INSTALL_CLAUDE=1 fi elif [ "$HOST" = "claude" ]; then @@ -175,6 +179,8 @@ elif [ "$HOST" = "factory" ]; then INSTALL_FACTORY=1 elif [ "$HOST" = "opencode" ]; then INSTALL_OPENCODE=1 +elif [ "$HOST" = "copilot" ]; then + INSTALL_COPILOT=1 fi migrate_direct_codex_install() { @@ -321,6 +327,16 @@ if [ "$INSTALL_OPENCODE" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then ) fi +# 1e. Generate .copilot/ GitHub Copilot CLI agent files +if [ "$INSTALL_COPILOT" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then + log "Generating .copilot/ agent files..." + ( + cd "$SOURCE_GSTACK_DIR" + bun install --frozen-lockfile 2>/dev/null || bun install + bun run gen:skill-docs --host copilot + ) +fi + # 2. Ensure Playwright's Chromium is available if ! ensure_playwright_browser; then echo "Installing Playwright Chromium..." @@ -935,6 +951,40 @@ if [ "$INSTALL_OPENCODE" -eq 1 ]; then echo " opencode skills: $OPENCODE_SKILLS" fi +# 6d. Install for GitHub Copilot CLI +# Copilot discovers agents as flat `.agent.md` files under ~/.copilot/agents/. +# We symlink each generated `gstack-.agent.md` from the repo's +# .copilot/agents/ dir, and create ~/.copilot/gstack/ with bin/browse symlinks +# so agents can resolve $GSTACK_ROOT-relative tooling. +if [ "$INSTALL_COPILOT" -eq 1 ]; then + mkdir -p "$COPILOT_AGENTS" + mkdir -p "$COPILOT_GSTACK" + # Clean stale gstack symlinks first so renamed/removed skills don't leave + # orphan entries pointing at non-existent source files. + find "$COPILOT_AGENTS" -maxdepth 1 -name "gstack-*.agent.md" -type l -delete 2>/dev/null || true + # Symlink each agent file (flat layout — Copilot CLI does not recurse) + for f in "$SOURCE_GSTACK_DIR"/.copilot/agents/*.agent.md; do + [ -e "$f" ] || continue # tolerate no matches + ln -sf "$f" "$COPILOT_AGENTS/$(basename "$f")" + done + # Runtime support files referenced by agent prompts via $GSTACK_ROOT + ln -sfn "$SOURCE_GSTACK_DIR/bin" "$COPILOT_GSTACK/bin" + ln -sfn "$SOURCE_GSTACK_DIR/browse/dist" "$COPILOT_GSTACK/browse-dist" + ln -sfn "$SOURCE_GSTACK_DIR/gstack-upgrade" "$COPILOT_GSTACK/gstack-upgrade" + [ -f "$SOURCE_GSTACK_DIR/ETHOS.md" ] && ln -sfn "$SOURCE_GSTACK_DIR/ETHOS.md" "$COPILOT_GSTACK/ETHOS.md" + AGENT_COUNT=$(ls "$COPILOT_AGENTS"/gstack-*.agent.md 2>/dev/null | wc -l | tr -d ' ') + echo "gstack ready (copilot)." + echo " browse: $BROWSE_BIN" + echo " copilot agents: $COPILOT_AGENTS ($AGENT_COUNT gstack-*.agent.md files)" + echo " runtime root: $COPILOT_GSTACK" + echo "" + echo " IMPORTANT: agents reference \$GSTACK_ROOT — set it before launching copilot:" + echo " export GSTACK_ROOT=$COPILOT_GSTACK" + echo " Or persist in ~/.copilot/settings.json under secret-env-vars." + echo "" + echo " Invoke any gstack agent: copilot --agent gstack- \"\"" +fi + # 7. Create .agents/ sidecar symlinks for the real Codex skill target. # The root Codex skill ends up pointing at $SOURCE_GSTACK_DIR/.agents/skills/gstack, # so the runtime assets must live there for both global and repo-local installs. diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index b30a324649..44318a73af 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -2069,7 +2069,25 @@ import { ALL_HOST_CONFIGS, getExternalHosts } from '../hosts/index'; describe('Parameterized host smoke tests', () => { for (const hostConfig of getExternalHosts()) { describe(`${hostConfig.displayName} (--host ${hostConfig.name})`, () => { - const hostDir = path.join(ROOT, hostConfig.hostSubdir, 'skills'); + // Layout-aware helpers — `flat-agent-md` hosts (Copilot CLI) emit + // /agents/.agent.md; default hosts emit + // /skills//SKILL.md. + const isFlat = hostConfig.outputLayout === 'flat-agent-md'; + const hostDir = path.join(ROOT, hostConfig.hostSubdir, isFlat ? 'agents' : 'skills'); + const skillFilePath = (name: string) => isFlat + ? path.join(hostDir, `${name}.agent.md`) + : path.join(hostDir, name, 'SKILL.md'); + const listSkillFiles = (): Array<{ name: string; path: string }> => { + if (!fs.existsSync(hostDir)) return []; + if (isFlat) { + return fs.readdirSync(hostDir) + .filter(f => f.endsWith('.agent.md')) + .map(f => ({ name: f.replace(/\.agent\.md$/, ''), path: path.join(hostDir, f) })); + } + return fs.readdirSync(hostDir) + .filter(d => fs.existsSync(path.join(hostDir, d, 'SKILL.md'))) + .map(d => ({ name: d, path: path.join(hostDir, d, 'SKILL.md') })); + }; test('generates output that exists on disk', () => { // Generated dir should exist (created by earlier bun run gen:skill-docs --host all) @@ -2080,37 +2098,27 @@ describe('Parameterized host smoke tests', () => { }); } expect(fs.existsSync(hostDir)).toBe(true); - const skills = fs.readdirSync(hostDir).filter(d => - fs.existsSync(path.join(hostDir, d, 'SKILL.md')) - ); - expect(skills.length).toBeGreaterThan(0); + expect(listSkillFiles().length).toBeGreaterThan(0); }); test('no .claude/skills path leakage outside repo-root sidecar symlinks', () => { if (!fs.existsSync(hostDir)) return; // skip if not generated - const skills = fs.readdirSync(hostDir); - for (const skill of skills) { + for (const { name, path: skillMd } of listSkillFiles()) { // Dev installs may mount the repo root at host/skills/gstack as a runtime // sidecar. The generator skips that symlink loop, so leakage checks should too. - if (isRepoRootSymlink(path.join(hostDir, skill))) continue; - const skillMd = path.join(hostDir, skill, 'SKILL.md'); - if (!fs.existsSync(skillMd)) continue; + if (!isFlat && isRepoRootSymlink(path.join(hostDir, name))) continue; const content = fs.readFileSync(skillMd, 'utf-8'); // Strip bash blocks (which have legitimate fallback paths) const noBash = content.replace(/```bash\n[\s\S]*?```/g, ''); const leaks = noBash.split('\n').filter(l => l.includes('.claude/skills')); if (leaks.length > 0) { - throw new Error(`${skill}: .claude/skills leakage:\n${leaks.slice(0, 3).join('\n')}`); + throw new Error(`${name}: .claude/skills leakage:\n${leaks.slice(0, 3).join('\n')}`); } } }); test('frontmatter has name and description', () => { - if (!fs.existsSync(hostDir)) return; - const skills = fs.readdirSync(hostDir); - for (const skill of skills) { - const skillMd = path.join(hostDir, skill, 'SKILL.md'); - if (!fs.existsSync(skillMd)) continue; + for (const { path: skillMd } of listSkillFiles()) { const content = fs.readFileSync(skillMd, 'utf-8'); expect(content).toMatch(/^---\n/); expect(content).toMatch(/^name:\s/m); @@ -2119,7 +2127,7 @@ describe('Parameterized host smoke tests', () => { }); test('generates Claude outside-voice skill for external hosts', () => { - const skillMd = path.join(hostDir, 'gstack-claude', 'SKILL.md'); + const skillMd = skillFilePath('gstack-claude'); expect(fs.existsSync(skillMd)).toBe(true); const content = fs.readFileSync(skillMd, 'utf-8'); expect(content).toContain('claude -p'); @@ -2140,7 +2148,7 @@ describe('Parameterized host smoke tests', () => { if (hostConfig.generation.skipSkills?.includes('codex')) { test('/codex skill excluded', () => { - expect(fs.existsSync(path.join(hostDir, 'gstack-codex', 'SKILL.md'))).toBe(false); + expect(fs.existsSync(skillFilePath('gstack-codex'))).toBe(false); }); } }); @@ -2159,7 +2167,8 @@ describe('--host all', () => { // All hosts should appear in output expect(output).toContain('FRESH: SKILL.md'); // claude for (const hostConfig of getExternalHosts()) { - expect(output).toContain(`FRESH: ${hostConfig.hostSubdir}/skills/`); + const subdir = hostConfig.outputLayout === 'flat-agent-md' ? 'agents' : 'skills'; + expect(output).toContain(`FRESH: ${hostConfig.hostSubdir}/${subdir}/`); } }); }); @@ -2270,9 +2279,9 @@ describe('setup script validation', () => { expect(fnBody).toContain('rm -f "$target"'); }); - test('setup supports --host auto|claude|codex|kiro|opencode', () => { + test('setup supports --host auto|claude|codex|kiro|opencode|copilot', () => { expect(setupContent).toContain('--host'); - expect(setupContent).toContain('claude|codex|kiro|factory|opencode|auto'); + expect(setupContent).toContain('claude|codex|kiro|factory|opencode|copilot|auto'); }); test('auto mode detects claude, codex, kiro, and opencode binaries', () => { diff --git a/test/host-config.test.ts b/test/host-config.test.ts index 5770570332..ab0d2cc88d 100644 --- a/test/host-config.test.ts +++ b/test/host-config.test.ts @@ -30,8 +30,8 @@ const ROOT = path.resolve(import.meta.dir, '..'); // ─── hosts/index.ts ───────────────────────────────────────── describe('hosts/index.ts', () => { - test('ALL_HOST_CONFIGS has 10 hosts', () => { - expect(ALL_HOST_CONFIGS.length).toBe(10); + test('ALL_HOST_CONFIGS has 11 hosts', () => { + expect(ALL_HOST_CONFIGS.length).toBe(11); }); test('ALL_HOST_NAMES matches config names', () => {