mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
refactor: setup tooling uses config-driven host detection
- host-config-export.ts: new CLI that exposes host configs to bash (list, get, detect, validate, symlinks commands) - bin/gstack-platform-detect: reads host configs instead of hardcoded binary/path mapping - scripts/skill-check.ts: iterates host configs for skill validation and freshness checks instead of separate Codex/Factory blocks - lib/worktree.ts: iterates host configs for directory copy instead of hardcoded .agents Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+19
-12
@@ -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
|
||||
|
||||
+7
-4
@@ -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');
|
||||
|
||||
@@ -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 <command> [args]
|
||||
*
|
||||
* Commands:
|
||||
* list Print all host names, one per line
|
||||
* get <host> <field> 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 <host> <field>');
|
||||
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 <host>');
|
||||
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 <list|get|detect|validate|symlinks> [args]');
|
||||
process.exit(1);
|
||||
}
|
||||
+43
-94
@@ -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('');
|
||||
|
||||
Reference in New Issue
Block a user