From b5b70dedf904a885c29fcd07191ebc324b457b3e Mon Sep 17 00:00:00 2001 From: Pradeep Elankumaran Date: Mon, 23 Mar 2026 18:31:52 -0700 Subject: [PATCH] feat: add Pi.dev's open source coding harness as a new host (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Pi as native host for gstack skill generation Add --host pi support to gen-skill-docs.ts and the setup script. Pi skills are generated to .pi/skills/gstack-{name}/SKILL.md with Pi-specific preamble paths ($GSTACK_ROOT defaulting to ~/.pi/agent/skills/gstack), repo-local override from .pi/skills/gstack, and correct frontmatter name: fields that match directory names (gstack-review, not review) to pass Pi's validation. Setup --host pi generates .pi/ skill docs, creates a runtime root at ~/.pi/agent/skills/gstack with symlinked bin/browse/review assets, and copies generated skills into ~/.pi/agent/skills/. * test: add Pi host generation + setup validation tests Mirrors the existing Codex test suite for the new --host pi support: Pi generation (18 tests): - Output path verification for all generated skills - Skill naming (gstack- prefix, no double-prefix) - Frontmatter: name+description only, name matches directory - No openai.yaml metadata (Pi doesn't use it) - No .claude/ or .agents/ path leaks in output - /codex skill excluded - Preamble resolves from ~/.pi/agent/skills/gstack - Sidecar paths use .pi/skills/gstack/review/ (not gstack-review/) - Auto-generated headers present - Dry-run freshness check - Path rewrite rules apply to all skills - Claude output unaffected by Pi generation - Hook skills have safety prose - Multiline descriptions preserved Setup validation (5 tests): - INSTALL_PI flag exists - Pi install section creates runtime root with symlinked assets - Generated skills copied from .pi/skills/ to ~/.pi/agent/skills/ - gen:skill-docs --host pi runs before install - Old whole-dir symlink migration handled Also updated auto mode test to check for pi binary detection. * docs: document Pi vs Codex host differences in ARCHITECTURE.md Add 'Multi-host generation' section to ARCHITECTURE.md covering: - Shared behavior between Pi and Codex (frontmatter, preamble, path rewriting, /codex exclusion, skill naming) - Differences table (output dirs, name: field, openai.yaml, install method, migration, runtime root, repo-local detection) - Path rewrite rules with ordering and sidecar explanation - Setup install flow (generation, runtime root, skill copy, cleanup) Source files (gen-skill-docs.ts, setup) now have short pointers to ARCHITECTURE.md instead of inline comment blocks. Small inline comments kept at key decision points (HOST_PATHS, preamble, transformFrontmatter, openai.yaml). * docs: compress multi-host generation section in ARCHITECTURE.md 74 lines → 46 lines. Consolidated shared transforms into a single paragraph, merged 3 directory rows in the diff table, unified path rewrite rules with brace notation, and trimmed setup flow prose. All material facts preserved. --- .gitignore | 1 + ARCHITECTURE.md | 46 +++++++ README.md | 13 ++ gstack-upgrade/SKILL.md | 6 + gstack-upgrade/SKILL.md.tmpl | 6 + scripts/gen-skill-docs.ts | 74 ++++++++-- setup | 76 ++++++++++- test/gen-skill-docs.test.ts | 258 ++++++++++++++++++++++++++++++++++- 8 files changed, 459 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index 3a57aa4a1..119ac44fd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ bin/gstack-global-discover .gstack/ .claude/skills/ .agents/ +.pi/ .context/ /tmp/ *.log diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8ffc16aae..eb58986f9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -237,6 +237,52 @@ Three reasons: Tier 1 runs on every `bun test`. Tiers 2+3 are gated behind `EVALS=1`. The idea is: catch 95% of issues for free, use LLMs only for judgment calls. +### Multi-host generation (Claude, Codex, Pi) + +Claude-native `.tmpl` templates are transformed per host at generation time: + +``` +bun run gen:skill-docs # Claude (default) → {skill}/SKILL.md +bun run gen:skill-docs --host codex # Codex → .agents/skills/gstack-{name}/SKILL.md +bun run gen:skill-docs --host pi # Pi → .pi/skills/gstack-{name}/SKILL.md +``` + +**Shared transforms (Pi + Codex vs Claude):** Frontmatter stripped to `name:` + `description:` only (Claude's `allowed-tools`/`hooks`/`version` removed; hook-based skills get inline safety prose instead). Preamble uses `$GSTACK_ROOT`/`$GSTACK_BIN`/`$GSTACK_BROWSE` with repo-local-first resolution. `/codex` skill excluded (self-referential). All `~/.claude/` and `.claude/` paths rewritten to host equivalents. Skill naming via `codexSkillName()`: root → `gstack`, subdirs → `gstack-{dir}`, existing `gstack-` prefix preserved. + +#### Pi vs Codex differences + +| Aspect | Pi | Codex | +|--------|-----|-------| +| **Output / global / local dirs** | `.pi/skills/`, `~/.pi/agent/skills/`, `.pi/skills/` | `.agents/skills/`, `~/.codex/skills/`, `.agents/skills/` | +| **name: field** | Must match dir name (`gstack-review`) — Pi validates at runtime | Passes through unchanged | +| **openai.yaml** | Not generated | Generated per skill | +| **Codex CLI sections** | Kept (Pi users may have `codex`) | Stripped (self-referential) | +| **Setup install** | `cp` (avoids dangling symlinks) | `ln -snf` (supports dual-path discovery) | +| **Migration** | None (new host) | `migrate_direct_codex_install()` | +| **Repo-local detection** | Not yet implemented | `CODEX_REPO_LOCAL` flag | + +#### Path rewrite rules + +Order matters — specific before general to avoid prefix collisions: + +``` +~/.claude/skills/gstack → $GSTACK_ROOT # both hosts +.claude/skills/gstack → .{pi,agents}/skills/gstack +.claude/skills/review → .{pi,agents}/skills/gstack/review ← sidecar! +.claude/skills → .{pi,agents}/skills +``` + +The `review → gstack/review` rewrite is critical: review sidecars (checklist.md, design-checklist.md, etc.) live under the `gstack/` runtime root, not under `gstack-review/`. + +#### Pi setup flow (`setup` sections 1c + 7) + +1. **Generate** (`INSTALL_PI=1`): Runs `gen:skill-docs --host pi` explicitly (not included in `bun run build`). +2. **Runtime root**: Creates `~/.pi/agent/skills/gstack/` with symlinked assets (`bin/`, `browse/dist`, `browse/bin`, `ETHOS.md`, review sidecars). +3. **Install**: Copies each `SKILL.md` from generated `.pi/skills/gstack-*/` into `~/.pi/agent/skills/gstack-*/`. +4. **Cleanup**: Removes stale whole-directory symlinks from previous layouts. + +Intentionally omitted vs Codex: no dedicated `create_pi_runtime_root()` (inline suffices), no `create_agents_sidecar`, no migration logic, no repo-local detection (future work). + ## Command dispatch Commands are categorized by side effects: diff --git a/README.md b/README.md index 253d54252..797f4fef7 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,19 @@ cd ~/gstack && ./setup --host auto For Codex-compatible hosts, setup now supports both repo-local installs from `.agents/skills/gstack` and user-global installs from `~/.codex/skills/gstack`. All 28 skills work across all supported agents. Hook-based safety skills (careful, freeze, guard) use inline safety advisory prose on non-Claude hosts. +### Pi + +[Pi](https://github.com/mariozechner/pi-coding-agent) implements the [Agent Skills standard](https://agentskills.io/specification). Skills live in `~/.pi/agent/skills/` and are discovered automatically. + +Install once for your user account: + +```bash +git clone https://github.com/garrytan/gstack.git ~/gstack +cd ~/gstack && ./setup --host pi +``` + +`setup --host pi` copies skills (with rewritten paths) to `~/.pi/agent/skills/` and symlinks runtime assets back to the checkout. Run `./setup --host pi` again after `git pull` to refresh, or use `/skill:gstack-upgrade`. + ## See it work ``` diff --git a/gstack-upgrade/SKILL.md b/gstack-upgrade/SKILL.md index f97f11fb7..449f3078c 100644 --- a/gstack-upgrade/SKILL.md +++ b/gstack-upgrade/SKILL.md @@ -88,12 +88,18 @@ elif [ -d ".claude/skills/gstack/.git" ]; then elif [ -d ".agents/skills/gstack/.git" ]; then INSTALL_TYPE="local-git" INSTALL_DIR=".agents/skills/gstack" +elif [ -d ".pi/skills/gstack/.git" ]; then + INSTALL_TYPE="local-git" + INSTALL_DIR=".pi/skills/gstack" elif [ -d ".claude/skills/gstack" ]; then INSTALL_TYPE="vendored" INSTALL_DIR=".claude/skills/gstack" elif [ -d "$HOME/.claude/skills/gstack" ]; then INSTALL_TYPE="vendored-global" INSTALL_DIR="$HOME/.claude/skills/gstack" +elif [ -d "$HOME/.pi/agent/skills/gstack" ]; then + INSTALL_TYPE="vendored-global" + INSTALL_DIR="$HOME/.pi/agent/skills/gstack" else echo "ERROR: gstack not found" exit 1 diff --git a/gstack-upgrade/SKILL.md.tmpl b/gstack-upgrade/SKILL.md.tmpl index ac25894b3..116f6ffb0 100644 --- a/gstack-upgrade/SKILL.md.tmpl +++ b/gstack-upgrade/SKILL.md.tmpl @@ -86,12 +86,18 @@ elif [ -d ".claude/skills/gstack/.git" ]; then elif [ -d ".agents/skills/gstack/.git" ]; then INSTALL_TYPE="local-git" INSTALL_DIR=".agents/skills/gstack" +elif [ -d ".pi/skills/gstack/.git" ]; then + INSTALL_TYPE="local-git" + INSTALL_DIR=".pi/skills/gstack" elif [ -d ".claude/skills/gstack" ]; then INSTALL_TYPE="vendored" INSTALL_DIR=".claude/skills/gstack" elif [ -d "$HOME/.claude/skills/gstack" ]; then INSTALL_TYPE="vendored-global" INSTALL_DIR="$HOME/.claude/skills/gstack" +elif [ -d "$HOME/.pi/agent/skills/gstack" ]; then + INSTALL_TYPE="vendored-global" + INSTALL_DIR="$HOME/.pi/agent/skills/gstack" else echo "ERROR: gstack not found" exit 1 diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index 340dbb3ca..b7d38b9a7 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -19,7 +19,7 @@ const DRY_RUN = process.argv.includes('--dry-run'); // ─── Template Context ─────────────────────────────────────── -type Host = 'claude' | 'codex'; +type Host = 'claude' | 'codex' | 'pi'; const OPENAI_SHORT_DESCRIPTION_LIMIT = 120; const HOST_ARG = process.argv.find(a => a.startsWith('--host')); @@ -27,8 +27,9 @@ const HOST: Host = (() => { if (!HOST_ARG) return 'claude'; const val = HOST_ARG.includes('=') ? HOST_ARG.split('=')[1] : process.argv[process.argv.indexOf(HOST_ARG) + 1]; if (val === 'codex' || val === 'agents') return 'codex'; + if (val === 'pi') return 'pi'; if (val === 'claude') return 'claude'; - throw new Error(`Unknown host: ${val}. Use claude, codex, or agents.`); + throw new Error(`Unknown host: ${val}. Use claude, codex, pi, or agents.`); })(); interface HostPaths { @@ -38,6 +39,10 @@ interface HostPaths { browseDir: string; } +// Pi vs Codex host differences: see ARCHITECTURE.md § "Multi-host generation" +// for the full reference (shared behavior, unique behavior, path rewrite rules, +// setup install flow, and what Pi intentionally omits vs Codex). + const HOST_PATHS: Record = { claude: { skillRoot: '~/.claude/skills/gstack', @@ -51,6 +56,14 @@ const HOST_PATHS: Record = { binDir: '$GSTACK_BIN', browseDir: '$GSTACK_BROWSE', }, + pi: { + // Pi shares Codex's dynamic $GSTACK_ROOT pattern but resolves to + // ~/.pi/agent/skills/gstack at runtime (see generatePreambleBash). + skillRoot: '$GSTACK_ROOT', + localSkillRoot: '.pi/skills/gstack', + binDir: '$GSTACK_BIN', + browseDir: '$GSTACK_BROWSE', + }, }; interface TemplateContext { @@ -177,12 +190,24 @@ function generateSnapshotFlags(_ctx: TemplateContext): string { } function generatePreambleBash(ctx: TemplateContext): string { + // Pi and Codex both use dynamic $GSTACK_ROOT resolution with a repo-local + // override. The pattern is identical — only the paths differ: + // Codex: default $HOME/.codex/skills/gstack, override .agents/skills/gstack + // Pi: default $HOME/.pi/agent/skills/gstack, override .pi/skills/gstack + // Claude uses hardcoded ~/.claude/skills/gstack paths (no $GSTACK_ROOT). const runtimeRoot = ctx.host === 'codex' ? `_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) GSTACK_ROOT="$HOME/.codex/skills/gstack" [ -n "$_ROOT" ] && [ -d "$_ROOT/.agents/skills/gstack" ] && GSTACK_ROOT="$_ROOT/.agents/skills/gstack" GSTACK_BIN="$GSTACK_ROOT/bin" GSTACK_BROWSE="$GSTACK_ROOT/browse/dist" +` + : ctx.host === 'pi' + ? `_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +GSTACK_ROOT="$HOME/.pi/agent/skills/gstack" +[ -n "$_ROOT" ] && [ -d "$_ROOT/.pi/skills/gstack" ] && GSTACK_ROOT="$_ROOT/.pi/skills/gstack" +GSTACK_BIN="$GSTACK_ROOT/bin" +GSTACK_BROWSE="$GSTACK_ROOT/browse/dist" ` : ''; @@ -2896,9 +2921,14 @@ policy: } /** - * Transform frontmatter for Codex: keep only name + description. + * Transform frontmatter for Codex/Pi: keep only name + description. * Strips allowed-tools, hooks, version, and all other fields. * Handles multiline block scalar descriptions (YAML | syntax). + * + * Pi-specific: the name: field is prefixed with 'gstack-' (e.g., 'review' + * becomes 'gstack-review') because Pi validates that name: matches the + * parent directory name at runtime. Codex doesn't enforce this, so Codex + * names pass through unchanged. */ function transformFrontmatter(content: string, host: Host): string { if (host === 'claude') return content; @@ -2908,7 +2938,13 @@ function transformFrontmatter(content: string, host: Host): string { const fmEnd = content.indexOf('\n---', fmStart + 4); if (fmEnd === -1) return content; const body = content.slice(fmEnd + 4); // includes the leading \n after --- - const { name, description } = extractNameAndDescription(content); + const { name: rawName, description } = extractNameAndDescription(content); + + // For pi host, prefix name with gstack- to match directory convention (codexSkillName) + // Pi validates that the name: field matches the parent directory name + const name = (host === 'pi' && rawName && rawName !== 'gstack' && !rawName.startsWith('gstack-')) + ? `gstack-${rawName}` + : rawName; // Codex 1024-char description limit — fail build, don't ship broken skills const MAX_DESC = 1024; @@ -2969,12 +3005,17 @@ function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath: // Determine skill directory relative to ROOT const skillDir = path.relative(ROOT, path.dirname(tmplPath)); - // For codex host, route output to .agents/skills/{codexSkillName}/SKILL.md + // For codex/pi hosts, route output to {host-dir}/skills/{skillName}/SKILL.md if (host === 'codex') { const codexName = codexSkillName(skillDir === '.' ? '' : skillDir); outputDir = path.join(ROOT, '.agents', 'skills', codexName); fs.mkdirSync(outputDir, { recursive: true }); outputPath = path.join(outputDir, 'SKILL.md'); + } else if (host === 'pi') { + const piName = codexSkillName(skillDir === '.' ? '' : skillDir); + outputDir = path.join(ROOT, '.pi', 'skills', piName); + fs.mkdirSync(outputDir, { recursive: true }); + outputPath = path.join(outputDir, 'SKILL.md'); } // Extract skill name from frontmatter for TemplateContext @@ -3002,8 +3043,8 @@ function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath: throw new Error(`Unresolved placeholders in ${relTmplPath}: ${remaining.join(', ')}`); } - // For codex host: transform frontmatter and replace Claude-specific paths - if (host === 'codex') { + // For codex/pi hosts: transform frontmatter and replace Claude-specific paths + if (host === 'codex' || host === 'pi') { // Extract hook safety prose BEFORE transforming frontmatter (which strips hooks) const safetyProse = extractHookSafetyProse(tmplContent); @@ -3019,10 +3060,19 @@ function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath: // Replace remaining hardcoded Claude paths with host-appropriate paths content = content.replace(/~\/\.claude\/skills\/gstack/g, ctx.paths.skillRoot); content = content.replace(/\.claude\/skills\/gstack/g, ctx.paths.localSkillRoot); - content = content.replace(/\.claude\/skills\/review/g, '.agents/skills/gstack/review'); - content = content.replace(/\.claude\/skills/g, '.agents/skills'); + if (host === 'codex') { + content = content.replace(/\.claude\/skills\/review/g, '.agents/skills/gstack/review'); + content = content.replace(/\.claude\/skills/g, '.agents/skills'); + } else { + content = content.replace(/\.claude\/skills\/review/g, '.pi/skills/gstack/review'); + content = content.replace(/\.claude\/skills/g, '.pi/skills'); + } - if (outputDir) { + // Write openai.yaml agent metadata (codex only). + // Pi doesn't generate openai.yaml — Pi's skill discovery reads the + // name: and description: fields directly from SKILL.md frontmatter. + // Codex needs the separate openai.yaml for its skill browsing UI. + if (host === 'codex' && outputDir) { const codexName = codexSkillName(skillDir === '.' ? '' : skillDir); const agentsDir = path.join(outputDir, 'agents'); fs.mkdirSync(agentsDir, { recursive: true }); @@ -3063,8 +3113,8 @@ function findTemplates(): string[] { let hasChanges = false; for (const tmplPath of findTemplates()) { - // Skip /codex skill for codex host (self-referential — it's a Claude wrapper around codex exec) - if (HOST === 'codex') { + // Skip /codex skill for codex/pi hosts (self-referential — it's a Claude wrapper around codex exec) + if (HOST === 'codex' || HOST === 'pi') { const dir = path.basename(path.dirname(tmplPath)); if (dir === 'codex') continue; } diff --git a/setup b/setup index 4d7d29c01..a2a92a534 100755 --- a/setup +++ b/setup @@ -24,27 +24,29 @@ esac HOST="claude" while [ $# -gt 0 ]; do case "$1" in - --host) [ -z "$2" ] && echo "Missing value for --host (expected claude, codex, kiro, or auto)" >&2 && exit 1; HOST="$2"; shift 2 ;; + --host) [ -z "$2" ] && echo "Missing value for --host (expected claude, codex, kiro, pi, or auto)" >&2 && exit 1; HOST="$2"; shift 2 ;; --host=*) HOST="${1#--host=}"; shift ;; *) shift ;; esac done case "$HOST" in - claude|codex|kiro|auto) ;; - *) echo "Unknown --host value: $HOST (expected claude, codex, kiro, or auto)" >&2; exit 1 ;; + claude|codex|kiro|pi|auto) ;; + *) echo "Unknown --host value: $HOST (expected claude, codex, kiro, pi, or auto)" >&2; exit 1 ;; esac # For auto: detect which agents are installed INSTALL_CLAUDE=0 INSTALL_CODEX=0 INSTALL_KIRO=0 +INSTALL_PI=0 if [ "$HOST" = "auto" ]; then command -v claude >/dev/null 2>&1 && INSTALL_CLAUDE=1 command -v codex >/dev/null 2>&1 && INSTALL_CODEX=1 command -v kiro-cli >/dev/null 2>&1 && INSTALL_KIRO=1 + command -v pi >/dev/null 2>&1 && INSTALL_PI=1 # If none found, default to claude - if [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ]; then + if [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_PI" -eq 0 ]; then INSTALL_CLAUDE=1 fi elif [ "$HOST" = "claude" ]; then @@ -53,6 +55,8 @@ elif [ "$HOST" = "codex" ]; then INSTALL_CODEX=1 elif [ "$HOST" = "kiro" ]; then INSTALL_KIRO=1 +elif [ "$HOST" = "pi" ]; then + INSTALL_PI=1 fi migrate_direct_codex_install() { @@ -134,6 +138,7 @@ fi # Always regenerate: generation is fast (<2s) and mtime-based staleness checks are fragile # (miss stale files when timestamps match after clone/checkout/upgrade). AGENTS_DIR="$SOURCE_GSTACK_DIR/.agents/skills" +PI_GEN_DIR="$SOURCE_GSTACK_DIR/.pi/skills" NEEDS_AGENTS_GEN=1 if [ "$NEEDS_AGENTS_GEN" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then @@ -145,6 +150,17 @@ if [ "$NEEDS_AGENTS_GEN" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then ) fi +# 1c. Generate .pi/ skill docs when installing for Pi. +# bun run build doesn't include --host pi, so always generate when INSTALL_PI=1. +if [ "$INSTALL_PI" -eq 1 ]; then + echo "Generating .pi/ skill docs..." + ( + cd "$SOURCE_GSTACK_DIR" + bun install --frozen-lockfile 2>/dev/null || bun install + bun run gen:skill-docs --host pi + ) +fi + # 2. Ensure Playwright's Chromium is available if ! ensure_playwright_browser; then echo "Installing Playwright Chromium..." @@ -421,14 +437,62 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then fi fi -# 7. Create .agents/ sidecar symlinks for the real Codex skill target. +# 7. Install for Pi (copy generated .pi/skills/ into ~/.pi/agent/skills/) +# See ARCHITECTURE.md § "Multi-host generation" for full Pi vs Codex comparison +# (path conventions, cp vs ln -snf rationale, what's intentionally absent). +if [ "$INSTALL_PI" -eq 1 ]; then + PI_SKILLS="$HOME/.pi/agent/skills" + mkdir -p "$PI_SKILLS" + + # Create gstack runtime root with symlinks for runtime assets + PI_GSTACK="$PI_SKILLS/gstack" + # Remove old whole-dir symlink from previous installs + [ -L "$PI_GSTACK" ] && rm -f "$PI_GSTACK" + mkdir -p "$PI_GSTACK" "$PI_GSTACK/browse" "$PI_GSTACK/gstack-upgrade" "$PI_GSTACK/review" + ln -snf "$SOURCE_GSTACK_DIR/bin" "$PI_GSTACK/bin" + ln -snf "$SOURCE_GSTACK_DIR/browse/dist" "$PI_GSTACK/browse/dist" + ln -snf "$SOURCE_GSTACK_DIR/browse/bin" "$PI_GSTACK/browse/bin" + # ETHOS.md — referenced by "Search Before Building" in all skill preambles + if [ -f "$SOURCE_GSTACK_DIR/ETHOS.md" ]; then + ln -snf "$SOURCE_GSTACK_DIR/ETHOS.md" "$PI_GSTACK/ETHOS.md" + fi + # Review runtime assets (individual files, not whole dir) + for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do + if [ -f "$SOURCE_GSTACK_DIR/review/$f" ]; then + ln -snf "$SOURCE_GSTACK_DIR/review/$f" "$PI_GSTACK/review/$f" + fi + done + + # Link generated Pi-native skills from .pi/skills/ into ~/.pi/agent/skills/ + if [ ! -d "$PI_GEN_DIR" ]; then + echo " warning: no .pi/skills/ directory found — run 'bun run gen:skill-docs --host pi' first" >&2 + else + for skill_dir in "$PI_GEN_DIR"/gstack*/; do + [ -f "$skill_dir/SKILL.md" ] || continue + skill_name="$(basename "$skill_dir")" + target_dir="$PI_SKILLS/$skill_name" + mkdir -p "$target_dir" + # Copy the generated Pi-native SKILL.md (already has correct $GSTACK_ROOT paths) + cp "$skill_dir/SKILL.md" "$target_dir/SKILL.md" + done + # The root gstack SKILL.md is also generated natively + if [ -f "$PI_GEN_DIR/gstack/SKILL.md" ]; then + cp "$PI_GEN_DIR/gstack/SKILL.md" "$PI_GSTACK/SKILL.md" + fi + echo "gstack ready (pi)." + echo " browse: $BROWSE_BIN" + echo " pi skills: $PI_SKILLS" + fi +fi + +# 8. 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. if [ "$INSTALL_CODEX" -eq 1 ]; then create_agents_sidecar "$SOURCE_GSTACK_DIR" fi -# 8. First-time welcome + legacy cleanup +# 9. First-time welcome + legacy cleanup if [ ! -d "$HOME/.gstack" ]; then mkdir -p "$HOME/.gstack" echo " Welcome! Run /gstack-upgrade anytime to stay current." diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 32e77a368..ffdf4f6b2 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -1233,6 +1233,208 @@ describe('Codex generation (--host codex)', () => { }); }); +// ─── Pi generation tests ───────────────────────────────────── + +describe('Pi generation (--host pi)', () => { + const PI_DIR = path.join(ROOT, '.pi', 'skills'); + + // .pi/ is gitignored — generate on demand for tests + Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'pi'], { + cwd: ROOT, stdout: 'pipe', stderr: 'pipe', + }); + + // Dynamic discovery of expected Pi skills: all templates except /codex + const PI_SKILLS = (() => { + const skills: Array<{ dir: string; piName: string }> = []; + if (fs.existsSync(path.join(ROOT, 'SKILL.md.tmpl'))) { + skills.push({ dir: '.', piName: 'gstack' }); + } + for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue; + if (entry.name === 'codex') continue; // /codex is excluded from Pi output + if (!fs.existsSync(path.join(ROOT, entry.name, 'SKILL.md.tmpl'))) continue; + const piName = entry.name.startsWith('gstack-') ? entry.name : `gstack-${entry.name}`; + skills.push({ dir: entry.name, piName }); + } + return skills; + })(); + + test('--host pi generates correct output paths', () => { + for (const skill of PI_SKILLS) { + const skillMd = path.join(PI_DIR, skill.piName, 'SKILL.md'); + expect(fs.existsSync(skillMd)).toBe(true); + } + }); + + test('piSkillName mapping: root is gstack, others are gstack-{dir}', () => { + // Root → gstack + expect(fs.existsSync(path.join(PI_DIR, 'gstack', 'SKILL.md'))).toBe(true); + // Subdirectories → gstack-{dir} + expect(fs.existsSync(path.join(PI_DIR, 'gstack-review', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(PI_DIR, 'gstack-ship', 'SKILL.md'))).toBe(true); + // gstack-upgrade doesn't double-prefix + expect(fs.existsSync(path.join(PI_DIR, 'gstack-upgrade', 'SKILL.md'))).toBe(true); + // No double-prefix: gstack-gstack-upgrade must NOT exist + expect(fs.existsSync(path.join(PI_DIR, 'gstack-gstack-upgrade', 'SKILL.md'))).toBe(false); + }); + + test('Pi frontmatter has name + description only (like Codex, no allowed-tools/version/hooks)', () => { + for (const skill of PI_SKILLS) { + const content = fs.readFileSync(path.join(PI_DIR, skill.piName, 'SKILL.md'), 'utf-8'); + expect(content.startsWith('---\n')).toBe(true); + const fmEnd = content.indexOf('\n---', 4); + expect(fmEnd).toBeGreaterThan(0); + const frontmatter = content.slice(4, fmEnd); + // Must have name and description + expect(frontmatter).toContain('name:'); + expect(frontmatter).toContain('description:'); + // Must NOT have allowed-tools, version, or hooks + expect(frontmatter).not.toContain('allowed-tools:'); + expect(frontmatter).not.toContain('version:'); + expect(frontmatter).not.toContain('hooks:'); + } + }); + + test('Pi frontmatter name: matches directory name (gstack- prefix)', () => { + for (const skill of PI_SKILLS) { + const content = fs.readFileSync(path.join(PI_DIR, skill.piName, 'SKILL.md'), 'utf-8'); + const fmEnd = content.indexOf('\n---', 4); + const frontmatter = content.slice(4, fmEnd); + // Pi validates that name: matches the parent directory name + expect(frontmatter).toContain(`name: ${skill.piName}`); + } + }); + + test('Pi skills do NOT generate openai.yaml metadata', () => { + for (const skill of PI_SKILLS) { + const agentsDir = path.join(PI_DIR, skill.piName, 'agents'); + expect(fs.existsSync(agentsDir)).toBe(false); + } + }); + + test('no .claude/skills/ paths in Pi output', () => { + for (const skill of PI_SKILLS) { + const content = fs.readFileSync(path.join(PI_DIR, skill.piName, 'SKILL.md'), 'utf-8'); + expect(content).not.toContain('.claude/skills'); + } + }); + + test('no ~/.claude/ paths in Pi output', () => { + for (const skill of PI_SKILLS) { + const content = fs.readFileSync(path.join(PI_DIR, skill.piName, 'SKILL.md'), 'utf-8'); + expect(content).not.toContain('~/.claude/'); + } + }); + + test('no .agents/skills/ paths in Pi output', () => { + for (const skill of PI_SKILLS) { + const content = fs.readFileSync(path.join(PI_DIR, skill.piName, 'SKILL.md'), 'utf-8'); + expect(content).not.toContain('.agents/skills'); + } + }); + + test('/codex skill excluded from Pi output', () => { + expect(fs.existsSync(path.join(PI_DIR, 'gstack-codex', 'SKILL.md'))).toBe(false); + expect(fs.existsSync(path.join(PI_DIR, 'gstack-codex'))).toBe(false); + }); + + test('Pi preamble resolves runtime assets from repo-local or global gstack roots', () => { + const content = fs.readFileSync(path.join(PI_DIR, 'gstack-review', 'SKILL.md'), 'utf-8'); + expect(content).toContain('GSTACK_ROOT='); + expect(content).toContain('$HOME/.pi/agent/skills/gstack'); + expect(content).toContain('$_ROOT/.pi/skills/gstack'); + expect(content).toContain('$GSTACK_BIN/'); + }); + + test('sidecar paths point to .pi/skills/gstack/review/ (not gstack-review/)', () => { + const content = fs.readFileSync(path.join(PI_DIR, 'gstack-review', 'SKILL.md'), 'utf-8'); + // Correct: references to sidecar files use gstack/review/ path + expect(content).toContain('.pi/skills/gstack/review/checklist.md'); + expect(content).toContain('.pi/skills/gstack/review/design-checklist.md'); + // Wrong: must NOT reference gstack-review/checklist.md (file doesn't exist there) + expect(content).not.toContain('.pi/skills/gstack-review/checklist.md'); + expect(content).not.toContain('.pi/skills/gstack-review/design-checklist.md'); + }); + + test('sidecar paths in ship skill point to gstack/review/ for pre-landing review', () => { + const content = fs.readFileSync(path.join(PI_DIR, 'gstack-ship', 'SKILL.md'), 'utf-8'); + if (content.includes('checklist.md')) { + expect(content).toContain('.pi/skills/gstack/review/'); + expect(content).not.toContain('.pi/skills/gstack-review/checklist'); + } + }); + + test('all Pi SKILL.md files have auto-generated header', () => { + for (const skill of PI_SKILLS) { + const content = fs.readFileSync(path.join(PI_DIR, skill.piName, 'SKILL.md'), 'utf-8'); + expect(content).toContain('AUTO-GENERATED from SKILL.md.tmpl'); + expect(content).toContain('Regenerate: bun run gen:skill-docs'); + } + }); + + test('--host pi --dry-run freshness', () => { + const result = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'pi', '--dry-run'], { + cwd: ROOT, + stdout: 'pipe', + stderr: 'pipe', + }); + expect(result.exitCode).toBe(0); + const output = result.stdout.toString(); + // Every Pi skill should be FRESH + for (const skill of PI_SKILLS) { + expect(output).toContain(`FRESH: .pi/skills/${skill.piName}/SKILL.md`); + } + expect(output).not.toContain('STALE'); + }); + + test('path rewrite rules apply to all Pi skills with sidecar references', () => { + for (const skill of PI_SKILLS) { + const content = fs.readFileSync(path.join(PI_DIR, skill.piName, 'SKILL.md'), 'utf-8'); + // No skill should reference Claude or Codex paths + expect(content).not.toContain('~/.claude/skills'); + expect(content).not.toContain('.claude/skills'); + expect(content).not.toContain('.agents/skills'); + if (content.includes('gstack-config') || content.includes('gstack-update-check') || content.includes('gstack-telemetry-log')) { + expect(content).toContain('$GSTACK_ROOT'); + } + } + }); + + test('Claude output unchanged: all Claude skills have zero Pi paths', () => { + // Pi changes must NOT affect Claude output + for (const skill of PI_SKILLS) { + if (skill.dir === '.') continue; + const claudePath = path.join(ROOT, skill.dir, 'SKILL.md'); + if (!fs.existsSync(claudePath)) continue; + const content = fs.readFileSync(claudePath, 'utf-8'); + expect(content).not.toContain('.pi/skills'); + expect(content).not.toContain('~/.pi/'); + } + }); + + test('hook skills have safety prose and no hooks: in frontmatter', () => { + const HOOK_SKILLS = ['gstack-careful', 'gstack-freeze', 'gstack-guard']; + for (const skillName of HOOK_SKILLS) { + const skillPath = path.join(PI_DIR, skillName, 'SKILL.md'); + if (!fs.existsSync(skillPath)) continue; + const content = fs.readFileSync(skillPath, 'utf-8'); + expect(content).toContain('Safety Advisory'); + const fmEnd = content.indexOf('\n---', 4); + const frontmatter = content.slice(4, fmEnd); + expect(frontmatter).not.toContain('hooks:'); + } + }); + + test('multiline descriptions preserved in Pi output', () => { + const content = fs.readFileSync(path.join(PI_DIR, 'gstack-office-hours', 'SKILL.md'), 'utf-8'); + const fmEnd = content.indexOf('\n---', 4); + const frontmatter = content.slice(4, fmEnd); + const descLines = frontmatter.split('\n').filter(l => l.startsWith(' ')); + expect(descLines.length).toBeGreaterThan(1); + expect(frontmatter).toContain('YC Office Hours'); + }); +}); + // ─── Setup script validation ───────────────────────────────── // These tests verify the setup script's install layout matches // what the generator produces — catching the bug where setup @@ -1308,15 +1510,16 @@ describe('setup script validation', () => { expect(fnBody).toContain('ln -snf "gstack/$skill_name"'); }); - test('setup supports --host auto|claude|codex|kiro', () => { + test('setup supports --host auto|claude|codex|kiro|pi', () => { expect(setupContent).toContain('--host'); - expect(setupContent).toContain('claude|codex|kiro|auto'); + expect(setupContent).toContain('claude|codex|kiro|pi|auto'); }); - test('auto mode detects claude, codex, and kiro binaries', () => { + test('auto mode detects claude, codex, kiro, and pi binaries', () => { expect(setupContent).toContain('command -v claude'); expect(setupContent).toContain('command -v codex'); expect(setupContent).toContain('command -v kiro-cli'); + expect(setupContent).toContain('command -v pi'); }); // T1: Sidecar skip guard — prevents .agents/skills/gstack from being linked as a skill @@ -1376,6 +1579,55 @@ describe('setup script validation', () => { expect(setupContent).toContain('$HOME/.gstack/repos/gstack'); expect(setupContent).toContain('avoid duplicate skill discovery'); }); + + // ─── Pi setup validation ───────────────────────────────────── + + test('setup has INSTALL_PI flag', () => { + expect(setupContent).toContain('INSTALL_PI='); + expect(setupContent).toContain('INSTALL_PI=1'); + }); + + test('Pi install section creates runtime root with symlinked assets', () => { + const piSection = setupContent.slice( + setupContent.indexOf('# 7. Install for Pi'), + setupContent.indexOf('# 8. Create') + ); + expect(piSection).toContain('PI_SKILLS="$HOME/.pi/agent/skills"'); + expect(piSection).toContain('PI_GSTACK="$PI_SKILLS/gstack"'); + // Runtime asset symlinks + expect(piSection).toContain('ln -snf "$SOURCE_GSTACK_DIR/bin"'); + expect(piSection).toContain('ln -snf "$SOURCE_GSTACK_DIR/browse/dist"'); + expect(piSection).toContain('ln -snf "$SOURCE_GSTACK_DIR/browse/bin"'); + expect(piSection).toContain('ETHOS.md'); + // Review sidecar files + expect(piSection).toContain('checklist.md'); + expect(piSection).toContain('design-checklist.md'); + expect(piSection).toContain('greptile-triage.md'); + expect(piSection).toContain('TODOS-format.md'); + }); + + test('Pi install copies generated skills from .pi/skills/ into ~/.pi/agent/skills/', () => { + const piSection = setupContent.slice( + setupContent.indexOf('# 7. Install for Pi'), + setupContent.indexOf('# 8. Create') + ); + expect(piSection).toContain('PI_GEN_DIR'); + expect(piSection).toContain('cp "$skill_dir/SKILL.md" "$target_dir/SKILL.md"'); + }); + + test('Pi install generates .pi/ skill docs before installing', () => { + // Setup must run gen:skill-docs --host pi before copying + expect(setupContent).toContain('bun run gen:skill-docs --host pi'); + }); + + test('Pi install removes old whole-dir symlink from previous installs', () => { + const piSection = setupContent.slice( + setupContent.indexOf('# 7. Install for Pi'), + setupContent.indexOf('# 8. Create') + ); + // Must handle upgrade from old symlink layout to new mkdir layout + expect(piSection).toContain('[ -L "$PI_GSTACK" ] && rm -f "$PI_GSTACK"'); + }); }); describe('telemetry', () => {