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.