diff --git a/bin/gstack-platform-detect b/bin/gstack-platform-detect index 4fef7331..766a585b 100755 --- a/bin/gstack-platform-detect +++ b/bin/gstack-platform-detect @@ -2,19 +2,26 @@ set -euo pipefail # gstack-platform-detect: show which AI coding agents are installed and gstack status +# Config-driven: reads host definitions from hosts/*.ts via host-config-export.ts + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +GSTACK_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + printf "%-16s %-10s %-40s %s\n" "Agent" "Version" "Skill Path" "gstack" printf "%-16s %-10s %-40s %s\n" "-----" "-------" "----------" "------" -for entry in "claude:claude" "codex:codex" "droid:factory" "kiro-cli:kiro"; do - bin="${entry%%:*}"; label="${entry##*:}" - if command -v "$bin" >/dev/null 2>&1; then - ver=$("$bin" --version 2>/dev/null | head -1 || echo "unknown") - case "$label" in - claude) spath="$HOME/.claude/skills/gstack" ;; - codex) spath="$HOME/.codex/skills/gstack" ;; - factory) spath="$HOME/.factory/skills/gstack" ;; - kiro) spath="$HOME/.kiro/skills/gstack" ;; - esac - status=$([ -d "$spath" ] && echo "INSTALLED" || echo "NOT INSTALLED") - printf "%-16s %-10s %-40s %s\n" "$label" "$ver" "$spath" "$status" + +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" + + if command -v "$cmd" >/dev/null 2>&1; then + ver=$("$cmd" --version 2>/dev/null | head -1 || echo "unknown") + if [ -d "$spath" ] || [ -L "$spath" ]; then + status="INSTALLED" + else + status="NOT INSTALLED" + fi + printf "%-16s %-10s %-40s %s\n" "$host" "$ver" "$spath" "$status" fi done diff --git a/lib/worktree.ts b/lib/worktree.ts index 2337399f..1e68884b 100644 --- a/lib/worktree.ts +++ b/lib/worktree.ts @@ -123,10 +123,13 @@ export class WorktreeManager { // Create detached worktree at current HEAD git(['worktree', 'add', '--detach', worktreePath, 'HEAD'], this.repoRoot); - // Copy gitignored build artifacts that tests need - const agentsSrc = path.join(this.repoRoot, '.agents'); - if (fs.existsSync(agentsSrc)) { - copyDirSync(agentsSrc, path.join(worktreePath, '.agents')); + // Copy gitignored build artifacts that tests need (config-driven) + const { getExternalHosts } = require('../hosts/index'); + for (const hostConfig of getExternalHosts()) { + const hostSrc = path.join(this.repoRoot, hostConfig.hostSubdir); + if (fs.existsSync(hostSrc)) { + copyDirSync(hostSrc, path.join(worktreePath, hostConfig.hostSubdir)); + } } const browseDist = path.join(this.repoRoot, 'browse', 'dist'); diff --git a/scripts/host-config-export.ts b/scripts/host-config-export.ts new file mode 100644 index 00000000..bca436f2 --- /dev/null +++ b/scripts/host-config-export.ts @@ -0,0 +1,119 @@ +#!/usr/bin/env bun +/** + * Export host configs as shell-safe values for consumption by the bash setup script. + * + * Usage: bun run scripts/host-config-export.ts [args] + * + * Commands: + * 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 + * validate Validate all configs, exit 1 on error + * + * All output is shell-safe (single-quoted values, no eval needed). + */ + +import { ALL_HOST_CONFIGS, getHostConfig, ALL_HOST_NAMES } from '../hosts/index'; +import { validateAllConfigs } from './host-config'; +import { execSync } from 'child_process'; + +const CLI_REGEX = /^[a-z][a-z0-9_-]*$/; +const PATH_REGEX = /^[a-zA-Z0-9_.\/${}~-]+$/; + +function shellEscape(s: string): string { + return "'" + s.replace(/'/g, "'\\''") + "'"; +} + +function validateValue(val: string, context: string): void { + if (!PATH_REGEX.test(val) && !CLI_REGEX.test(val)) { + throw new Error(`Unsafe value for ${context}: ${val}`); + } +} + +const [command, ...args] = process.argv.slice(2); + +switch (command) { + case 'list': + for (const name of ALL_HOST_NAMES) { + console.log(name); + } + break; + + case 'get': { + const [hostName, field] = args; + if (!hostName || !field) { + console.error('Usage: host-config-export.ts get '); + process.exit(1); + } + const config = getHostConfig(hostName); + const value = (config as any)[field]; + if (value === undefined) { + console.error(`Unknown field: ${field}`); + process.exit(1); + } + if (typeof value === 'string') { + console.log(value); + } else if (typeof value === 'boolean') { + console.log(value ? '1' : '0'); + } else if (Array.isArray(value)) { + for (const item of value) { + console.log(typeof item === 'string' ? item : JSON.stringify(item)); + } + } else { + console.log(JSON.stringify(value)); + } + break; + } + + case 'detect': { + for (const config of ALL_HOST_CONFIGS) { + const commands = [config.cliCommand, ...(config.cliAliases || [])]; + for (const cmd of commands) { + try { + execSync(`command -v ${shellEscape(cmd)}`, { stdio: 'pipe' }); + console.log(config.name); + break; // Found this host, move to next + } catch { + // Binary not found, try next alias + } + } + } + break; + } + + case 'validate': { + const errors = validateAllConfigs(ALL_HOST_CONFIGS); + if (errors.length > 0) { + for (const error of errors) { + console.error(`ERROR: ${error}`); + } + process.exit(1); + } + console.log(`All ${ALL_HOST_CONFIGS.length} configs valid`); + break; + } + + case 'symlinks': { + const [hostName] = args; + if (!hostName) { + console.error('Usage: host-config-export.ts symlinks '); + process.exit(1); + } + const config = getHostConfig(hostName); + for (const link of config.runtimeRoot.globalSymlinks) { + console.log(link); + } + if (config.runtimeRoot.globalFiles) { + for (const [dir, files] of Object.entries(config.runtimeRoot.globalFiles)) { + for (const file of files) { + console.log(`${dir}/${file}`); + } + } + } + break; + } + + default: + console.error('Usage: host-config-export.ts [args]'); + process.exit(1); +} diff --git a/scripts/skill-check.ts b/scripts/skill-check.ts index e859d9b5..ebcced40 100644 --- a/scripts/skill-check.ts +++ b/scripts/skill-check.ts @@ -79,111 +79,60 @@ for (const file of SKILL_FILES) { } } -// ─── Codex Skills ─────────────────────────────────────────── +// ─── External Host Skills (config-driven) ─────────────────── -const AGENTS_DIR = path.join(ROOT, '.agents', 'skills'); -if (fs.existsSync(AGENTS_DIR)) { - console.log('\n Codex Skills (.agents/skills/):'); - const codexDirs = fs.readdirSync(AGENTS_DIR).sort(); - let codexCount = 0; - let codexMissing = 0; - for (const dir of codexDirs) { - const skillMd = path.join(AGENTS_DIR, dir, 'SKILL.md'); - if (fs.existsSync(skillMd)) { - codexCount++; - const content = fs.readFileSync(skillMd, 'utf-8'); - // Quick validation: must have frontmatter with name + description only - const hasClaude = content.includes('.claude/skills'); - if (hasClaude) { - hasErrors = true; - console.log(` \u274c ${dir.padEnd(30)} — contains .claude/skills reference`); +import { getExternalHosts } from '../hosts/index'; + +for (const hostConfig of getExternalHosts()) { + const hostDir = path.join(ROOT, hostConfig.hostSubdir, 'skills'); + if (fs.existsSync(hostDir)) { + console.log(`\n ${hostConfig.displayName} Skills (${hostConfig.hostSubdir}/skills/):`); + const dirs = fs.readdirSync(hostDir).sort(); + let count = 0; + let missing = 0; + for (const dir of dirs) { + const skillMd = path.join(hostDir, dir, 'SKILL.md'); + if (fs.existsSync(skillMd)) { + count++; + const content = fs.readFileSync(skillMd, 'utf-8'); + const hasClaude = content.includes('.claude/skills'); + if (hasClaude) { + hasErrors = true; + console.log(` \u274c ${dir.padEnd(30)} — contains .claude/skills reference`); + } else { + console.log(` \u2705 ${dir.padEnd(30)} — OK`); + } } else { - console.log(` \u2705 ${dir.padEnd(30)} — OK`); - } - } else { - codexMissing++; - hasErrors = true; - console.log(` \u274c ${dir.padEnd(30)} — SKILL.md missing`); - } - } - console.log(` Total: ${codexCount} skills, ${codexMissing} missing`); -} else { - console.log('\n Codex Skills: .agents/skills/ not found (run: bun run gen:skill-docs --host codex)'); -} - -// ─── Factory Skills ───────────────────────────────────────── - -const FACTORY_DIR = path.join(ROOT, '.factory', 'skills'); -if (fs.existsSync(FACTORY_DIR)) { - console.log('\n Factory Skills (.factory/skills/):'); - const factoryDirs = fs.readdirSync(FACTORY_DIR).sort(); - let factoryCount = 0; - let factoryMissing = 0; - for (const dir of factoryDirs) { - const skillMd = path.join(FACTORY_DIR, dir, 'SKILL.md'); - if (fs.existsSync(skillMd)) { - factoryCount++; - const content = fs.readFileSync(skillMd, 'utf-8'); - const hasClaude = content.includes('.claude/skills'); - if (hasClaude) { + missing++; hasErrors = true; - console.log(` \u274c ${dir.padEnd(30)} — contains .claude/skills reference`); - } else { - console.log(` \u2705 ${dir.padEnd(30)} — OK`); + console.log(` \u274c ${dir.padEnd(30)} — SKILL.md missing`); } - } else { - factoryMissing++; - hasErrors = true; - console.log(` \u274c ${dir.padEnd(30)} — SKILL.md missing`); } + console.log(` Total: ${count} skills, ${missing} missing`); + } else { + console.log(`\n ${hostConfig.displayName} Skills: ${hostConfig.hostSubdir}/skills/ not found (run: bun run gen:skill-docs --host ${hostConfig.name})`); } - console.log(` Total: ${factoryCount} skills, ${factoryMissing} missing`); -} else { - console.log('\n Factory Skills: .factory/skills/ not found (run: bun run gen:skill-docs --host factory)'); } -// ─── Freshness ────────────────────────────────────────────── +// ─── Freshness (config-driven) ────────────────────────────── -console.log('\n Freshness (Claude):'); -try { - execSync('bun run scripts/gen-skill-docs.ts --dry-run', { cwd: ROOT, stdio: 'pipe' }); - console.log(' \u2705 All Claude generated files are fresh'); -} catch (err: any) { - hasErrors = true; - const output = err.stdout?.toString() || ''; - console.log(' \u274c Claude generated files are stale:'); - for (const line of output.split('\n').filter((l: string) => l.startsWith('STALE'))) { - console.log(` ${line}`); - } - console.log(' Run: bun run gen:skill-docs'); -} +import { ALL_HOST_CONFIGS } from '../hosts/index'; -console.log('\n Freshness (Codex):'); -try { - execSync('bun run scripts/gen-skill-docs.ts --host codex --dry-run', { cwd: ROOT, stdio: 'pipe' }); - console.log(' \u2705 All Codex generated files are fresh'); -} catch (err: any) { - hasErrors = true; - const output = err.stdout?.toString() || ''; - console.log(' \u274c Codex generated files are stale:'); - for (const line of output.split('\n').filter((l: string) => l.startsWith('STALE'))) { - console.log(` ${line}`); +for (const hostConfig of ALL_HOST_CONFIGS) { + const hostFlag = hostConfig.name === 'claude' ? '' : ` --host ${hostConfig.name}`; + console.log(`\n Freshness (${hostConfig.displayName}):`); + try { + execSync(`bun run scripts/gen-skill-docs.ts${hostFlag} --dry-run`, { cwd: ROOT, stdio: 'pipe' }); + console.log(` \u2705 All ${hostConfig.displayName} generated files are fresh`); + } catch (err: any) { + hasErrors = true; + const output = err.stdout?.toString() || ''; + console.log(` \u274c ${hostConfig.displayName} generated files are stale:`); + for (const line of output.split('\n').filter((l: string) => l.startsWith('STALE'))) { + console.log(` ${line}`); + } + console.log(` Run: bun run gen:skill-docs${hostFlag}`); } - console.log(' Run: bun run gen:skill-docs --host codex'); -} - -console.log('\n Freshness (Factory):'); -try { - execSync('bun run scripts/gen-skill-docs.ts --host factory --dry-run', { cwd: ROOT, stdio: 'pipe' }); - console.log(' \u2705 All Factory generated files are fresh'); -} catch (err: any) { - hasErrors = true; - const output = err.stdout?.toString() || ''; - console.log(' \u274c Factory generated files are stale:'); - for (const line of output.split('\n').filter((l: string) => l.startsWith('STALE'))) { - console.log(` ${line}`); - } - console.log(' Run: bun run gen:skill-docs --host factory'); } console.log('');