This commit is contained in:
Mattias Petersson 2026-07-14 18:29:42 -07:00 committed by GitHub
commit 57df4277b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 457 additions and 39 deletions

1
.gitignore vendored
View File

@ -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.

View File

@ -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.**

View File

@ -1 +1 @@
1.60.1.0
1.61.0.0

View File

@ -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

View File

@ -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<void> {
async function dryRunReport(opts: {
prompt: string;
providers: Array<'claude' | 'gpt' | 'gemini'>;
providers: Array<'claude' | 'gpt' | 'gemini' | 'agy'>;
workdir: string;
timeoutMs: number;
output: OutputFormat;

View File

@ -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
```

71
docs/AGY.md Normal file
View File

@ -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.

59
hosts/agy.ts Normal file
View File

@ -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;

View File

@ -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<string, HostConfig> = 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 };

View File

@ -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",

124
setup
View File

@ -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.

View File

@ -36,12 +36,13 @@ function run(args: string[], opts: { env?: Record<string, string> } = {}): { 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)/);
}

View File

@ -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;

View File

@ -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);

View File

@ -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');

View File

@ -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<Record<'claude' | 'gpt' | 'gemini', string>>;
models?: Partial<Record<'claude' | 'gpt' | 'gemini' | 'agy', string>>;
/** 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<BenchmarkReport> {

View File

@ -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<AvailabilityCheck> {
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<RunResult> {
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,
};
}
}

View File

@ -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', () => {

View File

@ -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) {