diff --git a/.gitignore b/.gitignore index 5196c0d05..50ec63d50 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ bin/gstack-global-discover* .hermes/ .gbrain/ .gbrain-source +.gemini/ .context/ extension/.auth.json # xterm assets are vendored from npm at build time; not source-of-truth. diff --git a/CHANGELOG.md b/CHANGELOG.md index 351e7cabd..f89b8a222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [1.61.0.0] - 2026-07-12 + +## **Permanent Antigravity (Agy) integration and benchmark support are live.** +## **gstack now runs on Agy, registers/updates plugins automatically, and supports Agy model benchmarks.** + +Agy integration is now permanent and first-class. Running setup with `--host agy` generates and imports Agy-compatible skills, excludes incompatible skills, and registers the gstack plugin in Agy's plugin configuration. This release also fixes several test-suite and helper issues that were causing CI failures, and adds Agy model benchmarking. + +### Itemized changes + +#### Added + +- `hosts/agy.ts`: Declarative host configuration for Agy. +- `test/helpers/providers/agy.ts`: Agy benchmark runner adapter that executes `agy --print` with safely skipped permissions. +- `docs/AGY.md`: User documentation for Agy installation and integration. + +#### Fixed + +- `setup`: Support `--host agy` flag, build `plugin.json` and skill directories under `.gemini/config/plugins/gstack`, and trigger `agy plugin install`. +- `bin/gstack-codex-session-import`: Added `-r` flag to `xargs` pipeline to prevent listing files in current directory when empty. +- `test/gbrain-detect-install.test.ts`: Appended bun's executable directory to test environment `PATH` to resolve shebang resolution failures. +- `test/gbrain-refresh-install-render.test.ts`: Resolved regex pattern mismatch in status check. + ## [1.60.1.0] - 2026-07-09 ## **The /autoplan dual-voice eval is back on the board, catching real regressions.** diff --git a/VERSION b/VERSION index c4190e004..82b3fa4c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.60.1.0 +1.61.0.0 diff --git a/bin/gstack-codex-session-import b/bin/gstack-codex-session-import index 91368cac9..f6c091d00 100755 --- a/bin/gstack-codex-session-import +++ b/bin/gstack-codex-session-import @@ -65,7 +65,7 @@ case "$MODE" in exit 0 fi LATEST=$(find "$CODEX_SESSIONS_ROOT" -type f -name "rollout-*.jsonl" -print 2>/dev/null \ - | xargs ls -t 2>/dev/null | head -1 || true) + | xargs -r ls -t 2>/dev/null | head -1 || true) if [ -z "$LATEST" ]; then echo "NO_SESSIONS: no rollout-*.jsonl files under $CODEX_SESSIONS_ROOT" exit 0 diff --git a/bin/gstack-model-benchmark b/bin/gstack-model-benchmark index c5f5cb5b6..a102f2dcd 100755 --- a/bin/gstack-model-benchmark +++ b/bin/gstack-model-benchmark @@ -31,11 +31,13 @@ import { runBenchmark, formatTable, formatJson, formatMarkdown, type BenchmarkIn import { ClaudeAdapter } from '../test/helpers/providers/claude'; import { GptAdapter } from '../test/helpers/providers/gpt'; import { GeminiAdapter } from '../test/helpers/providers/gemini'; +import { AgyAdapter } from '../test/helpers/providers/agy'; const ADAPTER_FACTORIES = { claude: () => new ClaudeAdapter(), gpt: () => new GptAdapter(), gemini: () => new GeminiAdapter(), + agy: () => new AgyAdapter(), }; type OutputFormat = 'table' | 'json' | 'markdown'; @@ -76,13 +78,13 @@ function positionalArgs(args: string[]): string[] { return positional; } -function parseProviders(s: string | undefined): Array<'claude' | 'gpt' | 'gemini'> { +function parseProviders(s: string | undefined): Array<'claude' | 'gpt' | 'gemini' | 'agy'> { if (!s) return ['claude']; - const seen = new Set<'claude' | 'gpt' | 'gemini'>(); + const seen = new Set<'claude' | 'gpt' | 'gemini' | 'agy'>(); for (const p of s.split(',').map(x => x.trim()).filter(Boolean)) { - if (p === 'claude' || p === 'gpt' || p === 'gemini') seen.add(p); + if (p === 'claude' || p === 'gpt' || p === 'gemini' || p === 'agy') seen.add(p); else { - console.error(`WARN: unknown provider '${p}' — skipping. Valid: claude, gpt, gemini.`); + console.error(`WARN: unknown provider '${p}' — skipping. Valid: claude, gpt, gemini, agy.`); } } return seen.size ? Array.from(seen) : ['claude']; @@ -149,7 +151,7 @@ async function main(): Promise { async function dryRunReport(opts: { prompt: string; - providers: Array<'claude' | 'gpt' | 'gemini'>; + providers: Array<'claude' | 'gpt' | 'gemini' | 'agy'>; workdir: string; timeoutMs: number; output: OutputFormat; diff --git a/docs/ADDING_A_HOST.md b/docs/ADDING_A_HOST.md index 50654e4e2..7967d4638 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, Hermes, GBrain, Agy) 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,9 @@ hosts/ ├── slate.ts # Slate (Random Labs) ├── cursor.ts # Cursor ├── openclaw.ts # OpenClaw (hybrid: config + adapter) +├── hermes.ts # Hermes +├── gbrain.ts # GBrain +├── agy.ts # Google Antigravity └── index.ts # Registry: imports all, derives Host type ``` diff --git a/docs/AGY.md b/docs/AGY.md new file mode 100644 index 000000000..456b9445a --- /dev/null +++ b/docs/AGY.md @@ -0,0 +1,71 @@ +# Google Antigravity (Agy) Integration + +gstack supports Google Antigravity (Agy) as a first-class agent host. This integration allows you to generate and run Agy-compatible skills, load them dynamically via the `agy` plugin system, and benchmark agy models alongside Claude, OpenAI, and Gemini. + +## Features + +- **Automatic Setup**: Installs gstack skills directly into the Antigravity config directory via `./setup --host agy`. +- **Agy Plugin Compatibility**: Packages skills as a standard Agy plugin with a `plugin.json` manifest, automatically registered with `agy plugin install`. +- **Exclusion of Incompatible Skills**: Automatically filters out Claude-specific wrappers (like `gstack-codex`) from Agy generation. +- **Benchmark Integration**: Register Agy as a benchmark provider using `agy --print` to trace latency, quality, and tokens. + +--- + +## Setup & Installation + +To generate and install gstack skills into your local Antigravity environment: + +```bash +./setup --host agy +``` + +### What Setup Does + +1. **Precompile & Prepare**: Ensures the browse tool and other runtime dependencies are compiled. +2. **Generate Skills**: Runs `bun run gen:skill-docs --host agy` to generate Agy-compatible frontmatter. +3. **Initialize Plugin**: Creates the plugin directory structure under `.gemini/config/plugins/gstack`. +4. **Symlink/Copy Assets**: Places necessary executables (`bin`, `browse`, `review`, `qa`) into the plugin root. +5. **Register Plugin**: Invokes `agy plugin install` to import the gstack plugin into `~/.gemini/config/import_manifest.json`. + +--- + +## Plugin Structure + +When installed, the gstack plugin resides at: +`~/.gemini/config/plugins/gstack/` + +The directory layout matches the standard Agy plugin structure: + +``` +~/.gemini/config/plugins/gstack/ +├── plugin.json # Manifest declaring name, version, and license +├── skills/ # Generated Agy-compatible skills +│ ├── gstack/ # Root gstack skill (/gstack) +│ ├── gstack-qa/ # QA testing skill (/qa) +│ └── ... +├── bin/ # Shared executables and CLI tools +└── browse/ # Browse tool distribution files +``` + +--- + +## Benchmarking with Agy + +Agy is registered as a model benchmark provider. You can execute prompts against Agy using the gstack benchmark runner. + +### Pre-requisites +- The `agy` CLI must be installed and available in your `PATH`. +- Credentials must be configured in `~/.gemini/oauth_creds.json`. + +### Run Benchmark +To run a dry run and check availability: +```bash +gstack-model-benchmark --models agy --dry-run +``` + +To execute a benchmark run: +```bash +gstack-model-benchmark --models agy --prompt "Explain quantum computing in one sentence." +``` + +The runner automatically passes `--dangerously-skip-permissions` to the CLI to run headless without hanging on interactive permission prompts. diff --git a/hosts/agy.ts b/hosts/agy.ts new file mode 100644 index 000000000..b938b3b1a --- /dev/null +++ b/hosts/agy.ts @@ -0,0 +1,59 @@ +import type { HostConfig } from '../scripts/host-config'; + +const agy: HostConfig = { + name: 'agy', + displayName: 'Antigravity', + cliCommand: 'agy', + cliAliases: [], + + globalRoot: '.gemini/config/plugins/gstack/skills', + localSkillRoot: '.gemini/config/plugins/gstack/skills', + hostSubdir: '.gemini/config/plugins/gstack', + usesEnvVars: true, + + frontmatter: { + mode: 'allowlist', + keepFields: [ + 'name', + 'preamble-tier', + 'version', + 'description', + 'allowed-tools', + 'triggers', + 'hooks', + 'gbrain', + ], + descriptionLimit: null, + }, + + generation: { + generateMetadata: false, + skipSkills: ['codex'], + }, + + pathRewrites: [ + { from: '~/.claude/skills/gstack', to: '$GSTACK_ROOT' }, + { from: '.claude/skills/gstack', to: '.gemini/config/plugins/gstack/skills' }, + { from: '.claude/skills', to: '.gemini/config/plugins/gstack' }, + ], + + toolRewrites: {}, + + suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'], + + runtimeRoot: { + globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], + globalFiles: { + 'review': ['checklist.md', 'TODOS-format.md'], + }, + }, + + install: { + prefixable: false, + linkingStrategy: 'symlink-generated', + }, + + learningsMode: 'basic', +}; + +export default agy; diff --git a/hosts/index.ts b/hosts/index.ts index cc1c213b5..4cd643ce4 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 agy from './agy'; /** 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, agy]; /** 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, agy }; diff --git a/package.json b/package.json index 846438a60..9ffb1caf7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.60.1.0", + "version": "1.61.0.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/setup b/setup index 275236cd3..c96a31181 100755 --- a/setup +++ b/setup @@ -24,6 +24,9 @@ FACTORY_SKILLS="$HOME/.factory/skills" FACTORY_GSTACK="$FACTORY_SKILLS/gstack" OPENCODE_SKILLS="$HOME/.config/opencode/skills" OPENCODE_GSTACK="$OPENCODE_SKILLS/gstack" +AGY_PLUGINS="$HOME/.gemini/config/plugins" +AGY_GSTACK="$AGY_PLUGINS/gstack" +AGY_SKILLS="$AGY_GSTACK/skills" IS_WINDOWS=0 case "$(uname -s)" in @@ -85,7 +88,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, codex, kiro, factory, opencode, openclaw, hermes, gbrain, agy, 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 +104,7 @@ while [ $# -gt 0 ]; do done case "$HOST" in - claude|codex|kiro|factory|opencode|auto) ;; + claude|codex|kiro|factory|opencode|agy|auto) ;; openclaw) echo "" echo "OpenClaw integration uses a different model — OpenClaw spawns Claude Code" @@ -136,7 +139,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, codex, kiro, factory, opencode, openclaw, hermes, gbrain, agy, or auto)" >&2; exit 1 ;; esac # ─── Resolve skill prefix preference ───────────────────────── @@ -200,14 +203,16 @@ INSTALL_CODEX=0 INSTALL_KIRO=0 INSTALL_FACTORY=0 INSTALL_OPENCODE=0 +INSTALL_AGY=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 droid >/dev/null 2>&1 && INSTALL_FACTORY=1 command -v opencode >/dev/null 2>&1 && INSTALL_OPENCODE=1 + command -v agy >/dev/null 2>&1 && INSTALL_AGY=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_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ] && [ "$INSTALL_AGY" -eq 0 ]; then INSTALL_CLAUDE=1 fi elif [ "$HOST" = "claude" ]; then @@ -220,6 +225,8 @@ elif [ "$HOST" = "factory" ]; then INSTALL_FACTORY=1 elif [ "$HOST" = "opencode" ]; then INSTALL_OPENCODE=1 +elif [ "$HOST" = "agy" ]; then + INSTALL_AGY=1 fi migrate_direct_codex_install() { @@ -475,6 +482,16 @@ if [ "$INSTALL_OPENCODE" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then ) fi +# 1e. Generate .gemini/ Agy skill docs +if [ "$INSTALL_AGY" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then + log "Generating .gemini/ skill docs..." + ( + cd "$SOURCE_GSTACK_DIR" + bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install + bun_cmd run gen:skill-docs --host agy + ) +fi + # 2. Ensure Playwright's Chromium is available if ! ensure_playwright_browser; then echo "Installing Playwright Chromium..." @@ -972,6 +989,88 @@ link_opencode_skill_dirs() { fi } +create_agy_runtime_root() { + local gstack_dir="$1" + local agy_gstack="$2" + local agy_dir="$gstack_dir/.gemini/config/plugins/gstack/skills" + + if [ -L "$agy_gstack" ]; then + rm -f "$agy_gstack" + elif [ -d "$agy_gstack" ] && [ "$agy_gstack" != "$gstack_dir" ]; then + rm -rf "$agy_gstack" + fi + + mkdir -p "$agy_gstack" "$agy_gstack/browse" "$agy_gstack/design" "$agy_gstack/gstack-upgrade" "$agy_gstack/review" "$agy_gstack/qa" "$agy_gstack/plan-devex-review" "$agy_gstack/skills" + + # Create plugin.json for agy plugin structure + echo '{"name": "gstack", "version": "1.58.5.0", "description": "gstack skills for Google Antigravity", "author": {"name": "gstack"}, "license": "MIT"}' > "$agy_gstack/plugin.json" + + if [ -d "$gstack_dir/bin" ]; then + _link_or_copy "$gstack_dir/bin" "$agy_gstack/bin" + fi + if [ -d "$gstack_dir/browse/dist" ]; then + _link_or_copy "$gstack_dir/browse/dist" "$agy_gstack/browse/dist" + fi + if [ -d "$gstack_dir/browse/bin" ]; then + _link_or_copy "$gstack_dir/browse/bin" "$agy_gstack/browse/bin" + fi + if [ -d "$gstack_dir/design/dist" ]; then + _link_or_copy "$gstack_dir/design/dist" "$agy_gstack/design/dist" + fi + for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do + if [ -f "$gstack_dir/review/$f" ]; then + _link_or_copy "$gstack_dir/review/$f" "$agy_gstack/review/$f" + fi + done + if [ -d "$gstack_dir/review/specialists" ]; then + _link_or_copy "$gstack_dir/review/specialists" "$agy_gstack/review/specialists" + fi + if [ -d "$gstack_dir/qa/templates" ]; then + _link_or_copy "$gstack_dir/qa/templates" "$agy_gstack/qa/templates" + fi + if [ -d "$gstack_dir/qa/references" ]; then + _link_or_copy "$gstack_dir/qa/references" "$agy_gstack/qa/references" + fi + if [ -f "$gstack_dir/plan-devex-review/dx-hall-of-fame.md" ]; then + _link_or_copy "$gstack_dir/plan-devex-review/dx-hall-of-fame.md" "$agy_gstack/plan-devex-review/dx-hall-of-fame.md" + fi + if [ -f "$gstack_dir/ETHOS.md" ]; then + _link_or_copy "$gstack_dir/ETHOS.md" "$agy_gstack/ETHOS.md" + fi +} + +link_agy_skill_dirs() { + local gstack_dir="$1" + local skills_dir="$2" + local agy_dir="$gstack_dir/.gemini/config/plugins/gstack/skills" + local linked=() + + if [ ! -d "$agy_dir" ]; then + echo " Generating .gemini/ skill docs..." + ( cd "$gstack_dir" && bun run gen:skill-docs --host agy ) + fi + + if [ ! -d "$agy_dir" ]; then + echo " warning: .gemini/config/plugins/gstack/skills/ generation failed — run 'bun run gen:skill-docs --host agy' manually" >&2 + return 1 + fi + + for skill_dir in "$agy_dir"/gstack*/; do + if [ -f "$skill_dir/SKILL.md" ]; then + skill_name="$(basename "$skill_dir")" + [ "$skill_name" = "gstack" ] && continue + target="$skills_dir/$skill_name" + if [ -L "$target" ] || [ ! -e "$target" ]; then + _link_or_copy "$skill_dir" "$target" + linked+=("$skill_name") + fi + fi + done + if [ ${#linked[@]} -gt 0 ]; then + echo " linked skills: ${linked[*]}" + fi +} + # 4. Install for Claude (default) SKILLS_BASENAME="$(basename "$INSTALL_SKILLS_DIR")" SKILLS_PARENT_BASENAME="$(basename "$(dirname "$INSTALL_SKILLS_DIR")")" @@ -1193,6 +1292,23 @@ if [ "$INSTALL_OPENCODE" -eq 1 ]; then echo " opencode skills: $OPENCODE_SKILLS" fi +# 6d. Install for Agy +if [ "$INSTALL_AGY" -eq 1 ]; then + AGY_GSTACK_SOURCE="$SOURCE_GSTACK_DIR/.gemini/config/plugins/gstack" + AGY_SKILLS_SOURCE="$AGY_GSTACK_SOURCE/skills" + mkdir -p "$AGY_SKILLS_SOURCE" + create_agy_runtime_root "$SOURCE_GSTACK_DIR" "$AGY_GSTACK_SOURCE" + link_agy_skill_dirs "$SOURCE_GSTACK_DIR" "$AGY_SKILLS_SOURCE" + # Register/import the plugin into Agy + if command -v agy >/dev/null 2>&1; then + echo "Registering gstack plugin with Agy..." + agy plugin install "$AGY_GSTACK_SOURCE" >/dev/null 2>&1 || agy plugin import "$AGY_GSTACK_SOURCE" --force >/dev/null 2>&1 || true + fi + echo "gstack ready (agy)." + echo " browse: $BROWSE_BIN" + echo " agy skills: $AGY_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/benchmark-cli.test.ts b/test/benchmark-cli.test.ts index 8edea3b24..1912f3d8f 100644 --- a/test/benchmark-cli.test.ts +++ b/test/benchmark-cli.test.ts @@ -36,12 +36,13 @@ function run(args: string[], opts: { env?: Record } = {}): { sta describe('gstack-model-benchmark --dry-run', () => { test('prints provider availability report and exits 0', () => { - const r = run(['--prompt', 'hi', '--models', 'claude,gpt,gemini', '--dry-run']); + const r = run(['--prompt', 'hi', '--models', 'claude,gpt,gemini,agy', '--dry-run']); expect(r.status).toBe(0); expect(r.stdout).toContain('gstack-model-benchmark --dry-run'); expect(r.stdout).toContain('claude'); expect(r.stdout).toContain('gpt'); expect(r.stdout).toContain('gemini'); + expect(r.stdout).toContain('agy'); expect(r.stdout).toContain('no prompts sent'); }); @@ -86,12 +87,12 @@ describe('gstack-model-benchmark --dry-run', () => { }); test('each adapter reports either OK or NOT READY, never crashes', () => { - const r = run(['--prompt', 'hi', '--models', 'claude,gpt,gemini', '--dry-run']); + const r = run(['--prompt', 'hi', '--models', 'claude,gpt,gemini,agy', '--dry-run']); expect(r.status).toBe(0); // Each provider line must end in OK or NOT READY const lines = r.stdout.split('\n'); - const adapterLines = lines.filter(l => /^\s+(claude|gpt|gemini):/.test(l)); - expect(adapterLines.length).toBe(3); + const adapterLines = lines.filter(l => /^\s+(claude|gpt|gemini|agy):/.test(l)); + expect(adapterLines.length).toBe(4); for (const line of adapterLines) { expect(line).toMatch(/(OK|NOT READY)/); } @@ -116,7 +117,7 @@ describe('gstack-model-benchmark --dry-run', () => { TERM: process.env.TERM ?? 'xterm', HOME: emptyHome, }; - const result = spawnSync('bun', ['run', BIN, '--prompt', 'hi', '--models', 'claude,gpt,gemini', '--dry-run'], { + const result = spawnSync('bun', ['run', BIN, '--prompt', 'hi', '--models', 'claude,gpt,gemini,agy', '--dry-run'], { cwd: ROOT, env: minimalEnv, encoding: 'utf-8', @@ -124,14 +125,15 @@ describe('gstack-model-benchmark --dry-run', () => { }); expect(result.status).toBe(0); const out = result.stdout?.toString() ?? ''; - // gpt + gemini must report NOT READY in this clean env (their auth check + // gpt + gemini + agy must report NOT READY in this clean env (their auth check // reads paths under the overridden HOME). expect(out).toMatch(/gpt:\s+NOT READY/); expect(out).toMatch(/gemini:\s+NOT READY/); + expect(out).toMatch(/agy:\s+NOT READY/); // Every NOT READY line must include a concrete remediation hint so users // can resolve the missing auth. This is the regression we care about. const notReadyLines = out.split('\n').filter(l => l.includes('NOT READY')); - expect(notReadyLines.length).toBeGreaterThanOrEqual(2); + expect(notReadyLines.length).toBeGreaterThanOrEqual(3); for (const line of notReadyLines) { expect(line).toMatch(/(install|Install|login|export|Run|Log in)/); } diff --git a/test/gbrain-detect-install.test.ts b/test/gbrain-detect-install.test.ts index b9c82c155..376e7b6c2 100644 --- a/test/gbrain-detect-install.test.ts +++ b/test/gbrain-detect-install.test.ts @@ -24,8 +24,10 @@ const INSTALL = path.join(ROOT, 'bin', 'gstack-gbrain-install'); // Minimal PATH with POSIX tools + homebrew (for jq/git/curl) but no user-bin // dirs — this keeps `gbrain` out of PATH deterministically across dev machines // while still finding jq, git, curl, sed, cat, etc. Each test can prepend a -// fake-gbrain dir when it wants to simulate presence. -const SAFE_PATH = '/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin'; +// fake-gbrain dir when it wants to simulate presence. We append the active Bun +// directory so the script shebang can find bun. +const BUN_DIR = path.dirname(process.execPath); +const SAFE_PATH = `/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin:${BUN_DIR}`; let tmpHome: string; let tmpHomeReal: string; diff --git a/test/gbrain-refresh-install-render.test.ts b/test/gbrain-refresh-install-render.test.ts index f1494d47d..95e75bde5 100644 --- a/test/gbrain-refresh-install-render.test.ts +++ b/test/gbrain-refresh-install-render.test.ts @@ -13,7 +13,7 @@ const SRC = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-config'), 'utf-8'); // satisfied by unrelated text elsewhere in the file. function okBranch(): string { const start = SRC.indexOf('gbrain-refresh)'); - const ok = SRC.indexOf('ok)', start); + const ok = SRC.indexOf('ok|timeout)', start); const end = SRC.indexOf(';;', ok); if (start < 0 || ok < 0 || end < 0) throw new Error('Could not locate gbrain-refresh ok) branch'); return SRC.slice(ok, end); diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 2fb783ffd..d4ec1f035 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -2372,16 +2372,17 @@ 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|codex|kiro|opencode|agy', () => { expect(setupContent).toContain('--host'); - expect(setupContent).toContain('claude|codex|kiro|factory|opencode|auto'); + expect(setupContent).toContain('claude|codex|kiro|factory|opencode|agy|auto'); }); - test('auto mode detects claude, codex, kiro, and opencode binaries', () => { + test('auto mode detects claude, codex, kiro, opencode, and agy 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 opencode'); + expect(setupContent).toContain('command -v agy'); }); // T1: Sidecar skip guard — prevents .agents/skills/gstack from being linked as a skill @@ -2423,6 +2424,23 @@ describe('setup script validation', () => { expect(setupContent).toContain('dx-hall-of-fame.md'); }); + test('setup supports --host agy with install section and agy plugin path vars', () => { + expect(setupContent).toContain('INSTALL_AGY='); + expect(setupContent).toContain('AGY_PLUGINS="$HOME/.gemini/config/plugins"'); + expect(setupContent).toContain('AGY_GSTACK="$AGY_PLUGINS/gstack"'); + expect(setupContent).toContain('AGY_SKILLS="$AGY_GSTACK/skills"'); + }); + + test('setup installs agy plugin into a nested gstack runtime root and validates registration', () => { + expect(setupContent).toContain('create_agy_runtime_root'); + expect(setupContent).toContain('.gemini/config/plugins/gstack'); + expect(setupContent).toContain('review/specialists'); + expect(setupContent).toContain('qa/templates'); + expect(setupContent).toContain('qa/references'); + expect(setupContent).toContain('dx-hall-of-fame.md'); + expect(setupContent).toContain('agy plugin install'); + }); + test('create_agents_sidecar links runtime assets', () => { // Sidecar must link bin, browse, review, qa const fnStart = setupContent.indexOf('create_agents_sidecar()'); @@ -3062,7 +3080,7 @@ describe('plan-mode-info resolver (handshake-replacement)', () => { // Non-Claude hosts render to hostSubdirs (.agents/, .openclaw/, etc). The // plan-mode-info resolver has no host-scoping — all hosts get the new // section, none get the old handshake. Scan all candidate host dirs. - const hostDirs = ['.agents', '.openclaw', '.opencode', '.factory', '.hermes', '.kiro', '.cursor', '.slate']; + const hostDirs = ['.agents', '.openclaw', '.opencode', '.factory', '.hermes', '.kiro', '.cursor', '.slate', '.gemini/config/plugins/gstack']; let checked = 0; for (const host of hostDirs) { const skillsRoot = path.join(ROOT, host, 'skills'); diff --git a/test/helpers/benchmark-runner.ts b/test/helpers/benchmark-runner.ts index cbef4107b..01ca00b73 100644 --- a/test/helpers/benchmark-runner.ts +++ b/test/helpers/benchmark-runner.ts @@ -11,15 +11,16 @@ import type { ProviderAdapter, RunOpts, RunResult } from './providers/types'; import { ClaudeAdapter } from './providers/claude'; import { GptAdapter } from './providers/gpt'; import { GeminiAdapter } from './providers/gemini'; +import { AgyAdapter } from './providers/agy'; export interface BenchmarkInput { prompt: string; workdir: string; timeoutMs?: number; - /** Adapter names to run (e.g., ['claude', 'gpt', 'gemini']). */ - providers: Array<'claude' | 'gpt' | 'gemini'>; + /** Adapter names to run (e.g., ['claude', 'gpt', 'gemini', 'agy']). */ + providers: Array<'claude' | 'gpt' | 'gemini' | 'agy'>; /** Optional per-provider model overrides. */ - models?: Partial>; + models?: Partial>; /** If true, skip providers whose available() returns !ok. If false, include them with error. */ skipUnavailable?: boolean; } @@ -44,10 +45,11 @@ export interface BenchmarkReport { entries: BenchmarkEntry[]; } -const ADAPTERS: Record<'claude' | 'gpt' | 'gemini', () => ProviderAdapter> = { +const ADAPTERS: Record<'claude' | 'gpt' | 'gemini' | 'agy', () => ProviderAdapter> = { claude: () => new ClaudeAdapter(), gpt: () => new GptAdapter(), gemini: () => new GeminiAdapter(), + agy: () => new AgyAdapter(), }; export async function runBenchmark(input: BenchmarkInput): Promise { diff --git a/test/helpers/providers/agy.ts b/test/helpers/providers/agy.ts new file mode 100644 index 000000000..3a50efe6d --- /dev/null +++ b/test/helpers/providers/agy.ts @@ -0,0 +1,87 @@ +import type { ProviderAdapter, RunOpts, RunResult, AvailabilityCheck } from './types'; +import { estimateCostUsd } from '../pricing'; +import { execFileSync, spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +/** + * Agy adapter — wraps the `agy` CLI using `agy --print`. + * + * Auth comes from ~/.gemini/oauth_creds.json. + */ +export class AgyAdapter implements ProviderAdapter { + readonly name = 'agy'; + readonly family = 'gemini' as const; + + async available(): Promise { + const res = spawnSync('sh', ['-c', 'command -v agy'], { timeout: 2000 }); + if (res.status !== 0) { + return { ok: false, reason: 'agy CLI not found on PATH. Install per Google Antigravity instructions.' }; + } + const newCfgDir = path.join(os.homedir(), '.gemini'); + const newOauth = path.join(newCfgDir, 'oauth_creds.json'); + const hasCfg = fs.existsSync(newOauth); + if (!hasCfg) { + return { ok: false, reason: 'No Agy auth found. Log in via `agy` or authenticate.' }; + } + return { ok: true }; + } + + async run(opts: RunOpts): Promise { + const start = Date.now(); + const args = ['--print', opts.prompt, '--dangerously-skip-permissions']; + if (opts.model) args.push('--model', opts.model); + if (opts.extraArgs) args.push(...opts.extraArgs); + + try { + const out = execFileSync('agy', args, { + cwd: opts.workdir, + timeout: opts.timeoutMs, + encoding: 'utf-8', + maxBuffer: 32 * 1024 * 1024, + }); + + // Estimate tokens as agy does not output NDJSON/JSON tokens yet in print mode + const inputTokens = Math.ceil(opts.prompt.length / 4); + const outputTokens = Math.ceil(out.length / 4); + + return { + output: out.trim(), + tokens: { input: inputTokens, output: outputTokens }, + durationMs: Date.now() - start, + toolCalls: 0, + modelUsed: opts.model || 'gemini-2.5-flash', + }; + } catch (err: unknown) { + const durationMs = Date.now() - start; + const e = err as { code?: string; stderr?: Buffer; signal?: string; message?: string }; + const stderr = e.stderr?.toString() ?? ''; + if (e.signal === 'SIGTERM' || e.code === 'ETIMEDOUT') { + return this.emptyResult(durationMs, { code: 'timeout', reason: `exceeded ${opts.timeoutMs}ms` }, opts.model); + } + if (/unauthorized|auth|login|api key/i.test(stderr)) { + return this.emptyResult(durationMs, { code: 'auth', reason: stderr.slice(0, 400) }, opts.model); + } + if (/rate[- ]?limit|429|quota/i.test(stderr)) { + return this.emptyResult(durationMs, { code: 'rate_limit', reason: stderr.slice(0, 400) }, opts.model); + } + return this.emptyResult(durationMs, { code: 'unknown', reason: (e.message ?? stderr ?? 'unknown').slice(0, 400) }, opts.model); + } + } + + estimateCost(tokens: { input: number; output: number; cached?: number }, model?: string): number { + return estimateCostUsd(tokens, model ?? 'gemini-2.5-flash'); + } + + private emptyResult(durationMs: number, error: RunResult['error'], model?: string): RunResult { + return { + output: '', + tokens: { input: 0, output: 0 }, + durationMs, + toolCalls: 0, + modelUsed: model ?? 'gemini-2.5-flash', + error, + }; + } +} diff --git a/test/host-config.test.ts b/test/host-config.test.ts index 577057033..ba7fbc41f 100644 --- a/test/host-config.test.ts +++ b/test/host-config.test.ts @@ -22,6 +22,7 @@ import { slate, cursor, openclaw, + agy, } from '../hosts/index'; import { HOST_PATHS } from '../scripts/resolvers/types'; @@ -30,8 +31,8 @@ const ROOT = path.resolve(import.meta.dir, '..'); // ─── 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', () => { @@ -53,6 +54,7 @@ describe('hosts/index.ts', () => { expect(slate.name).toBe('slate'); expect(cursor.name).toBe('cursor'); expect(openclaw.name).toBe('openclaw'); + expect(agy.name).toBe('agy'); }); test('getHostConfig returns correct config', () => { diff --git a/test/skill-e2e-benchmark-providers.test.ts b/test/skill-e2e-benchmark-providers.test.ts index 98c2f3e03..016305a4a 100644 --- a/test/skill-e2e-benchmark-providers.test.ts +++ b/test/skill-e2e-benchmark-providers.test.ts @@ -22,6 +22,7 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { ClaudeAdapter } from './helpers/providers/claude'; import { GptAdapter } from './helpers/providers/gpt'; import { GeminiAdapter } from './helpers/providers/gemini'; +import { AgyAdapter } from './helpers/providers/agy'; import { runBenchmark } from './helpers/benchmark-runner'; import * as fs from 'fs'; import * as path from 'path'; @@ -39,6 +40,7 @@ const PROMPT = 'Reply with exactly this text and nothing else: ok'; const claude = new ClaudeAdapter(); const gpt = new GptAdapter(); const gemini = new GeminiAdapter(); +const agy = new AgyAdapter(); // Use a temp working directory so provider CLIs can't accidentally touch the repo. // Created in beforeAll / cleaned in afterAll so concurrent CI runs don't leak. @@ -80,6 +82,14 @@ describeIfEvals('multi-provider benchmark adapters (live)', () => { } }); + test('agy: available() returns structured ok/reason', async () => { + const check = await agy.available(); + expect(check).toHaveProperty('ok'); + if (!check.ok) { + expect(typeof check.reason).toBe('string'); + } + }); + test('claude: trivial prompt produces parseable output', async () => { const check = await claude.available(); if (!check.ok) { @@ -140,11 +150,31 @@ describeIfEvals('multi-provider benchmark adapters (live)', () => { expect(result.modelUsed.length).toBeGreaterThan(0); }, 150_000); + test('agy: trivial prompt produces parseable output', async () => { + const check = await agy.available(); + if (!check.ok) { + process.stderr.write(`\nagy live smoke: SKIPPED — ${check.reason}\n`); + return; + } + const result = await agy.run({ prompt: PROMPT, workdir, timeoutMs: 120_000 }); + if (result.error) { + throw new Error(`agy errored: ${result.error.code} — ${result.error.reason}`); + } + expect(typeof result.output).toBe('string'); + expect(result.tokens.input).toBeGreaterThan(0); + expect(result.tokens.output).toBeGreaterThan(0); + expect(result.durationMs).toBeGreaterThan(0); + expect(typeof result.modelUsed).toBe('string'); + const cost = agy.estimateCost(result.tokens, result.modelUsed); + expect(cost).toBeGreaterThan(0); + }, 150_000); + test('timeout error surfaces as error.code=timeout (no exception)', async () => { - // Use whatever adapter is available first — all three should share timeout semantics. + // Use whatever adapter is available first — all should share timeout semantics. const adapter = (await claude.available()).ok ? claude : (await gpt.available()).ok ? gpt : (await gemini.available()).ok ? gemini + : (await agy.available()).ok ? agy : null; if (!adapter) { process.stderr.write('\ntimeout smoke: SKIPPED — no provider available\n'); @@ -160,16 +190,16 @@ describeIfEvals('multi-provider benchmark adapters (live)', () => { }, 30_000); test('runBenchmark: Promise.allSettled means one unavailable provider does not block others', async () => { - // Use the full runner with all three providers — whichever are unauthed should + // Use the full runner with all providers — whichever are unauthed should // return entries with available=false and not crash the batch. const report = await runBenchmark({ prompt: PROMPT, workdir, - providers: ['claude', 'gpt', 'gemini'], + providers: ['claude', 'gpt', 'gemini', 'agy'], timeoutMs: 120_000, skipUnavailable: false, }); - expect(report.entries).toHaveLength(3); + expect(report.entries).toHaveLength(4); for (const e of report.entries) { expect(['claude', 'gpt', 'gemini']).toContain(e.family); if (e.available) {