From 0b41da9dfe195fe3301ed6393e2439cf4ffec3d1 Mon Sep 17 00:00:00 2001 From: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:34:35 -0500 Subject: [PATCH] feat: add GitHub Copilot host support Add one shared Agent Skills host contract for Copilot CLI and the Copilot desktop app, including generation, setup, upgrades, uninstall, documentation, and focused regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + README.md | 12 +- bin/gstack-platform-detect | 6 +- bin/gstack-uninstall | 30 ++ docs/ADDING_A_HOST.md | 15 +- gstack-upgrade/SKILL.md.tmpl | 48 +-- hosts/copilot.ts | 150 +++++++++ hosts/index.ts | 5 +- scripts/gen-skill-docs.ts | 14 +- scripts/host-config-export.ts | 21 +- scripts/host-config.ts | 19 ++ scripts/resolvers/index.ts | 2 + .../preamble/generate-preamble-bash.ts | 7 +- scripts/resolvers/upgrade.ts | 76 +++++ setup | 176 +++++++++-- test/copilot-host.test.ts | 286 ++++++++++++++++++ test/gen-skill-docs.test.ts | 8 +- test/host-config.test.ts | 34 ++- test/setup-windows-fallback.test.ts | 8 + 19 files changed, 831 insertions(+), 87 deletions(-) create mode 100644 hosts/copilot.ts create mode 100644 scripts/resolvers/upgrade.ts create mode 100644 test/copilot-host.test.ts diff --git a/.gitignore b/.gitignore index 5196c0d05a..50ad1bfb3d 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ bin/gstack-global-discover* .openclaw/ .hermes/ .gbrain/ +.copilot/ .gbrain-source .context/ extension/.auth.json diff --git a/README.md b/README.md index af534d2c53..06e4024275 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ These are conversational skills. Your OpenClaw agent runs them directly via chat ### Other AI Agents -gstack works on 10 AI coding agents, not just Claude. Setup auto-detects which +gstack works on 11 AI coding agents, not just Claude. Setup auto-detects which agents you have installed: ```bash @@ -113,6 +113,7 @@ Or target a specific agent with `./setup --host `: | Agent | Flag | Skills install to | |-------|------|-------------------| +| GitHub Copilot CLI + app | `--host copilot` | `~/.copilot/skills/gstack-*/` | | OpenAI Codex CLI | `--host codex` | `~/.codex/skills/gstack-*/` | | OpenCode | `--host opencode` | `~/.config/opencode/skills/gstack-*/` | | Cursor | `--host cursor` | `~/.cursor/skills/gstack-*/` | @@ -122,6 +123,15 @@ Or target a specific agent with `./setup --host `: | Hermes | `--host hermes` | `~/.hermes/skills/gstack-*/` | | GBrain (mod) | `--host gbrain` | `~/.gbrain/skills/gstack-*/` | +Copilot CLI and the GitHub Copilot app use the same [Agent Skills +contract](https://docs.github.com/en/copilot/concepts/agents/about-agent-skills). One +gstack host supports both: personal skills install to `~/.copilot/skills`, and +`./setup --host copilot --local` installs repository skills to `.github/skills`. +GitHub documents that the [Copilot app automatically makes CLI and repository +skills available in project sessions](https://docs.github.com/en/copilot/how-tos/github-copilot-app/customize-github-copilot-app). +If `COPILOT_HOME` is set, setup installs personal skills under +`$COPILOT_HOME/skills` instead. + **Want to add support for another agent?** See [docs/ADDING_A_HOST.md](docs/ADDING_A_HOST.md). It's one TypeScript config file, zero code changes. diff --git a/bin/gstack-platform-detect b/bin/gstack-platform-detect index 766a585b36..e1065f12ea 100755 --- a/bin/gstack-platform-detect +++ b/bin/gstack-platform-detect @@ -12,8 +12,10 @@ printf "%-16s %-10s %-40s %s\n" "-----" "-------" "----------" "------" for host in $(bun run "$GSTACK_DIR/scripts/host-config-export.ts" list 2>/dev/null); do cmd=$(bun run "$GSTACK_DIR/scripts/host-config-export.ts" get "$host" cliCommand 2>/dev/null) - root=$(bun run "$GSTACK_DIR/scripts/host-config-export.ts" get "$host" globalRoot 2>/dev/null) - spath="$HOME/$root" + spath=$(bun run "$GSTACK_DIR/scripts/host-config-export.ts" resolve-global-root "$host" 2>/dev/null) + if command -v cygpath >/dev/null 2>&1; then + spath=$(cygpath -u "$spath") + fi if command -v "$cmd" >/dev/null 2>&1; then ver=$("$cmd" --version 2>/dev/null | head -1 || echo "unknown") diff --git a/bin/gstack-uninstall b/bin/gstack-uninstall index 17d7d30bcd..5c6ccf31c7 100755 --- a/bin/gstack-uninstall +++ b/bin/gstack-uninstall @@ -10,6 +10,8 @@ # ~/.claude/skills/gstack — global Claude skill install (git clone or vendored) # ~/.claude/skills/{skill} — per-skill symlinks created by setup # ~/.codex/skills/gstack* — Codex skill install + per-skill symlinks +# ${COPILOT_HOME:-~/.copilot}/skills/gstack* +# — Copilot CLI/app personal skill install # ~/.factory/skills/gstack* — Factory Droid skill install + per-skill symlinks # ~/.kiro/skills/gstack* — Kiro skill install + per-skill symlinks # ~/.gstack/ — global state (config, analytics, sessions, projects, @@ -18,6 +20,7 @@ # .gstack/ — per-project browse state (in current git repo) # .gstack-worktrees/ — per-project test worktrees (in current git repo) # .agents/skills/gstack* — Codex/Gemini/Cursor sidecar (in current git repo) +# .github/skills/gstack* — Copilot repository skills (in current git repo) # Running browse daemons — stopped via SIGTERM before cleanup # # What is NOT REMOVED: @@ -38,6 +41,11 @@ fi GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +COPILOT_CONFIG_HOME="${COPILOT_HOME:-$HOME/.copilot}" +if command -v cygpath >/dev/null 2>&1; then + COPILOT_CONFIG_HOME="$(cygpath -u "$COPILOT_CONFIG_HOME")" +fi +COPILOT_SKILLS="$COPILOT_CONFIG_HOME/skills" _GIT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" # ─── Parse flags ───────────────────────────────────────────── @@ -64,6 +72,7 @@ if [ "$FORCE" -eq 0 ]; then echo "This will remove gstack from your system:" { [ -d "$HOME/.claude/skills/gstack" ] || [ -L "$HOME/.claude/skills/gstack" ]; } && echo " ~/.claude/skills/gstack (+ per-skill symlinks)" [ -d "$HOME/.codex/skills" ] && echo " ~/.codex/skills/gstack*" + [ -d "$COPILOT_SKILLS" ] && echo " $COPILOT_SKILLS/gstack*" [ -d "$HOME/.factory/skills" ] && echo " ~/.factory/skills/gstack*" [ -d "$HOME/.kiro/skills" ] && echo " ~/.kiro/skills/gstack*" [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ] && echo " $STATE_DIR" @@ -73,6 +82,7 @@ if [ "$FORCE" -eq 0 ]; then [ -d "$_GIT_ROOT/.gstack" ] && echo " $_GIT_ROOT/.gstack/ (browse state + reports)" [ -d "$_GIT_ROOT/.gstack-worktrees" ] && echo " $_GIT_ROOT/.gstack-worktrees/" [ -d "$_GIT_ROOT/.agents/skills" ] && echo " $_GIT_ROOT/.agents/skills/gstack*" + [ -d "$_GIT_ROOT/.github/skills" ] && echo " $_GIT_ROOT/.github/skills/gstack*" fi # Preview running daemons @@ -171,6 +181,15 @@ if [ -d "$CODEX_SKILLS" ]; then done fi +# ─── Remove GitHub Copilot skills ──────────────────────────── +if [ -d "$COPILOT_SKILLS" ]; then + for _ITEM in "$COPILOT_SKILLS"/gstack*; do + [ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue + rm -rf "$_ITEM" + REMOVED+=("copilot/$(basename "$_ITEM")") + done +fi + # ─── Remove Factory Droid skills ──────────────────────────── FACTORY_SKILLS="$HOME/.factory/skills" if [ -d "$FACTORY_SKILLS" ]; then @@ -203,6 +222,17 @@ if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.agents/skills" ]; then rmdir "$_GIT_ROOT/.agents" 2>/dev/null || true fi +# ─── Remove per-project Copilot skills ────────────────────── +if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.github/skills" ]; then + for _ITEM in "$_GIT_ROOT/.github/skills"/gstack*; do + [ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue + rm -rf "$_ITEM" + REMOVED+=("copilot/$(basename "$_ITEM")") + done + + rmdir "$_GIT_ROOT/.github/skills" 2>/dev/null || true +fi + # ─── Remove per-project .factory/ sidecar ──────────────────── if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.factory/skills" ]; then for _ITEM in "$_GIT_ROOT/.factory/skills"/gstack*; do diff --git a/docs/ADDING_A_HOST.md b/docs/ADDING_A_HOST.md index 50654e4e23..f2b253ef3e 100644 --- a/docs/ADDING_A_HOST.md +++ b/docs/ADDING_A_HOST.md @@ -1,7 +1,7 @@ # Adding a New Host to gstack gstack uses a declarative host config system. Each supported AI coding agent -(Claude, Codex, Factory, Kiro, OpenCode, Slate, Cursor, OpenClaw) is defined +(Claude, Codex, Factory, Kiro, OpenCode, Slate, Cursor, OpenClaw, Copilot) is defined as a typed TypeScript config object. Adding a new host means creating one file and re-exporting it. Zero code changes to the generator, setup, or tooling. @@ -17,6 +17,7 @@ hosts/ ├── slate.ts # Slate (Random Labs) ├── cursor.ts # Cursor ├── openclaw.ts # OpenClaw (hybrid: config + adapter) +├── copilot.ts # GitHub Copilot CLI + app (shared Agent Skills contract) └── index.ts # Registry: imports all, derives Host type ``` @@ -25,6 +26,7 @@ Each config file exports a `HostConfig` object that tells the generator: - How to transform frontmatter (allowlist/denylist fields) - What Claude-specific references to rewrite (paths, tool names) - What binary to detect for auto-install +- Which product surfaces share the contract - What resolver sections to suppress - What assets to symlink at install time @@ -133,10 +135,15 @@ bun test test/gen-skill-docs.test.ts bun test test/host-config.test.ts ``` -The parameterized smoke tests automatically pick up the new host. Zero test +The parameterized smoke tests automatically pick up the new host. Zero generic test code to write. They verify: output exists, no path leakage, valid frontmatter, freshness check passes, codex skill excluded. +Add focused host tests for claims the generic matrix cannot prove, such as executable +detection, official discovery roots, host-specific frontmatter limits, and install +behavior. Copilot is the reference example because its CLI and app share one skill +format while using different personal and repository roots. + ### 6. Update README.md Add install instructions for the new host in the appropriate section. @@ -155,7 +162,11 @@ Key fields: | `frontmatter.descriptionLimitBehavior` | `error` (fail build), `truncate`, `warn` | | `frontmatter.conditionalFields` | Add fields based on template values (e.g., sensitive → disable-model-invocation) | | `frontmatter.renameFields` | Rename template fields (e.g., voice-triggers → triggers) | +| `frontmatter.namePrefix` | Prefix generated names for hosts that require name to match the skill directory | +| `frontmatter.nameLimit` | Fail generation when the host's maximum skill-name length is exceeded | | `pathRewrites` | Literal replaceAll on content. Order matters. | +| `supportedSurfaces` | Product surfaces covered by one contract (`cli`, optionally `app`) | +| `globalRootEnv` | Optional config-root override env var honored by setup and generated preambles | | `toolRewrites` | Rewrite Claude tool names (e.g., "use the Bash tool" → "run this command") | | `suppressedResolvers` | Resolver functions that return empty for this host | | `coAuthorTrailer` | Git co-author string for commits | diff --git a/gstack-upgrade/SKILL.md.tmpl b/gstack-upgrade/SKILL.md.tmpl index 5402a1da3c..02744595f7 100644 --- a/gstack-upgrade/SKILL.md.tmpl +++ b/gstack-upgrade/SKILL.md.tmpl @@ -143,53 +143,7 @@ cd "$INSTALL_DIR" && ./setup rm -rf "$INSTALL_DIR.bak" "$TMP_DIR" ``` -### Step 4.5: Handle local vendored copy - -Use the install directory from Step 2. Check if there's also a local vendored copy, and whether team mode is active: - -```bash -_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) -LOCAL_GSTACK="" -if [ -n "$_ROOT" ] && [ -d "$_ROOT/.claude/skills/gstack" ]; then - _RESOLVED_LOCAL=$(cd "$_ROOT/.claude/skills/gstack" && pwd -P) - _RESOLVED_PRIMARY=$(cd "$INSTALL_DIR" && pwd -P) - if [ "$_RESOLVED_LOCAL" != "$_RESOLVED_PRIMARY" ]; then - LOCAL_GSTACK="$_ROOT/.claude/skills/gstack" - fi -fi -_TEAM_MODE=$(~/.claude/skills/gstack/bin/gstack-config get team_mode 2>/dev/null || echo "false") -echo "LOCAL_GSTACK=$LOCAL_GSTACK" -echo "TEAM_MODE=$_TEAM_MODE" -``` - -**If `LOCAL_GSTACK` is non-empty AND `TEAM_MODE` is `true`:** Remove the vendored copy. Team mode uses the global install as the single source of truth. - -```bash -cd "$_ROOT" -git rm -r --cached .claude/skills/gstack/ 2>/dev/null || true -if ! grep -qF '.claude/skills/gstack/' .gitignore 2>/dev/null; then - echo '.claude/skills/gstack/' >> .gitignore -fi -rm -rf "$LOCAL_GSTACK" -``` -Tell user: "Removed vendored copy at `$LOCAL_GSTACK` (team mode active — global install is the source of truth). Commit the `.gitignore` change when ready." - -**If `LOCAL_GSTACK` is non-empty AND `TEAM_MODE` is NOT `true`:** Update it by copying from the freshly-upgraded primary install (same approach as README vendored install): -```bash -mv "$LOCAL_GSTACK" "$LOCAL_GSTACK.bak" -cp -Rf "$INSTALL_DIR" "$LOCAL_GSTACK" -rm -rf "$LOCAL_GSTACK/.git" -cd "$LOCAL_GSTACK" && ./setup -rm -rf "$LOCAL_GSTACK.bak" -``` -Tell user: "Also updated vendored copy at `$LOCAL_GSTACK` — commit `.claude/skills/gstack/` when you're ready." - -If `./setup` fails, restore from backup and warn the user: -```bash -rm -rf "$LOCAL_GSTACK" -mv "$LOCAL_GSTACK.bak" "$LOCAL_GSTACK" -``` -Tell user: "Sync failed — restored previous version at `$LOCAL_GSTACK`. Run `/gstack-upgrade` manually to retry." +{{UPGRADE_LOCAL_INSTALL_SYNC}} ### Step 4.75: Run version migrations diff --git a/hosts/copilot.ts b/hosts/copilot.ts new file mode 100644 index 0000000000..2db53f9a5d --- /dev/null +++ b/hosts/copilot.ts @@ -0,0 +1,150 @@ +import type { HostConfig } from '../scripts/host-config'; + +/** + * GitHub documents one Agent Skills contract for Copilot CLI and the Copilot app: + * personal skills live in ~/.copilot/skills and repository skills in .github/skills. + * Keep generated staging under .copilot/ so Copilot never collides with Codex's + * .agents/skills output. + */ +const copilot: HostConfig = { + name: 'copilot', + displayName: 'GitHub Copilot CLI and app', + cliCommand: 'copilot', + cliAliases: [], + supportedSurfaces: ['cli', 'app'], + + globalRoot: '.copilot/skills/gstack', + globalRootEnv: 'COPILOT_HOME', + localSkillRoot: '.github/skills/gstack', + hostSubdir: '.copilot', + usesEnvVars: true, + + frontmatter: { + mode: 'allowlist', + keepFields: ['name', 'description'], + namePrefix: 'gstack-', + nameLimit: 64, + descriptionLimit: 1024, + descriptionLimitBehavior: 'error', + }, + + generation: { + generateMetadata: false, + skipSkills: ['codex'], + }, + + pathRewrites: [ + { from: '${HOME}/.claude/skills/gstack', to: '$GSTACK_ROOT' }, + { + from: '$HOME/.claude/skills/gstack/.git', + to: '$(cat "$GSTACK_ROOT/.source-path")/.git', + }, + { + from: 'INSTALL_DIR="$HOME/.claude/skills/gstack"', + to: 'INSTALL_DIR="$(cat "$GSTACK_ROOT/.source-path")"', + }, + { from: '$HOME/.claude/skills/gstack', to: '$GSTACK_ROOT' }, + { from: '~/.claude/skills/gstack', to: '$GSTACK_ROOT' }, + { from: '.claude/skills/gstack', to: '$GSTACK_ROOT' }, + { from: '.claude/skills/review', to: '$GSTACK_ROOT/review' }, + { + from: '[ -f "$GSTACK_ROOT/VERSION" ] || [ -d "$GSTACK_ROOT/.git" ]', + to: '[ -d "$GSTACK_ROOT/.git" ]', + }, + { from: '\n./setup\n', to: '\n./setup --host copilot\n' }, + { from: '&& ./setup\n', to: '&& ./setup --host copilot\n' }, + { from: '.claude/skills', to: '.github/skills' }, + { from: '.agents/skills/gstack', to: '.github/skills/gstack' }, + { from: '.agents/skills', to: '.github/skills' }, + ], + toolRewrites: { + 'AskUserQuestion': 'ask_user', + 'use the Bash tool': 'use the bash tool', + 'use the Write tool': 'edit the file', + 'use the Read tool': 'use the view tool', + 'use the Edit tool': 'edit the file', + 'use the Agent tool': 'use the task tool', + 'use the Grep tool': 'search the repository', + 'use the Glob tool': 'find matching files', + 'the Bash tool': 'the bash tool', + 'the Write tool': 'file editing', + 'the Read tool': 'the view tool', + 'the Edit tool': 'file editing', + 'the Agent tool': 'the task tool', + 'the Grep tool': 'repository search', + 'the Glob tool': 'file matching', + }, + + // Copilot supports task and skill tools, so its same-host subagent orchestration + // remains enabled. Only optional gbrain blocks are suppressed by default. + suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'], + + runtimeRoot: { + globalSymlinks: [ + 'bin', + 'browse/dist', + 'browse/bin', + 'design/dist', + 'make-pdf/dist', + 'design-html/vendor', + 'extension', + 'hosts', + 'lib', + 'scripts', + 'ios-qa/templates', + 'ios-qa/scripts', + 'ios-qa/daemon', + 'supabase', + 'VERSION', + 'CHANGELOG.md', + 'setup', + 'package.json', + 'bun.lock', + 'ETHOS.md', + 'office-hours/SKILL.md', + 'document-release/SKILL.md', + 'plan-ceo-review/SKILL.md', + 'plan-design-review/SKILL.md', + 'plan-devex-review/SKILL.md', + 'plan-eng-review/SKILL.md', + 'gstack-upgrade/SKILL.md', + 'gstack-upgrade/migrations', + 'review/specialists', + 'qa/templates', + 'qa/references', + 'plan-devex-review/dx-hall-of-fame.md', + ], + globalFiles: { + review: ['checklist.md', 'design-checklist.md', 'greptile-triage.md', 'TODOS-format.md'], + }, + }, + sidecar: { + path: '.github/skills/gstack', + symlinks: [ + 'bin', + 'browse', + 'design', + 'make-pdf', + 'design-html', + 'extension', + 'hosts', + 'lib', + 'scripts', + 'gstack-upgrade', + 'ETHOS.md', + 'review', + 'qa', + 'plan-devex-review', + ], + }, + + install: { + prefixable: false, + linkingStrategy: 'symlink-generated', + }, + + coAuthorTrailer: 'Co-Authored-By: GitHub Copilot ', + learningsMode: 'basic', +}; + +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 71aa1a34ca..73792f06f8 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -530,7 +530,19 @@ function transformFrontmatter(content: string, host: Host): string { if (fmEnd === -1) return content; const frontmatter = content.slice(fmStart + 4, fmEnd); const body = content.slice(fmEnd + 4); - const { name, description } = extractNameAndDescription(content); + const extracted = extractNameAndDescription(content); + const description = extracted.description; + const namePrefix = fm.namePrefix || ''; + const name = namePrefix && extracted.name !== namePrefix.replace(/-$/, '') && !extracted.name.startsWith(namePrefix) + ? `${namePrefix}${extracted.name}` + : extracted.name; + + if (fm.nameLimit && name.length > fm.nameLimit) { + throw new Error( + `${hostConfig.displayName} frontmatter name exceeds ${fm.nameLimit} characters: ` + + `${name} (${name.length})` + ); + } // Description limit enforcement if (fm.descriptionLimit) { diff --git a/scripts/host-config-export.ts b/scripts/host-config-export.ts index 5ef703624d..d73727bc6c 100644 --- a/scripts/host-config-export.ts +++ b/scripts/host-config-export.ts @@ -8,6 +8,7 @@ * list Print all host names, one per line * get Print a single config field value * detect Print names of hosts whose CLI binary is on PATH + * resolve-global-root Print the absolute global root, honoring config env * validate Validate all configs, exit 1 on error * * All output is shell-safe (single-quoted values, no eval needed). @@ -17,6 +18,7 @@ import { ALL_HOST_CONFIGS, getHostConfig, ALL_HOST_NAMES } from '../hosts/index' import { validateAllConfigs } from './host-config'; import { RESOLVERS } from './resolvers'; import { execSync } from 'child_process'; +import * as path from 'path'; const CLI_REGEX = /^[a-z][a-z0-9_-]*$/; const PATH_REGEX = /^[a-zA-Z0-9_.\/${}~-]+$/; @@ -82,6 +84,23 @@ switch (command) { break; } + case 'resolve-global-root': { + const [hostName] = args; + if (!hostName) { + console.error('Usage: host-config-export.ts resolve-global-root '); + process.exit(1); + } + const config = getHostConfig(hostName); + const parts = config.globalRoot.split('/'); + // Setup consumes this output from a POSIX shell, including Git Bash on Windows. + const defaultBase = path.posix.join(process.env.HOME || '', parts.shift() || ''); + const base = config.globalRootEnv + ? process.env[config.globalRootEnv] || defaultBase + : defaultBase; + console.log(path.posix.join(base, ...parts)); + break; + } + case 'validate': { const errors = validateAllConfigs(ALL_HOST_CONFIGS, new Set(Object.keys(RESOLVERS))); if (errors.length > 0) { @@ -115,6 +134,6 @@ switch (command) { } default: - console.error('Usage: host-config-export.ts [args]'); + console.error('Usage: host-config-export.ts [args]'); process.exit(1); } diff --git a/scripts/host-config.ts b/scripts/host-config.ts index c7a3ae9f3f..0f7dce67aa 100644 --- a/scripts/host-config.ts +++ b/scripts/host-config.ts @@ -23,10 +23,14 @@ export interface HostConfig { cliCommand: string; /** Alternative binary names (e.g., ['droid'] for factory). */ cliAliases?: string[]; + /** Product surfaces covered by this host contract. Defaults to ['cli']. */ + supportedSurfaces?: Array<'cli' | 'app'>; // --- Path Configuration --- /** Global install path relative to $HOME (e.g., '.config/opencode/skills/gstack'). */ globalRoot: string; + /** Optional config-root override env var (e.g., COPILOT_HOME for ~/.copilot). */ + globalRootEnv?: string; /** Project-local skill path relative to repo root (e.g., '.opencode/skills/gstack'). */ localSkillRoot: string; /** Gitignored directory under repo root for generated docs (e.g., '.opencode'). */ @@ -50,6 +54,10 @@ export interface HostConfig { extraFields?: Record; /** Rename fields from template (e.g., { 'voice-triggers': 'triggers' }). */ renameFields?: Record; + /** Prefix generated skill names when the host requires name === directory. */ + namePrefix?: string; + /** Maximum generated skill name length enforced by the host contract. */ + nameLimit?: number; /** Conditionally add fields based on template frontmatter values. */ conditionalFields?: Array<{ if: Record; add: Record }>; }; @@ -116,6 +124,7 @@ export interface HostConfig { const NAME_REGEX = /^[a-z][a-z0-9-]*$/; const PATH_REGEX = /^[a-zA-Z0-9_.\/${}~-]+$/; const CLI_REGEX = /^[a-z][a-z0-9_-]*$/; +const ENV_REGEX = /^[A-Z][A-Z0-9_]*$/; export function validateHostConfig(config: HostConfig, validResolverNames?: ReadonlySet): string[] { const errors: string[] = []; @@ -139,6 +148,9 @@ export function validateHostConfig(config: HostConfig, validResolverNames?: Read if (!PATH_REGEX.test(config.globalRoot)) { errors.push(`globalRoot '${config.globalRoot}' contains invalid characters`); } + if (config.globalRootEnv && !ENV_REGEX.test(config.globalRootEnv)) { + errors.push(`globalRootEnv '${config.globalRootEnv}' must be an uppercase environment variable name`); + } if (!PATH_REGEX.test(config.localSkillRoot)) { errors.push(`localSkillRoot '${config.localSkillRoot}' contains invalid characters`); } @@ -148,6 +160,13 @@ export function validateHostConfig(config: HostConfig, validResolverNames?: Read if (!['allowlist', 'denylist'].includes(config.frontmatter.mode)) { errors.push(`frontmatter.mode must be 'allowlist' or 'denylist'`); } + if (config.frontmatter.namePrefix && !NAME_REGEX.test(config.frontmatter.namePrefix.replace(/-$/, ''))) { + errors.push(`frontmatter.namePrefix '${config.frontmatter.namePrefix}' must be lowercase alphanumeric with hyphens`); + } + if (config.frontmatter.nameLimit !== undefined && + (!Number.isInteger(config.frontmatter.nameLimit) || config.frontmatter.nameLimit < 1)) { + errors.push(`frontmatter.nameLimit must be a positive integer`); + } if (!['real-dir-symlink', 'symlink-generated'].includes(config.install.linkingStrategy)) { errors.push(`install.linkingStrategy must be 'real-dir-symlink' or 'symlink-generated'`); } diff --git a/scripts/resolvers/index.ts b/scripts/resolvers/index.ts index aa598b867b..aa084e3386 100644 --- a/scripts/resolvers/index.ts +++ b/scripts/resolvers/index.ts @@ -36,6 +36,7 @@ import { generateMakePdfSetup } from './make-pdf'; import { generateTasksSectionEmit, generateTasksSectionAggregate } from './tasks-section'; import { SECTION, SECTION_INDEX } from './sections'; import { generateRedactTaxonomyTable, generateRedactInvocationBlock } from './redact-doc'; +import { generateUpgradeLocalInstallSync } from './upgrade'; export const RESOLVERS: Record = { SLUG_EVAL: generateSlugEval, @@ -100,6 +101,7 @@ export const RESOLVERS: Record = { MAKE_PDF_SETUP: generateMakePdfSetup, TASKS_SECTION_EMIT: generateTasksSectionEmit, TASKS_SECTION_AGGREGATE: generateTasksSectionAggregate, + UPGRADE_LOCAL_INSTALL_SYNC: generateUpgradeLocalInstallSync, SECTION, SECTION_INDEX, }; diff --git a/scripts/resolvers/preamble/generate-preamble-bash.ts b/scripts/resolvers/preamble/generate-preamble-bash.ts index f0bc57b4bb..a9edc6b84f 100644 --- a/scripts/resolvers/preamble/generate-preamble-bash.ts +++ b/scripts/resolvers/preamble/generate-preamble-bash.ts @@ -3,9 +3,14 @@ import { getHostConfig } from '../../../hosts/index'; export function generatePreambleBash(ctx: TemplateContext): string { const hostConfig = getHostConfig(ctx.host); + const globalRoot = (() => { + if (!hostConfig.globalRootEnv) return `$HOME/${hostConfig.globalRoot}`; + const [defaultBase, ...rest] = hostConfig.globalRoot.split('/'); + return `\${${hostConfig.globalRootEnv}:-$HOME/${defaultBase}}/${rest.join('/')}`; + })(); const runtimeRoot = hostConfig.usesEnvVars ? `_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) -GSTACK_ROOT="$HOME/${hostConfig.globalRoot}" +GSTACK_ROOT="${globalRoot}" [ -n "$_ROOT" ] && [ -d "$_ROOT/${ctx.paths.localSkillRoot}" ] && GSTACK_ROOT="$_ROOT/${ctx.paths.localSkillRoot}" GSTACK_BIN="$GSTACK_ROOT/bin" GSTACK_BROWSE="$GSTACK_ROOT/browse/dist" diff --git a/scripts/resolvers/upgrade.ts b/scripts/resolvers/upgrade.ts new file mode 100644 index 0000000000..f96c7b2bc5 --- /dev/null +++ b/scripts/resolvers/upgrade.ts @@ -0,0 +1,76 @@ +import type { TemplateContext } from './types'; + +const DEFAULT_LOCAL_INSTALL_SYNC = `### Step 4.5: Handle local vendored copy + +Use the install directory from Step 2. Check if there's also a local vendored copy, and whether team mode is active: + +\`\`\`bash +_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +LOCAL_GSTACK="" +if [ -n "$_ROOT" ] && [ -d "$_ROOT/.claude/skills/gstack" ]; then + _RESOLVED_LOCAL=$(cd "$_ROOT/.claude/skills/gstack" && pwd -P) + _RESOLVED_PRIMARY=$(cd "$INSTALL_DIR" && pwd -P) + if [ "$_RESOLVED_LOCAL" != "$_RESOLVED_PRIMARY" ]; then + LOCAL_GSTACK="$_ROOT/.claude/skills/gstack" + fi +fi +_TEAM_MODE=$(~/.claude/skills/gstack/bin/gstack-config get team_mode 2>/dev/null || echo "false") +echo "LOCAL_GSTACK=$LOCAL_GSTACK" +echo "TEAM_MODE=$_TEAM_MODE" +\`\`\` + +**If \`LOCAL_GSTACK\` is non-empty AND \`TEAM_MODE\` is \`true\`:** Remove the vendored copy. Team mode uses the global install as the single source of truth. + +\`\`\`bash +cd "$_ROOT" +git rm -r --cached .claude/skills/gstack/ 2>/dev/null || true +if ! grep -qF '.claude/skills/gstack/' .gitignore 2>/dev/null; then + echo '.claude/skills/gstack/' >> .gitignore +fi +rm -rf "$LOCAL_GSTACK" +\`\`\` +Tell user: "Removed vendored copy at \`$LOCAL_GSTACK\` (team mode active — global install is the source of truth). Commit the \`.gitignore\` change when ready." + +**If \`LOCAL_GSTACK\` is non-empty AND \`TEAM_MODE\` is NOT \`true\`:** Update it by copying from the freshly-upgraded primary install (same approach as README vendored install): +\`\`\`bash +mv "$LOCAL_GSTACK" "$LOCAL_GSTACK.bak" +cp -Rf "$INSTALL_DIR" "$LOCAL_GSTACK" +rm -rf "$LOCAL_GSTACK/.git" +cd "$LOCAL_GSTACK" && ./setup +rm -rf "$LOCAL_GSTACK.bak" +\`\`\` +Tell user: "Also updated vendored copy at \`$LOCAL_GSTACK\` — commit \`.claude/skills/gstack/\` when you're ready." + +If \`./setup\` fails, restore from backup and warn the user: +\`\`\`bash +rm -rf "$LOCAL_GSTACK" +mv "$LOCAL_GSTACK.bak" "$LOCAL_GSTACK" +\`\`\` +Tell user: "Sync failed — restored previous version at \`$LOCAL_GSTACK\`. Run \`/gstack-upgrade\` manually to retry."`; + +const COPILOT_LOCAL_INSTALL_SYNC = `### Step 4.5: Refresh a repository-local Copilot install + +Copilot repository installs are machine-local. If the current repository has one, +rerun setup from its recorded source checkout so Windows copies and Unix symlinks +both receive the upgraded generated skills and runtime assets. + +\`\`\`bash +_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +LOCAL_GSTACK="" +TEAM_MODE="false" +if [ -n "$_ROOT" ] && [ -f "$_ROOT/.github/skills/gstack/.source-path" ]; then + LOCAL_SOURCE=$(cat "$_ROOT/.github/skills/gstack/.source-path") + if [ ! -x "$LOCAL_SOURCE/setup" ]; then + echo "Copilot repository install source is unavailable: $LOCAL_SOURCE" >&2 + exit 1 + fi + (cd "$_ROOT" && "$LOCAL_SOURCE/setup" --host copilot --local) + echo "Refreshed repository Copilot skills at $_ROOT/.github/skills" +fi +echo "LOCAL_GSTACK=$LOCAL_GSTACK" +echo "TEAM_MODE=$TEAM_MODE" +\`\`\``; + +export function generateUpgradeLocalInstallSync(ctx: TemplateContext): string { + return ctx.host === 'copilot' ? COPILOT_LOCAL_INSTALL_SYNC : DEFAULT_LOCAL_INSTALL_SYNC; +} diff --git a/setup b/setup index 275236cd36..3025abb320 100755 --- a/setup +++ b/setup @@ -24,12 +24,23 @@ FACTORY_SKILLS="$HOME/.factory/skills" FACTORY_GSTACK="$FACTORY_SKILLS/gstack" OPENCODE_SKILLS="$HOME/.config/opencode/skills" OPENCODE_GSTACK="$OPENCODE_SKILLS/gstack" +HOST_CONFIG_EXPORT="$SOURCE_GSTACK_DIR/scripts/host-config-export.ts" + +host_config_get() { + bun run "$HOST_CONFIG_EXPORT" get "$1" "$2" +} IS_WINDOWS=0 case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*|Windows_NT) IS_WINDOWS=1 ;; esac +COPILOT_GSTACK="$(bun run "$HOST_CONFIG_EXPORT" resolve-global-root copilot)" +if [ "$IS_WINDOWS" -eq 1 ] && command -v cygpath >/dev/null 2>&1; then + COPILOT_GSTACK="$(cygpath -u "$COPILOT_GSTACK")" +fi +COPILOT_SKILLS="$(dirname "$COPILOT_GSTACK")" + # ─── Symlink-or-copy helper ─────────────────────────────────── # On macOS/Linux: create a symlink (existing behavior). # On Windows without Developer Mode (MSYS2/Git Bash): plain ln -snf silently @@ -85,7 +96,7 @@ NO_TEAM_MODE=0 PLAN_TUNE_HOOKS_MODE="" # "" = resolve from env/config/prompt; "yes"/"no" = explicit 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, copilot, codex, kiro, factory, opencode, 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 ;; @@ -101,7 +112,7 @@ while [ $# -gt 0 ]; do done case "$HOST" in - claude|codex|kiro|factory|opencode|auto) ;; + claude|copilot|codex|kiro|factory|opencode|auto) ;; openclaw) echo "" echo "OpenClaw integration uses a different model — OpenClaw spawns Claude Code" @@ -136,7 +147,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, copilot, codex, kiro, factory, opencode, openclaw, hermes, gbrain, or auto)" >&2; exit 1 ;; esac # ─── Resolve skill prefix preference ───────────────────────── @@ -180,18 +191,21 @@ else "$GSTACK_CONFIG" set skill_prefix "$([ "$SKILL_PREFIX" -eq 1 ] && echo true || echo false)" 2>/dev/null || true fi -# --local: install to .claude/skills/ in the current working directory (deprecated) +# --local: install to the host's repository skill root. if [ "$LOCAL_INSTALL" -eq 1 ]; then - echo "Warning: --local is deprecated. Use global install + --team instead." >&2 - echo " See: https://github.com/garrytan/gstack#team-mode" >&2 if [ "$HOST" = "codex" ]; then echo "Error: --local is only supported for Claude Code (not Codex)." >&2 exit 1 + elif [ "$HOST" = "copilot" ]; then + INSTALL_SKILLS_DIR="$(pwd)/$(dirname "$(host_config_get copilot localSkillRoot)")" + mkdir -p "$INSTALL_SKILLS_DIR" + else + echo "Warning: --local is deprecated for Claude Code. Use global install + --team instead." >&2 + echo " See: https://github.com/garrytan/gstack#team-mode" >&2 + INSTALL_SKILLS_DIR="$(pwd)/.claude/skills" + mkdir -p "$INSTALL_SKILLS_DIR" + HOST="claude" fi - INSTALL_SKILLS_DIR="$(pwd)/.claude/skills" - mkdir -p "$INSTALL_SKILLS_DIR" - HOST="claude" - INSTALL_CODEX=0 fi # For auto: detect which agents are installed @@ -200,18 +214,22 @@ 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 copilot >/dev/null 2>&1 && INSTALL_COPILOT=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 # 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_COPILOT" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ]; then INSTALL_CLAUDE=1 fi elif [ "$HOST" = "claude" ]; then INSTALL_CLAUDE=1 +elif [ "$HOST" = "copilot" ]; then + INSTALL_COPILOT=1 elif [ "$HOST" = "codex" ]; then INSTALL_CODEX=1 elif [ "$HOST" = "kiro" ]; then @@ -376,7 +394,9 @@ trap cleanup_copied_bun EXIT # 1. Build browse binary if needed (smart rebuild: stale sources, package.json, lock) NEEDS_BUILD=0 -if [ ! -x "$BROWSE_BIN" ]; then +if [ "${GSTACK_TEST_INSTALL_ONLY:-0}" = "1" ]; then + NEEDS_BUILD=0 +elif [ ! -x "$BROWSE_BIN" ]; then NEEDS_BUILD=1 elif [ -n "$(find "$SOURCE_GSTACK_DIR/browse/src" -type f -newer "$BROWSE_BIN" -print -quit 2>/dev/null)" ]; then NEEDS_BUILD=1 @@ -433,7 +453,7 @@ if [ "$NEEDS_BUILD" -eq 1 ]; then fi fi -if [ ! -x "$BROWSE_BIN" ]; then +if [ "${GSTACK_TEST_INSTALL_ONLY:-0}" != "1" ] && [ ! -x "$BROWSE_BIN" ]; then echo "gstack setup failed: browse binary missing at $BROWSE_BIN" >&2 exit 1 fi @@ -475,8 +495,20 @@ if [ "$INSTALL_OPENCODE" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then ) fi +# 1e. Generate Copilot skill docs from the dedicated .copilot staging tree. +if [ "$INSTALL_COPILOT" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then + log "Generating .copilot/ skill docs..." + ( + cd "$SOURCE_GSTACK_DIR" + if [ "${GSTACK_TEST_INSTALL_ONLY:-0}" != "1" ]; then + bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install + fi + bun_cmd run gen:skill-docs --host copilot + ) +fi + # 2. Ensure Playwright's Chromium is available -if ! ensure_playwright_browser; then +if [ "${GSTACK_TEST_INSTALL_ONLY:-0}" != "1" ] && ! ensure_playwright_browser; then echo "Installing Playwright Chromium..." ( cd "$SOURCE_GSTACK_DIR" @@ -504,7 +536,7 @@ if ! ensure_playwright_browser; then fi fi -if ! ensure_playwright_browser; then +if [ "${GSTACK_TEST_INSTALL_ONLY:-0}" != "1" ] && ! ensure_playwright_browser; then if [ "$IS_WINDOWS" -eq 1 ]; then echo "gstack setup failed: Playwright Chromium could not be launched via Node.js" >&2 echo " This is a known issue with Bun on Windows (oven-sh/bun#4253)." >&2 @@ -517,15 +549,17 @@ fi # 2b. Ensure a color-emoji font is installed so make-pdf emoji render (Linux). # Best-effort: warn instead of failing if it can't install. -if ! ensure_emoji_font; then - echo " Note: could not auto-install a color-emoji font. Emoji in make-pdf" >&2 - echo " output may render as boxes (▯). Install one manually, e.g.:" >&2 - echo " Debian/Ubuntu: sudo apt-get install fonts-noto-color-emoji" >&2 - echo " Fedora: sudo dnf install google-noto-color-emoji-fonts" >&2 - echo " Arch: sudo pacman -S noto-fonts-emoji" >&2 - echo " Alpine: sudo apk add font-noto-emoji" >&2 -else - refresh_browse_daemon_for_fonts +if [ "${GSTACK_TEST_INSTALL_ONLY:-0}" != "1" ]; then + if ! ensure_emoji_font; then + echo " Note: could not auto-install a color-emoji font. Emoji in make-pdf" >&2 + echo " output may render as boxes (▯). Install one manually, e.g.:" >&2 + echo " Debian/Ubuntu: sudo apt-get install fonts-noto-color-emoji" >&2 + echo " Fedora: sudo dnf install google-noto-color-emoji-fonts" >&2 + echo " Arch: sudo pacman -S noto-fonts-emoji" >&2 + echo " Alpine: sudo apk add font-noto-emoji" >&2 + else + refresh_browse_daemon_for_fonts + fi fi # 3. Ensure ~/.gstack global state directory exists @@ -972,6 +1006,86 @@ link_opencode_skill_dirs() { fi } +# ─── Helpers: install config-driven generated skills ────────── +# Used by Copilot, whose generated staging tree (.copilot/skills) intentionally +# differs from its repository discovery root (.github/skills). +link_configured_skill_dirs() { + local gstack_dir="$1" + local host="$2" + local skills_dir="$3" + local host_subdir + local generated_dir + local linked=() + + host_subdir="$(host_config_get "$host" hostSubdir)" + generated_dir="$gstack_dir/$host_subdir/skills" + if [ ! -d "$generated_dir" ]; then + echo " Generating $host skill docs..." + ( cd "$gstack_dir" && bun run gen:skill-docs --host "$host" ) + fi + if [ ! -d "$generated_dir" ]; then + echo " warning: $host skill generation failed" >&2 + return 1 + fi + + for skill_dir in "$generated_dir"/gstack*/; do + [ -f "$skill_dir/SKILL.md" ] || continue + skill_name="$(basename "$skill_dir")" + [ "$skill_name" = "gstack" ] && continue + target="$skills_dir/$skill_name" + if [ -L "$target" ] || [ ! -e "$target" ] || [ "$IS_WINDOWS" -eq 1 ]; then + _link_or_copy "$skill_dir" "$target" + linked+=("$skill_name") + fi + done + if [ ${#linked[@]} -gt 0 ]; then + echo " linked skills: ${linked[*]}" + fi +} + +create_configured_runtime_root() { + local gstack_dir="$1" + local host="$2" + local runtime_root="$3" + local host_subdir + local generated_dir + + host_subdir="$(host_config_get "$host" hostSubdir)" + generated_dir="$gstack_dir/$host_subdir/skills" + + if [ -L "$runtime_root" ]; then + rm -f "$runtime_root" + elif [ -d "$runtime_root" ] && [ "$runtime_root" != "$gstack_dir" ]; then + rm -rf "$runtime_root" + fi + mkdir -p "$runtime_root" + printf '%s\n' "$gstack_dir" > "$runtime_root/.source-path" + + if [ -f "$generated_dir/gstack/SKILL.md" ]; then + _link_or_copy "$generated_dir/gstack/SKILL.md" "$runtime_root/SKILL.md" + fi + + while IFS= read -r asset; do + [ -n "$asset" ] || continue + src="$gstack_dir/$asset" + if [ "${asset%/SKILL.md}" != "$asset" ]; then + skill_dir="${asset%/SKILL.md}" + case "$skill_dir" in + gstack-*) generated_skill="$skill_dir" ;; + *) generated_skill="gstack-$skill_dir" ;; + esac + if [ -f "$generated_dir/$generated_skill/SKILL.md" ]; then + src="$generated_dir/$generated_skill/SKILL.md" + fi + fi + dst="$runtime_root/$asset" + [ -e "$src" ] || continue + [ "$src" = "$dst" ] && continue + mkdir -p "$(dirname "$dst")" + _link_or_copy "$src" "$dst" + done < <(bun run "$HOST_CONFIG_EXPORT" symlinks "$host") +} + # 4. Install for Claude (default) SKILLS_BASENAME="$(basename "$INSTALL_SKILLS_DIR")" SKILLS_PARENT_BASENAME="$(basename "$(dirname "$INSTALL_SKILLS_DIR")")" @@ -1193,6 +1307,20 @@ if [ "$INSTALL_OPENCODE" -eq 1 ]; then echo " opencode skills: $OPENCODE_SKILLS" fi +# 6d. Install for GitHub Copilot CLI and app. +if [ "$INSTALL_COPILOT" -eq 1 ]; then + if [ "$LOCAL_INSTALL" -eq 1 ]; then + COPILOT_SKILLS="$INSTALL_SKILLS_DIR" + COPILOT_GSTACK="$COPILOT_SKILLS/gstack" + fi + mkdir -p "$COPILOT_SKILLS" + create_configured_runtime_root "$SOURCE_GSTACK_DIR" copilot "$COPILOT_GSTACK" + link_configured_skill_dirs "$SOURCE_GSTACK_DIR" copilot "$COPILOT_SKILLS" + log "gstack ready (copilot CLI + app)." + log " browse: $BROWSE_BIN" + log " copilot skills: $COPILOT_SKILLS" +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/copilot-host.test.ts b/test/copilot-host.test.ts new file mode 100644 index 0000000000..4e3e08b25a --- /dev/null +++ b/test/copilot-host.test.ts @@ -0,0 +1,286 @@ +import { afterEach, beforeAll, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { copilot, codex } from '../hosts'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const GENERATOR = path.join(ROOT, 'scripts', 'gen-skill-docs.ts'); +const EXPORTER = path.join(ROOT, 'scripts', 'host-config-export.ts'); +const SETUP = path.join(ROOT, 'setup'); +const UNINSTALL = path.join(ROOT, 'bin', 'gstack-uninstall'); +const GENERATED_SKILLS = path.join(ROOT, '.copilot', 'skills'); +const tempDirs: string[] = []; + +function tempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function generatedSkillFiles(): string[] { + return fs.readdirSync(GENERATED_SKILLS, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => path.join(GENERATED_SKILLS, entry.name, 'SKILL.md')) + .filter(file => fs.existsSync(file)); +} + +function parseFrontmatter(file: string): Record { + const content = fs.readFileSync(file, 'utf8'); + const end = content.indexOf('\n---', 4); + expect(content.startsWith('---\n')).toBe(true); + expect(end).toBeGreaterThan(0); + return Bun.YAML.parse(content.slice(4, end)) as Record; +} + +beforeAll(() => { + const result = Bun.spawnSync([process.execPath, 'run', GENERATOR, '--host', 'copilot'], { + cwd: ROOT, + stdout: 'pipe', + stderr: 'pipe', + }); + expect(result.exitCode, result.stderr.toString()).toBe(0); +}); + +afterEach(() => { + while (tempDirs.length > 0) { + fs.rmSync(tempDirs.pop()!, { recursive: true, force: true }); + } +}); + +describe('GitHub Copilot contract', () => { + test('one declarative host covers CLI and app without sharing Codex paths', () => { + expect(copilot.supportedSurfaces).toEqual(['cli', 'app']); + expect(copilot.cliCommand).toBe('copilot'); + expect(copilot.cliAliases).toEqual([]); + expect(copilot.globalRoot).toBe('.copilot/skills/gstack'); + expect(copilot.globalRootEnv).toBe('COPILOT_HOME'); + expect(copilot.localSkillRoot).toBe('.github/skills/gstack'); + expect(copilot.hostSubdir).toBe('.copilot'); + expect(copilot.frontmatter.nameLimit).toBe(64); + expect(copilot.globalRoot).not.toBe(codex.globalRoot); + expect(copilot.localSkillRoot).not.toBe(codex.localSkillRoot); + expect(copilot.hostSubdir).not.toBe(codex.hostSubdir); + }); + + test('detects the standalone copilot executable and not legacy gh alone', () => { + const fakeBin = tempDir('gstack-copilot-detect-'); + const copilotBin = path.join(fakeBin, 'copilot'); + fs.writeFileSync(copilotBin, '#!/bin/sh\nexit 0\n'); + fs.chmodSync(copilotBin, 0o755); + + const detected = Bun.spawnSync([process.execPath, 'run', EXPORTER, 'detect'], { + cwd: ROOT, + env: { ...process.env, PATH: fakeBin }, + stdout: 'pipe', + stderr: 'pipe', + }); + expect(detected.exitCode, detected.stderr.toString()).toBe(0); + expect(detected.stdout.toString().trim().split('\n')).toContain('copilot'); + + fs.rmSync(copilotBin); + const ghBin = path.join(fakeBin, 'gh'); + fs.writeFileSync(ghBin, '#!/bin/sh\nexit 0\n'); + fs.chmodSync(ghBin, 0o755); + const legacyOnly = Bun.spawnSync([process.execPath, 'run', EXPORTER, 'detect'], { + cwd: ROOT, + env: { ...process.env, PATH: fakeBin }, + stdout: 'pipe', + stderr: 'pipe', + }); + expect(legacyOnly.stdout.toString().split('\n')).not.toContain('copilot'); + }); + + test('resolves Git Bash paths without Windows separators', () => { + const resolved = Bun.spawnSync( + [process.execPath, 'run', EXPORTER, 'resolve-global-root', 'copilot'], + { + cwd: ROOT, + env: { ...process.env, HOME: '/c/Users/alice', COPILOT_HOME: '/d/copilot' }, + stdout: 'pipe', + stderr: 'pipe', + } + ); + expect(resolved.exitCode, resolved.stderr.toString()).toBe(0); + expect(resolved.stdout.toString().trim()).toBe('/d/copilot/skills/gstack'); + expect(resolved.stdout.toString()).not.toContain('\\'); + }); + + test('generated skills satisfy Agent Skills name and frontmatter limits', () => { + const files = generatedSkillFiles(); + expect(files.length).toBeGreaterThan(40); + for (const file of files) { + const frontmatter = parseFrontmatter(file); + const directory = path.basename(path.dirname(file)); + expect(Object.keys(frontmatter).sort()).toEqual(['description', 'name']); + expect(frontmatter.name).toBe(directory); + expect(String(frontmatter.name)).toMatch(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); + expect(String(frontmatter.name).length).toBeLessThanOrEqual(64); + expect(String(frontmatter.description).length).toBeGreaterThan(0); + expect(String(frontmatter.description).length).toBeLessThanOrEqual(1024); + } + }); + + test('name-limit failures retain the Copilot host diagnostic', () => { + const generator = fs.readFileSync(GENERATOR, 'utf8'); + expect(generator).toContain( + '`${hostConfig.displayName} frontmatter name exceeds ${fm.nameLimit} characters: `' + ); + expect(generator).not.toContain( + '`${currentHost} frontmatter name exceeds ${fm.nameLimit} characters: `' + ); + }); + + test('generated content uses Copilot paths and tool vocabulary', () => { + const content = generatedSkillFiles().map(file => fs.readFileSync(file, 'utf8')).join('\n'); + expect(content).toContain('${COPILOT_HOME:-$HOME/.copilot}/skills/gstack'); + expect(content).toContain('.github/skills/gstack'); + expect(content).toContain('$GSTACK_ROOT/review/checklist.md'); + expect(content).toContain('ask_user'); + expect(content).toContain('task tool'); + expect(content).not.toContain('.claude/skills'); + expect(content).not.toContain('.agents/skills'); + expect(content).not.toContain('$HOME/.github/skills'); + expect(content).not.toContain('${HOME}/$GSTACK_ROOT'); + expect(content).not.toContain('[ -f "$GSTACK_ROOT/VERSION" ] || [ -d "$GSTACK_ROOT/.git" ]'); + expect(content).not.toContain('AskUserQuestion'); + expect(content).not.toContain('use the Bash tool'); + expect(content).not.toContain('use the Agent tool'); + + const upgrade = fs.readFileSync( + path.join(GENERATED_SKILLS, 'gstack-upgrade', 'SKILL.md'), + 'utf8' + ); + expect(upgrade).toContain('INSTALL_DIR="$(cat "$GSTACK_ROOT/.source-path")"'); + expect(upgrade).toContain('./setup --host copilot'); + expect(upgrade).toContain('### Step 4.5: Refresh a repository-local Copilot install'); + expect(upgrade).toContain('"$LOCAL_SOURCE/setup" --host copilot --local'); + expect(upgrade).not.toContain('$_ROOT/$GSTACK_ROOT'); + expect(upgrade).not.toMatch(/(?:^|&& )\.\/setup(?:\n|$)/m); + }); +}); + +describe('GitHub Copilot install and uninstall', () => { + function setupEnv(home: string): NodeJS.ProcessEnv { + return { + ...process.env, + HOME: home, + COPILOT_HOME: path.join(home, 'custom-copilot'), + GSTACK_HOME: path.join(home, '.gstack'), + GSTACK_TEST_INSTALL_ONLY: '1', + GSTACK_SKIP_FONTS: '1', + GSTACK_SKIP_COREUTILS: '1', + GSTACK_SKIP_GBRAIN_REGEN: '1', + }; + } + + test('global and repository installs resolve skills and runtime assets', () => { + const root = tempDir('gstack-copilot-install-'); + const home = path.join(root, 'home'); + const repo = path.join(root, 'repo'); + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(repo, { recursive: true }); + spawnSync('git', ['init', '-q', repo]); + + const global = spawnSync('bash', [SETUP, '--host', 'copilot', '--quiet'], { + cwd: ROOT, + env: setupEnv(home), + encoding: 'utf8', + timeout: 120_000, + }); + expect(global.status, global.stderr).toBe(0); + + const globalRoot = path.join(home, 'custom-copilot', 'skills'); + expect(parseFrontmatter(path.join(globalRoot, 'gstack', 'SKILL.md')).name).toBe('gstack'); + expect(parseFrontmatter(path.join(globalRoot, 'gstack-review', 'SKILL.md')).name).toBe('gstack-review'); + expect(fs.realpathSync(path.join(globalRoot, 'gstack', 'bin'))).toBe(fs.realpathSync(path.join(ROOT, 'bin'))); + expect(fs.realpathSync(path.join(globalRoot, 'gstack', 'gstack-upgrade', 'SKILL.md'))) + .toBe(fs.realpathSync(path.join(GENERATED_SKILLS, 'gstack-upgrade', 'SKILL.md'))); + expect(fs.realpathSync(path.join(globalRoot, 'gstack', 'plan-ceo-review', 'SKILL.md'))) + .toBe(fs.realpathSync(path.join(GENERATED_SKILLS, 'gstack-plan-ceo-review', 'SKILL.md'))); + expect(fs.realpathSync(path.join(globalRoot, 'gstack', 'document-release', 'SKILL.md'))) + .toBe(fs.realpathSync(path.join(GENERATED_SKILLS, 'gstack-document-release', 'SKILL.md'))); + expect(fs.readFileSync(path.join(globalRoot, 'gstack', '.source-path'), 'utf8').trim()) + .toBe(fs.realpathSync(ROOT)); + for (const asset of [ + 'VERSION', + 'scripts/jargon-list.json', + 'scripts/one-way-doors.ts', + 'lib/redact-audit-log.ts', + 'lib/diagram-render/dist/diagram-render.html', + 'design-html/vendor/pretext.js', + 'extension/manifest.json', + 'hosts/copilot.ts', + 'ios-qa/scripts/gen-accessors.ts', + 'ios-qa/daemon/src/index.ts', + 'review/checklist.md', + ]) { + expect(fs.existsSync(path.join(globalRoot, 'gstack', asset)), asset).toBe(true); + } + + const questionPreference = spawnSync( + path.join(globalRoot, 'gstack', 'bin', 'gstack-question-preference'), + ['--check', 'copilot-runtime-test'], + { env: setupEnv(home), encoding: 'utf8' } + ); + expect(questionPreference.status, questionPreference.stderr).toBe(0); + expect(questionPreference.stdout).toContain('ASK_NORMALLY'); + + const platformDetect = spawnSync( + path.join(globalRoot, 'gstack', 'bin', 'gstack-platform-detect'), + [], + { env: setupEnv(home), encoding: 'utf8' } + ); + expect(platformDetect.status, platformDetect.stderr).toBe(0); + + const local = spawnSync('bash', [SETUP, '--host', 'copilot', '--local', '--quiet'], { + cwd: repo, + env: setupEnv(home), + encoding: 'utf8', + timeout: 120_000, + }); + expect(local.status, local.stderr).toBe(0); + + const localRoot = path.join(repo, '.github', 'skills'); + expect(parseFrontmatter(path.join(localRoot, 'gstack', 'SKILL.md')).name).toBe('gstack'); + expect(parseFrontmatter(path.join(localRoot, 'gstack-review', 'SKILL.md')).name).toBe('gstack-review'); + expect(fs.realpathSync(path.join(localRoot, 'gstack', 'bin'))).toBe(fs.realpathSync(path.join(ROOT, 'bin'))); + expect(fs.realpathSync(path.join(localRoot, 'gstack', 'plan-eng-review', 'SKILL.md'))) + .toBe(fs.realpathSync(path.join(GENERATED_SKILLS, 'gstack-plan-eng-review', 'SKILL.md'))); + expect(fs.readFileSync(path.join(localRoot, 'gstack', '.source-path'), 'utf8').trim()) + .toBe(fs.realpathSync(ROOT)); + }, 130_000); + + test('uninstall removes Copilot personal and repository skills', () => { + const root = tempDir('gstack-copilot-uninstall-'); + const home = path.join(root, 'home'); + const copilotHome = path.join(home, 'custom-copilot'); + const fakeBin = path.join(root, 'bin'); + const repo = path.join(root, 'repo'); + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, 'cygpath'), + `#!/bin/sh\nprintf '%s\\n' '${copilotHome}'\n` + ); + fs.chmodSync(path.join(fakeBin, 'cygpath'), 0o755); + fs.mkdirSync(path.join(copilotHome, 'skills', 'gstack-review'), { recursive: true }); + fs.mkdirSync(path.join(repo, '.github', 'skills', 'gstack-review'), { recursive: true }); + spawnSync('git', ['init', '-q', repo]); + + const result = spawnSync('bash', [UNINSTALL, '--force', '--keep-state'], { + cwd: repo, + env: { + ...process.env, + HOME: home, + COPILOT_HOME: 'C:\\Users\\alice\\copilot', + PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`, + GSTACK_STATE_DIR: path.join(home, '.gstack'), + }, + encoding: 'utf8', + }); + expect(result.status, result.stderr).toBe(0); + expect(fs.existsSync(path.join(copilotHome, 'skills', 'gstack-review'))).toBe(false); + expect(fs.existsSync(path.join(repo, '.github', 'skills', 'gstack-review'))).toBe(false); + }); +}); diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 2fb783ffd0..1a00b8f90a 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -2142,7 +2142,7 @@ describe('Parameterized host smoke tests', () => { cwd: ROOT, stdout: 'pipe', stderr: 'pipe', }); } - }); + }, 30_000); for (const hostConfig of getExternalHosts()) { describe(`${hostConfig.displayName} (--host ${hostConfig.name})`, () => { @@ -2235,7 +2235,7 @@ describe('--host all', () => { cwd: ROOT, stdout: 'pipe', stderr: 'pipe', }); } - }); + }, 30_000); test('--host all generates for all registered hosts', () => { const result = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'all', '--dry-run'], { @@ -2372,9 +2372,9 @@ describe('setup script validation', () => { expect(claudeSection).toContain('link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"'); }); - test('setup supports --host auto|claude|codex|kiro|opencode', () => { + test('setup supports --host auto|claude|copilot|codex|kiro|opencode', () => { expect(setupContent).toContain('--host'); - expect(setupContent).toContain('claude|codex|kiro|factory|opencode|auto'); + expect(setupContent).toContain('claude|copilot|codex|kiro|factory|opencode|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 1c939d7be2..a21f4a1c42 100644 --- a/test/host-config.test.ts +++ b/test/host-config.test.ts @@ -22,6 +22,7 @@ import { slate, cursor, openclaw, + copilot, } from '../hosts/index'; import { HOST_PATHS } from '../scripts/resolvers/types'; import { RESOLVERS } from '../scripts/resolvers'; @@ -32,8 +33,8 @@ const RESOLVER_NAMES = new Set(Object.keys(RESOLVERS)); // ─── 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', () => { @@ -55,6 +56,7 @@ describe('hosts/index.ts', () => { expect(slate.name).toBe('slate'); expect(cursor.name).toBe('cursor'); expect(openclaw.name).toBe('openclaw'); + expect(copilot.name).toBe('copilot'); }); test('getHostConfig returns correct config', () => { @@ -486,6 +488,34 @@ describe('host config correctness', () => { expect(codex.sidecar!.path).toBe('.agents/skills/gstack'); }); + test('copilot uses the shared CLI and app skill contract without .agents collision', () => { + expect(copilot.cliCommand).toBe('copilot'); + expect(copilot.cliAliases).toEqual([]); + expect(copilot.globalRoot).toBe('.copilot/skills/gstack'); + expect(copilot.globalRootEnv).toBe('COPILOT_HOME'); + expect(copilot.localSkillRoot).toBe('.github/skills/gstack'); + expect(copilot.hostSubdir).toBe('.copilot'); + expect(copilot.globalRoot).not.toBe(codex.globalRoot); + expect(copilot.localSkillRoot).not.toBe(codex.localSkillRoot); + expect(copilot.hostSubdir).not.toBe(codex.hostSubdir); + }); + + test('copilot enforces Agent Skills frontmatter limits', () => { + expect(copilot.frontmatter.mode).toBe('allowlist'); + expect(copilot.frontmatter.keepFields).toEqual(['name', 'description']); + expect(copilot.frontmatter.namePrefix).toBe('gstack-'); + expect(copilot.frontmatter.nameLimit).toBe(64); + expect(copilot.frontmatter.descriptionLimit).toBe(1024); + expect(copilot.frontmatter.descriptionLimitBehavior).toBe('error'); + }); + + test('copilot keeps supported task orchestration and rewrites Claude tool names', () => { + expect(copilot.suppressedResolvers).not.toContain('REVIEW_ARMY'); + expect(copilot.suppressedResolvers).not.toContain('DESIGN_OUTSIDE_VOICES'); + expect(copilot.toolRewrites?.AskUserQuestion).toBe('ask_user'); + expect(copilot.toolRewrites?.['use the Agent tool']).toBe('use the task tool'); + }); + test('factory has tool rewrites', () => { expect(factory.toolRewrites).toBeDefined(); expect(Object.keys(factory.toolRewrites!).length).toBeGreaterThan(0); diff --git a/test/setup-windows-fallback.test.ts b/test/setup-windows-fallback.test.ts index cc04fbbd71..04af266805 100644 --- a/test/setup-windows-fallback.test.ts +++ b/test/setup-windows-fallback.test.ts @@ -55,6 +55,14 @@ describe('setup: _link_or_copy invariant (D7)', () => { const fnBody = SETUP_SRC.slice(fnStart, fnEnd); expect(fnBody).toContain('_print_windows_copy_note_once'); }); + + test('config-driven Windows skill copies refresh on repeated setup', () => { + const fnStart = SETUP_SRC.indexOf('link_configured_skill_dirs() {'); + const fnEnd = SETUP_SRC.indexOf('\n}\n', fnStart); + const fnBody = SETUP_SRC.slice(fnStart, fnEnd); + expect(fnBody).toContain('[ "$IS_WINDOWS" -eq 1 ]'); + expect(fnBody).toContain('_link_or_copy "$skill_dir" "$target"'); + }); }); // Behavior matrix uses Unix `ln -snf` semantics in the IS_WINDOWS=0 cells.