From 3dd36fb08bc9f4b99c1757648feed898dd859adf Mon Sep 17 00:00:00 2001 From: Charles Green Date: Thu, 17 Sep 2026 12:43:04 +0900 Subject: [PATCH 1/3] Add a Gemini engine that talks to Vertex AI engine: gemini shells out to the Gemini CLI instead of Codex, so an adopter can leave Azure. The reusable workflow takes optional Vertex project, location, and API key, and only requires Azure for Codex. --- .github/workflows/aixgo.yml | 41 ++++++- CHANGELOG.md | 12 ++ cmd/aixgo-code/main.go | 40 ++++++- cmd/aixgo-code/main_test.go | 77 ++++++++++++- docs/setup.md | 28 ++++- internal/config/config.go | 7 +- internal/config/config_test.go | 2 +- internal/engine/gemini/gemini.go | 148 ++++++++++++++++++++++++ internal/engine/gemini/gemini_test.go | 155 ++++++++++++++++++++++++++ 9 files changed, 489 insertions(+), 21 deletions(-) create mode 100644 internal/engine/gemini/gemini.go create mode 100644 internal/engine/gemini/gemini_test.go diff --git a/.github/workflows/aixgo.yml b/.github/workflows/aixgo.yml index 27d2037..75cd236 100644 --- a/.github/workflows/aixgo.yml +++ b/.github/workflows/aixgo.yml @@ -38,9 +38,20 @@ on: required: true type: string azure-openai-endpoint: - description: Azure OpenAI endpoint, for example https://resource.openai.azure.com. - required: true + description: Azure OpenAI endpoint, for example https://resource.openai.azure.com. Required for the default Codex engine; unused when engine is claude or gemini. + required: false type: string + default: "" + vertex-project: + description: Google Cloud project for Vertex AI. Required when engine is gemini. + required: false + type: string + default: "" + vertex-location: + description: Vertex AI location, for example us-central1. + required: false + type: string + default: us-central1 model: description: Engine model or deployment name passed to the Aixgo CLI. default: gpt-5.4 @@ -53,7 +64,9 @@ on: type: string secrets: azure-openai-api-key: - required: true + required: false + vertex-api-key: + required: false github-app-private-key: required: true @@ -104,6 +117,9 @@ jobs: env: AIXGO_AZURE_OPENAI_ENDPOINT: ${{ inputs['azure-openai-endpoint'] }} AIXGO_AZURE_OPENAI_API_KEY: ${{ secrets['azure-openai-api-key'] }} + AIXGO_VERTEX_PROJECT: ${{ inputs['vertex-project'] }} + AIXGO_VERTEX_LOCATION: ${{ inputs['vertex-location'] }} + AIXGO_VERTEX_API_KEY: ${{ secrets['vertex-api-key'] }} AIXGO_GH_APP_CLIENT_ID: ${{ inputs['github-app-client-id'] }} AIXGO_GH_APP_PRIVATE_KEY: ${{ secrets['github-app-private-key'] }} run: aixgo-code preflight --repo-dir . --actions @@ -128,13 +144,21 @@ jobs: run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - name: Install Codex CLI + if: ${{ inputs['azure-openai-endpoint'] != '' }} run: npm install -g @openai/codex@0.146.0 + - name: Install Gemini CLI + if: ${{ inputs['vertex-project'] != '' }} + run: npm install -g @google/gemini-cli@0.60.0 + - name: Run Aixgo env: GH_TOKEN: ${{ steps.app-token.outputs.token }} AIXGO_AZURE_OPENAI_ENDPOINT: ${{ inputs['azure-openai-endpoint'] }} AIXGO_AZURE_OPENAI_API_KEY: ${{ secrets['azure-openai-api-key'] }} + AIXGO_VERTEX_PROJECT: ${{ inputs['vertex-project'] }} + AIXGO_VERTEX_LOCATION: ${{ inputs['vertex-location'] }} + AIXGO_VERTEX_API_KEY: ${{ secrets['vertex-api-key'] }} COMMENT_BODY: ${{ inputs['comment-body'] }} # --base is explicit because actions/checkout never creates origin/HEAD, # which is the CLI's default base. The CLI resolves that case on its own @@ -198,6 +222,9 @@ jobs: env: AIXGO_AZURE_OPENAI_ENDPOINT: ${{ inputs['azure-openai-endpoint'] }} AIXGO_AZURE_OPENAI_API_KEY: ${{ secrets['azure-openai-api-key'] }} + AIXGO_VERTEX_PROJECT: ${{ inputs['vertex-project'] }} + AIXGO_VERTEX_LOCATION: ${{ inputs['vertex-location'] }} + AIXGO_VERTEX_API_KEY: ${{ secrets['vertex-api-key'] }} AIXGO_GH_APP_CLIENT_ID: ${{ inputs['github-app-client-id'] }} AIXGO_GH_APP_PRIVATE_KEY: ${{ secrets['github-app-private-key'] }} run: aixgo-code preflight --repo-dir . --actions @@ -222,13 +249,21 @@ jobs: run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - name: Install Codex CLI + if: ${{ inputs['azure-openai-endpoint'] != '' }} run: npm install -g @openai/codex@0.146.0 + - name: Install Gemini CLI + if: ${{ inputs['vertex-project'] != '' }} + run: npm install -g @google/gemini-cli@0.60.0 + - name: Address review feedback env: GH_TOKEN: ${{ steps.app-token.outputs.token }} AIXGO_AZURE_OPENAI_ENDPOINT: ${{ inputs['azure-openai-endpoint'] }} AIXGO_AZURE_OPENAI_API_KEY: ${{ secrets['azure-openai-api-key'] }} + AIXGO_VERTEX_PROJECT: ${{ inputs['vertex-project'] }} + AIXGO_VERTEX_LOCATION: ${{ inputs['vertex-location'] }} + AIXGO_VERTEX_API_KEY: ${{ secrets['vertex-api-key'] }} COMMENT_BODY: ${{ inputs['comment-body'] }} run: | if [ -n "${COMMENT_BODY}" ]; then diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c1d4be..5ea03ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes are recorded here and summarized again in the matching GitHub release notes for each tag. +## Unreleased + +- **Gemini on Vertex AI** (`engine: gemini`). The loop, roles, and gate are + unchanged. The adapter shells out to the Gemini CLI (`gemini -p`) and reads + `AIXGO_VERTEX_PROJECT`, `AIXGO_VERTEX_LOCATION` (default `us-central1`), and + `AIXGO_VERTEX_API_KEY`. Azure credentials are not required on this path. +- The reusable workflow accepts optional `vertex-project`, `vertex-location`, + and `vertex-api-key`, and the Azure endpoint/key are no longer required + inputs. A Codex adopter who already passes them is unchanged. A Gemini + adopter omits Azure and passes the Vertex values instead. The workflow + installs `@google/gemini-cli@0.60.0` when `vertex-project` is set. + ## v0.5.0 **Breaking. The binary is `aixgo-code`, not `aixgo`.** The Aixgo framework's diff --git a/cmd/aixgo-code/main.go b/cmd/aixgo-code/main.go index bec062c..7ee611e 100644 --- a/cmd/aixgo-code/main.go +++ b/cmd/aixgo-code/main.go @@ -30,6 +30,7 @@ import ( "github.com/aixgo-dev/code/internal/engine" "github.com/aixgo-dev/code/internal/engine/claude" "github.com/aixgo-dev/code/internal/engine/codex" + "github.com/aixgo-dev/code/internal/engine/gemini" forge2 "github.com/aixgo-dev/code/internal/forge" "github.com/aixgo-dev/code/internal/forge/dryrun" forgegh "github.com/aixgo-dev/code/internal/forge/gh" @@ -115,8 +116,11 @@ flags (both commands): --state-dir where worktrees and the generated config live environment (engine settings; never committed to a repo): - AIXGO_AZURE_OPENAI_ENDPOINT e.g. https://.openai.azure.com + AIXGO_AZURE_OPENAI_ENDPOINT e.g. https://.openai.azure.com (codex) AIXGO_AZURE_OPENAI_API_KEY the key, read by name and never written to disk + AIXGO_VERTEX_PROJECT Google Cloud project (gemini / Vertex) + AIXGO_VERTEX_LOCATION Vertex location, default us-central1 + AIXGO_VERTEX_API_KEY Vertex API key, read by name and never written to disk `, buildinfo.Version) } @@ -165,10 +169,30 @@ var selftestWorkflowTemplate string // Azure endpoint when the engine needs one. It is the single implementation: // `prepare` calls it before a run, and `preflight` calls it so a workflow can // fail early without a second copy of these rules written in shell. +func usesAzure(cfg *config.Config) bool { + if cfg == nil { + return true + } + switch cfg.Engine { + case "claude", "gemini": + return false + } + return true +} + func engineEnv(cfg *config.Config) (string, error) { if cfg != nil && cfg.Engine == "claude" { return "", nil } + if cfg != nil && cfg.Engine == "gemini" { + if err := requireSet("AIXGO_VERTEX_PROJECT", sectionVariable); err != nil { + return "", err + } + if err := requireSet("AIXGO_VERTEX_API_KEY", sectionSecret); err != nil { + return "", err + } + return "", nil + } if err := requireSet("AIXGO_AZURE_OPENAI_ENDPOINT", sectionVariable); err != nil { return "", err } @@ -246,9 +270,15 @@ func newVCS(self string) *vcsgit.Git { // the sandbox start rather than widening it. A run that still cannot sandbox // stops and a human finishes the work; it never falls back to running // unconfined. See docs/faq.md and the self-test. -func newRunner(cfg *config.Config, codexHome string) engine.Runner { +func newRunner(cfg *config.Config, codexHome, model string) engine.Runner { if cfg.Engine == "claude" { - return claude.New() + return &claude.Runner{Model: model} + } + if cfg.Engine == "gemini" { + if model == "" || model == "gpt-5.4" { + model = "gemini-2.5-pro" + } + return &gemini.Runner{Model: model} } r := codex.New(codexHome) if mode := os.Getenv("AIXGO_SANDBOX"); mode != "" { @@ -602,7 +632,7 @@ func prepare(name string, argv []string) (*commonFlags, []string, error) { } codexHome := filepath.Join(*stateDir, "codex-home") - if cfg.Engine != "claude" { + if usesAzure(cfg) { if _, err := codex.WriteConfig(codexHome, codex.ProviderConfig{ Model: *model, BaseURL: endpoint + "/openai/v1", @@ -638,7 +668,7 @@ func prepare(name string, argv []string) (*commonFlags, []string, error) { } deps := app.Deps{ - Runner: newRunner(cfg, codexHome), + Runner: newRunner(cfg, codexHome, *model), // Self, when set, filters the agent's own review feedback out of the fix // loop. Empty for local runs, where the operator is a human, not the bot. Forge: forgeForLoop, diff --git a/cmd/aixgo-code/main_test.go b/cmd/aixgo-code/main_test.go index 080b82c..b46776d 100644 --- a/cmd/aixgo-code/main_test.go +++ b/cmd/aixgo-code/main_test.go @@ -9,6 +9,7 @@ import ( "github.com/aixgo-dev/code/internal/domain" "github.com/aixgo-dev/code/internal/engine/claude" "github.com/aixgo-dev/code/internal/engine/codex" + "github.com/aixgo-dev/code/internal/engine/gemini" forge2 "github.com/aixgo-dev/code/internal/forge" "github.com/aixgo-dev/code/internal/forge/dryrun" forgefake "github.com/aixgo-dev/code/internal/forge/fake" @@ -462,6 +463,25 @@ func TestEngineEnvSkipsAzureForClaude(t *testing.T) { } } +func TestEngineEnvRequiresVertexForGemini(t *testing.T) { + t.Setenv("AIXGO_AZURE_OPENAI_ENDPOINT", "") + t.Setenv("AIXGO_AZURE_OPENAI_API_KEY", "") + t.Setenv("AIXGO_VERTEX_PROJECT", "") + t.Setenv("AIXGO_VERTEX_API_KEY", "") + if _, err := engineEnv(&config.Config{Engine: "gemini"}); err == nil || !strings.Contains(err.Error(), "AIXGO_VERTEX_PROJECT") { + t.Fatal("gemini without a project must name AIXGO_VERTEX_PROJECT") + } + t.Setenv("AIXGO_VERTEX_PROJECT", "simplycubed-agents") + if _, err := engineEnv(&config.Config{Engine: "gemini"}); err == nil || !strings.Contains(err.Error(), "AIXGO_VERTEX_API_KEY") { + t.Fatal("gemini without a key must name AIXGO_VERTEX_API_KEY") + } + t.Setenv("AIXGO_VERTEX_API_KEY", "k") + got, err := engineEnv(&config.Config{Engine: "gemini"}) + if err != nil || got != "" { + t.Fatalf("engineEnv(gemini) = %q, %v", got, err) + } +} + func TestPreflightCmd(t *testing.T) { t.Run("reports ok when config and engine settings are present", func(t *testing.T) { t.Setenv("AIXGO_AZURE_OPENAI_ENDPOINT", "https://r.openai.azure.com") @@ -487,6 +507,20 @@ func TestPreflightCmd(t *testing.T) { } }) + t.Run("allows gemini with Vertex settings and no Azure", func(t *testing.T) { + t.Setenv("AIXGO_AZURE_OPENAI_ENDPOINT", "") + t.Setenv("AIXGO_AZURE_OPENAI_API_KEY", "") + t.Setenv("AIXGO_VERTEX_PROJECT", "simplycubed-agents") + t.Setenv("AIXGO_VERTEX_API_KEY", "k") + var out bytes.Buffer + if err := preflightCmd([]string{"--repo-dir", repoWithConfigBody(t, "gate: make check\nengine: gemini\nappName: acme-code\n")}, &out); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out.String(), "preflight ok") { + t.Fatalf("output = %q", out.String()) + } + }) + // The whole point of preflight is naming what is wrong, so each failure // asserts the message identifies the thing the operator has to fix. t.Run("names the missing config", func(t *testing.T) { @@ -673,6 +707,24 @@ func TestPrepare(t *testing.T) { } }) + t.Run("allows gemini with Vertex settings and skips codex config", func(t *testing.T) { + t.Setenv("AIXGO_AZURE_OPENAI_ENDPOINT", "") + t.Setenv("AIXGO_AZURE_OPENAI_API_KEY", "") + t.Setenv("AIXGO_VERTEX_PROJECT", "simplycubed-agents") + t.Setenv("AIXGO_VERTEX_API_KEY", "k") + stateDir := t.TempDir() + c, _, err := prepare("run", []string{"--repo-dir", repoWithConfigBody(t, "gate: make check\nengine: gemini\nappName: acme-code\n"), "--state-dir", stateDir}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := c.deps.Runner.(*gemini.Runner); !ok { + t.Fatalf("expected the Gemini runner, got %#v", c.deps.Runner) + } + if _, err := os.Stat(filepath.Join(stateDir, "codex-home", "config.toml")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("gemini runs must not render a codex config, stat err = %v", err) + } + }) + t.Run("rejects an unknown flag", func(t *testing.T) { if _, _, err := prepare("run", []string{"--nope"}); err == nil { t.Fatal("expected an error for an unknown flag") @@ -1017,15 +1069,30 @@ func TestCommandCmdRejectsABodyFlagWithNoValue(t *testing.T) { // Engine selection is a documented config key and had no test. The default // matters most: an existing repository must keep the engine it already had. func TestNewRunnerSelectsTheEngine(t *testing.T) { - if _, ok := newRunner(&config.Config{}, t.TempDir()).(*codex.Runner); !ok { + if _, ok := newRunner(&config.Config{}, t.TempDir(), "").(*codex.Runner); !ok { t.Fatal("the default engine must be codex") } - if _, ok := newRunner(&config.Config{Engine: "claude"}, t.TempDir()).(*claude.Runner); !ok { + if _, ok := newRunner(&config.Config{Engine: "claude"}, t.TempDir(), "").(*claude.Runner); !ok { t.Fatal("engine: claude must select the Claude adapter") } + g, ok := newRunner(&config.Config{Engine: "gemini"}, t.TempDir(), "").(*gemini.Runner) + if !ok { + t.Fatal("engine: gemini must select the Gemini adapter") + } + if g.Model != "gemini-2.5-pro" { + t.Fatalf("gemini default model = %q, want gemini-2.5-pro", g.Model) + } + g2, _ := newRunner(&config.Config{Engine: "gemini"}, t.TempDir(), "gpt-5.4").(*gemini.Runner) + if g2.Model != "gemini-2.5-pro" { + t.Fatalf("the Codex default must not be forwarded to Gemini: %q", g2.Model) + } + g3, _ := newRunner(&config.Config{Engine: "gemini"}, t.TempDir(), "gemini-2.5-flash").(*gemini.Runner) + if g3.Model != "gemini-2.5-flash" { + t.Fatalf("an explicit gemini model must be kept: %q", g3.Model) + } // An unrecognised engine falls back rather than failing later with an // error that says nothing about the cause. - if _, ok := newRunner(&config.Config{Engine: "nope"}, t.TempDir()).(*codex.Runner); !ok { + if _, ok := newRunner(&config.Config{Engine: "nope"}, t.TempDir(), "").(*codex.Runner); !ok { t.Fatal("an unknown engine must fall back to the default") } } @@ -1034,7 +1101,7 @@ func TestNewRunnerSelectsTheEngine(t *testing.T) { // widen it themselves. Nothing in this repository sets it, so the test asserts // both that the default is untouched and that an explicit value is honoured. func TestNewRunnerHonoursTheSandboxOverride(t *testing.T) { - r, ok := newRunner(&config.Config{}, t.TempDir()).(*codex.Runner) + r, ok := newRunner(&config.Config{}, t.TempDir(), "").(*codex.Runner) if !ok { t.Fatal("expected the codex runner") } @@ -1042,7 +1109,7 @@ func TestNewRunnerHonoursTheSandboxOverride(t *testing.T) { t.Fatalf("the sandbox must stay on by default, got %q", r.Sandbox) } t.Setenv("AIXGO_SANDBOX", "read-only") - r2, _ := newRunner(&config.Config{}, t.TempDir()).(*codex.Runner) + r2, _ := newRunner(&config.Config{}, t.TempDir(), "").(*codex.Runner) if r2.Sandbox != "read-only" { t.Fatalf("an explicit sandbox mode must be honoured, got %q", r2.Sandbox) } diff --git a/docs/setup.md b/docs/setup.md index f82828f..7ab25a1 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -18,7 +18,8 @@ You need: - Go installed locally so you can run `go install` - `gh` authenticated against the repository you want to onboard - write access on that repository -- an Azure OpenAI endpoint and API key +- model credentials: an Azure OpenAI endpoint and API key (Codex, the default), + or a Vertex AI project and API key (`engine: gemini`) ## Install and configure @@ -276,6 +277,22 @@ engine: claude aixgo-code run owner/repo#123 --repo-dir . ``` +To use Gemini on Vertex AI instead, set `engine: gemini` and the Vertex +variables. The reusable workflow installs the Gemini CLI when +`vertex-project` is passed, and does not need Azure: + +```yaml +gate: make check +engine: gemini +``` + +```sh +export AIXGO_VERTEX_PROJECT="" +export AIXGO_VERTEX_LOCATION="us-central1" # optional, this is the default +export AIXGO_VERTEX_API_KEY="" +aixgo-code run owner/repo#123 --repo-dir . +``` + ### GitHub Actions For the hosted-in-your-GitHub path, the caller workflow in your repository @@ -293,9 +310,12 @@ passes: The reusable workflow installs the CLI, exports the endpoint and key for the job, and runs `aixgo-code run` or `aixgo-code address`. -That hosted path is still Codex-on-Azure only. The reusable workflow installs -the Codex CLI, not the Claude CLI, and its inputs and secrets still require the -Azure endpoint and API key. +The hosted path follows `.github/aixgo.yml` `engine:`. Codex (the default) +still needs the Azure endpoint and key, and the workflow installs the Codex +CLI. `engine: gemini` needs `vertex-project`, `vertex-location`, and +`vertex-api-key` instead, and the workflow installs the Gemini CLI. Claude +is still a local-CLI path; the reusable workflow does not install the Claude +CLI. ## Watching it run before it writes diff --git a/internal/config/config.go b/internal/config/config.go index 5a4667b..9b337d8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,8 +36,9 @@ type Config struct { // default) is the plain one-line body; "rich" adds a generated walkthrough, // changes table, and sequence diagram (issue #16). Opt-in until validated. PRDescription string - // Engine selects the adapter that writes the code: "codex" (default) or - // "claude". The loop, the roles, and the gate are identical either way. + // Engine selects the adapter that writes the code: "codex" (default), + // "claude", or "gemini". The loop, the roles, and the gate are identical + // either way. gemini talks to Vertex AI. Engine string // AppName is the login of the GitHub App this repository installed, without // the "[bot]" suffix. It is what a comment command addresses, so it differs @@ -93,7 +94,7 @@ func Parse(b []byte) (*Config, error) { c.AppName = strings.TrimSuffix(c.AppName, "[bot]") case "engine": switch strings.ToLower(val) { - case "codex", "claude": + case "codex", "claude", "gemini": c.Engine = strings.ToLower(val) } case "review": diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 727fe82..7a27f86 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -85,7 +85,7 @@ func TestParseEngineSelection(t *testing.T) { if err != nil || c.Engine != "" { t.Fatalf("default Engine = %q err = %v", c.Engine, err) } - for in, want := range map[string]string{"claude": "claude", "CODEX": "codex"} { + for in, want := range map[string]string{"claude": "claude", "CODEX": "codex", "gemini": "gemini"} { c, err := Parse([]byte("gate: make check\nengine: " + in + "\n")) if err != nil || c.Engine != want { t.Fatalf("engine %q -> %q err = %v", in, c.Engine, err) diff --git a/internal/engine/gemini/gemini.go b/internal/engine/gemini/gemini.go new file mode 100644 index 0000000..bc5f1c4 --- /dev/null +++ b/internal/engine/gemini/gemini.go @@ -0,0 +1,148 @@ +// Package gemini implements engine.Runner by shelling out to the Gemini CLI +// (`gemini -p`) pointed at Vertex AI. +// +// It follows the same two lessons as the other adapters (see docs/decisions/0003): +// +// - The environment is pre-configured so the agent fixes the bug rather than +// the environment. Workspace-local Go caches are set here for the same +// reason they are set there. +// - This adapter runs the turn and nothing else. It does not police what the +// agent changed; that is the gate's job and the PR guard's job. +package gemini + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/aixgo-dev/code/internal/domain" +) + +const defaultLocation = "us-central1" + +// Runner runs one role turn via `gemini -p`. +type Runner struct { + // Bin is the gemini binary. Defaults to "gemini" (resolved on PATH). + Bin string + // Model is the model to request. Empty uses the CLI's own default. + Model string + // ExtraEnv is appended to the child environment, for pre-configuring the + // workspace. See the package doc. + ExtraEnv []string +} + +// New returns a Runner with the defaults applied. +func New() *Runner { return &Runner{} } + +func (r *Runner) bin() string { + if r.Bin != "" { + return r.Bin + } + return "gemini" +} + +// Run executes one turn. A non-nil error means the CLI itself failed; it does +// not mean the gate failed, which is a separate signal the loop grades. +func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (domain.RunResult, error) { + if req.WorkDir == "" { + err := errors.New("gemini: WorkDir is required") + return domain.RunResult{Role: req.Role, Err: err}, err + } + + args := []string{"-p", req.Prompt, "--output-format", "text", "-y"} + model := req.Model + if model == "" { + model = r.Model + } + if model != "" { + args = append(args, "-m", model) + } + + cmd := exec.CommandContext(ctx, r.bin(), args...) + cmd.Dir = req.WorkDir + cmd.Env = r.childEnv(req.WorkDir) + out, runErr := cmd.CombinedOutput() + + res := domain.RunResult{Role: req.Role, Summary: tail(strings.TrimSpace(string(out)), 40)} + if runErr != nil { + res.Err = fmt.Errorf("gemini -p: %w", runErr) + return res, res.Err + } + return res, nil +} + +// childEnv builds the child environment. It mirrors the other adapters for the +// Go caches, and translates the AIXGO_VERTEX_* names into the names the Gemini +// CLI reads for Vertex. +func (r *Runner) childEnv(workDir string) []string { + env := filterEnvKeys(os.Environ(), + "GOCACHE", "GOPATH", "GH_TOKEN", "GITHUB_TOKEN", + "GOOGLE_GENAI_USE_VERTEXAI", "GOOGLE_CLOUD_PROJECT", "GOOGLE_CLOUD_LOCATION", "GOOGLE_API_KEY", + ) + env = append(env, r.ExtraEnv...) + if !hasEnvKey(r.ExtraEnv, "GOCACHE") { + gc := filepath.Join(workDir, ".gocache") + _ = os.MkdirAll(gc, 0o755) + env = append(env, "GOCACHE="+gc) + } + if !hasEnvKey(r.ExtraEnv, "GOPATH") { + gp := filepath.Join(workDir, ".gopath") + _ = os.MkdirAll(gp, 0o755) + env = append(env, "GOPATH="+gp) + } + env = append(env, "GOOGLE_GENAI_USE_VERTEXAI=true") + if p := strings.TrimSpace(os.Getenv("AIXGO_VERTEX_PROJECT")); p != "" { + env = append(env, "GOOGLE_CLOUD_PROJECT="+p) + } + loc := strings.TrimSpace(os.Getenv("AIXGO_VERTEX_LOCATION")) + if loc == "" { + loc = defaultLocation + } + env = append(env, "GOOGLE_CLOUD_LOCATION="+loc) + if k := strings.TrimSpace(os.Getenv("AIXGO_VERTEX_API_KEY")); k != "" { + env = append(env, "GOOGLE_API_KEY="+k) + } + return env +} + +func filterEnvKeys(env []string, keys ...string) []string { + out := make([]string, 0, len(env)) + for _, e := range env { + drop := false + for _, k := range keys { + if strings.HasPrefix(e, k+"=") { + drop = true + break + } + } + if !drop { + out = append(out, e) + } + } + return out +} + +func hasEnvKey(env []string, key string) bool { + for _, e := range env { + if strings.HasPrefix(e, key+"=") { + return true + } + } + return false +} + +func tail(s string, n int) string { + s = strings.TrimRight(s, "\n") + if s == "" { + return "" + } + lines := strings.Split(s, "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, "\n") +} diff --git a/internal/engine/gemini/gemini_test.go b/internal/engine/gemini/gemini_test.go new file mode 100644 index 0000000..29995aa --- /dev/null +++ b/internal/engine/gemini/gemini_test.go @@ -0,0 +1,155 @@ +package gemini + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/aixgo-dev/code/internal/domain" +) + +func writeFakeGemini(t *testing.T, script string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("stub is a POSIX shell script") + } + dir := t.TempDir() + bin := filepath.Join(dir, "gemini") + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return bin +} + +const echoArgs = `#!/bin/sh +echo "$@" +exit 0 +` + +func TestRunPassesThePromptAndModel(t *testing.T) { + r := New() + r.Bin = writeFakeGemini(t, echoArgs) + res, err := r.Run(context.Background(), domain.RunRequest{ + Role: domain.RoleImplementer, WorkDir: t.TempDir(), + Prompt: "do the thing", Model: "gemini-2.5-pro", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, want := range []string{"-p", "do the thing", "-m", "gemini-2.5-pro"} { + if !strings.Contains(res.Summary, want) { + t.Fatalf("args missing %q: %s", want, res.Summary) + } + } +} + +// aixgo-code always runs unattended, so a permission prompt would hang the +// job. -y is the Gemini CLI equivalent of Codex's workspace-write sandbox: +// the human gate is the pull request, not each tool call. +func TestRunAutoApprovesActions(t *testing.T) { + r := New() + r.Bin = writeFakeGemini(t, echoArgs) + res, err := r.Run(context.Background(), domain.RunRequest{ + Role: domain.RoleImplementer, WorkDir: t.TempDir(), Prompt: "x", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(res.Summary, "-y") { + t.Fatalf("unattended runs must pass -y: %s", res.Summary) + } +} + +const echoGoEnv = `#!/bin/sh +printf '%s\n%s\n' "$GOCACHE" "$GOPATH" +exit 0 +` + +func TestRunSetsWorkspaceLocalGoCaches(t *testing.T) { + work := t.TempDir() + r := New() + r.Bin = writeFakeGemini(t, echoGoEnv) + res, err := r.Run(context.Background(), domain.RunRequest{ + Role: domain.RoleImplementer, WorkDir: work, Prompt: "x", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + lines := strings.Split(res.Summary, "\n") + if len(lines) != 2 { + t.Fatalf("summary = %q, want GOCACHE and GOPATH", res.Summary) + } + if !strings.HasPrefix(lines[0], work) || !strings.HasSuffix(lines[0], ".gocache") { + t.Fatalf("GOCACHE = %q, want a .gocache under %q", lines[0], work) + } + if !strings.HasPrefix(lines[1], work) || !strings.HasSuffix(lines[1], ".gopath") { + t.Fatalf("GOPATH = %q, want a .gopath under %q", lines[1], work) + } +} + +const echoVertexEnv = `#!/bin/sh +printf '%s\n%s\n%s\n%s\n' "$GOOGLE_GENAI_USE_VERTEXAI" "$GOOGLE_CLOUD_PROJECT" "$GOOGLE_CLOUD_LOCATION" "$GOOGLE_API_KEY" +exit 0 +` + +func TestRunExportsVertexSettingsForTheGeminiCLI(t *testing.T) { + t.Setenv("AIXGO_VERTEX_PROJECT", "simplycubed-agents") + t.Setenv("AIXGO_VERTEX_LOCATION", "us-central1") + t.Setenv("AIXGO_VERTEX_API_KEY", "vertex-key") + r := New() + r.Bin = writeFakeGemini(t, echoVertexEnv) + res, err := r.Run(context.Background(), domain.RunRequest{ + Role: domain.RoleImplementer, WorkDir: t.TempDir(), Prompt: "x", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := strings.Split(res.Summary, "\n") + if len(got) != 4 { + t.Fatalf("summary = %q", res.Summary) + } + if got[0] != "true" || got[1] != "simplycubed-agents" || got[2] != "us-central1" || got[3] != "vertex-key" { + t.Fatalf("vertex env = %#v", got) + } +} + +func TestRunDefaultsLocationWhenUnset(t *testing.T) { + t.Setenv("AIXGO_VERTEX_PROJECT", "p") + t.Setenv("AIXGO_VERTEX_LOCATION", "") + t.Setenv("AIXGO_VERTEX_API_KEY", "k") + r := New() + r.Bin = writeFakeGemini(t, echoVertexEnv) + res, err := r.Run(context.Background(), domain.RunRequest{ + Role: domain.RoleImplementer, WorkDir: t.TempDir(), Prompt: "x", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := strings.Split(res.Summary, "\n") + if len(got) != 4 || got[2] != "us-central1" { + t.Fatalf("default location missing: %q", res.Summary) + } +} + +func TestRunReportsAFailureAsAnEngineError(t *testing.T) { + r := New() + r.Bin = writeFakeGemini(t, "#!/bin/sh\necho boom >&2\nexit 3\n") + res, err := r.Run(context.Background(), domain.RunRequest{ + Role: domain.RoleImplementer, WorkDir: t.TempDir(), Prompt: "x", + }) + if err == nil || res.Err == nil { + t.Fatal("a non-zero exit must surface as an engine error") + } + if !strings.Contains(res.Summary, "boom") { + t.Fatalf("the CLI's own output should be kept: %q", res.Summary) + } +} + +func TestRunRequiresAWorkDir(t *testing.T) { + if _, err := New().Run(context.Background(), domain.RunRequest{Role: domain.RoleImplementer}); err == nil { + t.Fatal("expected an error with no WorkDir") + } +} From c36f2ce3c52264482bb7faf0bf971bdcdd4d0fab Mon Sep 17 00:00:00 2001 From: Charles Green Date: Thu, 17 Sep 2026 15:45:07 +0900 Subject: [PATCH 2/3] Treat a Vertex service-account JSON as ADC AIXGO_VERTEX_API_KEY may be a plain API key or the contents of a service-account JSON. JSON is written to a temp file and exported as GOOGLE_APPLICATION_CREDENTIALS so Gemini CLI authenticates with IAM instead of an API token. --- CHANGELOG.md | 5 ++- docs/setup.md | 4 +- internal/engine/gemini/gemini.go | 55 +++++++++++++++++++++++++-- internal/engine/gemini/gemini_test.go | 38 ++++++++++++++++++ 4 files changed, 96 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ea03ec..7362264 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,10 @@ GitHub release notes for each tag. - **Gemini on Vertex AI** (`engine: gemini`). The loop, roles, and gate are unchanged. The adapter shells out to the Gemini CLI (`gemini -p`) and reads `AIXGO_VERTEX_PROJECT`, `AIXGO_VERTEX_LOCATION` (default `us-central1`), and - `AIXGO_VERTEX_API_KEY`. Azure credentials are not required on this path. + `AIXGO_VERTEX_API_KEY`. If that last value is a service-account JSON object + (`"type": "service_account"`), it is written to a temp file and exported as + `GOOGLE_APPLICATION_CREDENTIALS`; a plain API key is still passed as + `GOOGLE_API_KEY`. Azure credentials are not required on this path. - The reusable workflow accepts optional `vertex-project`, `vertex-location`, and `vertex-api-key`, and the Azure endpoint/key are no longer required inputs. A Codex adopter who already passes them is unchanged. A Gemini diff --git a/docs/setup.md b/docs/setup.md index 7ab25a1..f8649b9 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -289,7 +289,9 @@ engine: gemini ```sh export AIXGO_VERTEX_PROJECT="" export AIXGO_VERTEX_LOCATION="us-central1" # optional, this is the default -export AIXGO_VERTEX_API_KEY="" +# Either a Vertex API key, or the contents of a service-account JSON key +# (a value starting with {"type":"service_account"...} is used as ADC). +export AIXGO_VERTEX_API_KEY="" aixgo-code run owner/repo#123 --repo-dir . ``` diff --git a/internal/engine/gemini/gemini.go b/internal/engine/gemini/gemini.go index bc5f1c4..e59123d 100644 --- a/internal/engine/gemini/gemini.go +++ b/internal/engine/gemini/gemini.go @@ -12,6 +12,7 @@ package gemini import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -53,6 +54,12 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (domain.RunResu return domain.RunResult{Role: req.Role, Err: err}, err } + credsPath, cleanup, err := materializeVertexCreds() + if err != nil { + return domain.RunResult{Role: req.Role, Err: err}, err + } + defer cleanup() + args := []string{"-p", req.Prompt, "--output-format", "text", "-y"} model := req.Model if model == "" { @@ -64,7 +71,7 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (domain.RunResu cmd := exec.CommandContext(ctx, r.bin(), args...) cmd.Dir = req.WorkDir - cmd.Env = r.childEnv(req.WorkDir) + cmd.Env = r.childEnv(req.WorkDir, credsPath) out, runErr := cmd.CombinedOutput() res := domain.RunResult{Role: req.Role, Summary: tail(strings.TrimSpace(string(out)), 40)} @@ -78,10 +85,11 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (domain.RunResu // childEnv builds the child environment. It mirrors the other adapters for the // Go caches, and translates the AIXGO_VERTEX_* names into the names the Gemini // CLI reads for Vertex. -func (r *Runner) childEnv(workDir string) []string { +func (r *Runner) childEnv(workDir, credsPath string) []string { env := filterEnvKeys(os.Environ(), "GOCACHE", "GOPATH", "GH_TOKEN", "GITHUB_TOKEN", - "GOOGLE_GENAI_USE_VERTEXAI", "GOOGLE_CLOUD_PROJECT", "GOOGLE_CLOUD_LOCATION", "GOOGLE_API_KEY", + "GOOGLE_GENAI_USE_VERTEXAI", "GOOGLE_CLOUD_PROJECT", "GOOGLE_CLOUD_LOCATION", + "GOOGLE_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS", ) env = append(env, r.ExtraEnv...) if !hasEnvKey(r.ExtraEnv, "GOCACHE") { @@ -103,12 +111,51 @@ func (r *Runner) childEnv(workDir string) []string { loc = defaultLocation } env = append(env, "GOOGLE_CLOUD_LOCATION="+loc) - if k := strings.TrimSpace(os.Getenv("AIXGO_VERTEX_API_KEY")); k != "" { + if credsPath != "" { + env = append(env, "GOOGLE_APPLICATION_CREDENTIALS="+credsPath) + } else if k := strings.TrimSpace(os.Getenv("AIXGO_VERTEX_API_KEY")); k != "" { env = append(env, "GOOGLE_API_KEY="+k) } return env } +// materializeVertexCreds writes a service-account JSON from AIXGO_VERTEX_API_KEY +// to a temp file outside the worktree. A plain API key is left in the env and +// handled by childEnv. The file is 0600 and must be removed by the caller. +func materializeVertexCreds() (string, func(), error) { + noop := func() {} + raw := strings.TrimSpace(os.Getenv("AIXGO_VERTEX_API_KEY")) + if raw == "" || !isServiceAccountJSON(raw) { + return "", noop, nil + } + f, err := os.CreateTemp("", "aixgo-vertex-sa-*.json") + if err != nil { + return "", noop, fmt.Errorf("gemini: write service account key: %w", err) + } + path := f.Name() + cleanup := func() { _ = os.Remove(path) } + if _, err := f.WriteString(raw); err != nil { + _ = f.Close() + cleanup() + return "", noop, fmt.Errorf("gemini: write service account key: %w", err) + } + if err := f.Close(); err != nil { + cleanup() + return "", noop, fmt.Errorf("gemini: write service account key: %w", err) + } + return path, cleanup, nil +} + +func isServiceAccountJSON(s string) bool { + var doc struct { + Type string `json:"type"` + } + if err := json.Unmarshal([]byte(s), &doc); err != nil { + return false + } + return doc.Type == "service_account" +} + func filterEnvKeys(env []string, keys ...string) []string { out := make([]string, 0, len(env)) for _, e := range env { diff --git a/internal/engine/gemini/gemini_test.go b/internal/engine/gemini/gemini_test.go index 29995aa..7512539 100644 --- a/internal/engine/gemini/gemini_test.go +++ b/internal/engine/gemini/gemini_test.go @@ -116,6 +116,44 @@ func TestRunExportsVertexSettingsForTheGeminiCLI(t *testing.T) { } } +const echoADC = `#!/bin/sh +printf '%s\n%s\n' "$GOOGLE_API_KEY" "$GOOGLE_APPLICATION_CREDENTIALS" +if [ -n "$GOOGLE_APPLICATION_CREDENTIALS" ]; then + cat "$GOOGLE_APPLICATION_CREDENTIALS" +fi +exit 0 +` + +const saJSON = `{"type":"service_account","project_id":"simplycubed-agents","client_email":"vertex@simplycubed-agents.iam.gserviceaccount.com"}` + +func TestRunTreatsServiceAccountJSONAsADC(t *testing.T) { + work := t.TempDir() + t.Setenv("AIXGO_VERTEX_PROJECT", "simplycubed-agents") + t.Setenv("AIXGO_VERTEX_API_KEY", saJSON) + r := New() + r.Bin = writeFakeGemini(t, echoADC) + res, err := r.Run(context.Background(), domain.RunRequest{ + Role: domain.RoleImplementer, WorkDir: work, Prompt: "x", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + lines := strings.SplitN(res.Summary, "\n", 2) + if len(lines) < 2 { + t.Fatalf("summary = %q", res.Summary) + } + creds := lines[0] + if creds == "" || strings.HasPrefix(creds, "{") { + t.Fatalf("GOOGLE_APPLICATION_CREDENTIALS must be a file path, not the JSON, got %q", creds) + } + if strings.HasPrefix(creds, work) { + t.Fatalf("the key file must not live in the worktree, got %q", creds) + } + if !strings.Contains(lines[1], `"type":"service_account"`) { + t.Fatalf("the key file should contain the service account JSON, got %q", res.Summary) + } +} + func TestRunDefaultsLocationWhenUnset(t *testing.T) { t.Setenv("AIXGO_VERTEX_PROJECT", "p") t.Setenv("AIXGO_VERTEX_LOCATION", "") From 8b3b24f71eb9f2457e951b69f0a4929d80ad5ad7 Mon Sep 17 00:00:00 2001 From: Charles Green Date: Sat, 19 Sep 2026 17:51:05 +0900 Subject: [PATCH 3/3] docs: Vertex dogfood config; drop Azure from this repo's caller Point aixgo-dev/code's own Actions caller at Vertex Gemini instead of AIXGO_AZURE_*. Set engine: gemini in .github/aixgo.yml. Add README TOC and Engines table so configuration is discoverable. Adopter templates still document Codex/Azure as an option; this repo no longer wires those env vars. --- .github/aixgo.yml | 3 ++ .github/workflows/aixgo-caller.yml | 15 ++++--- README.md | 63 ++++++++++++++++++++++++++++-- docs/setup.md | 7 ++++ 4 files changed, 78 insertions(+), 10 deletions(-) diff --git a/.github/aixgo.yml b/.github/aixgo.yml index 42f8f8f..be0785f 100644 --- a/.github/aixgo.yml +++ b/.github/aixgo.yml @@ -13,3 +13,6 @@ appName: aixgo-code gate: make check prDescription: rich + +# Dogfood on Vertex Gemini (not Azure OpenAI). +engine: gemini diff --git a/.github/workflows/aixgo-caller.yml b/.github/workflows/aixgo-caller.yml index 3669c3c..1e4ae61 100644 --- a/.github/workflows/aixgo-caller.yml +++ b/.github/workflows/aixgo-caller.yml @@ -27,11 +27,12 @@ jobs: github-app-client-id: ${{ vars.AIXGO_GH_APP_CLIENT_ID }} issue-number: ${{ github.event.issue.number }} actor-login: ${{ github.event.sender.login }} - azure-openai-endpoint: ${{ vars.AIXGO_AZURE_OPENAI_ENDPOINT }} + vertex-project: ${{ vars.AIXGO_VERTEX_PROJECT }} + vertex-location: ${{ vars.AIXGO_VERTEX_LOCATION }} version: ${{ github.sha }} # model: my-gpt-5-4-deployment secrets: - azure-openai-api-key: ${{ secrets.AIXGO_AZURE_OPENAI_API_KEY }} + vertex-api-key: ${{ secrets.AIXGO_VERTEX_API_KEY }} github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }} address: @@ -44,11 +45,12 @@ jobs: github-app-client-id: ${{ vars.AIXGO_GH_APP_CLIENT_ID }} pr-number: ${{ github.event.pull_request.number }} actor-login: ${{ github.event.review.user.login }} - azure-openai-endpoint: ${{ vars.AIXGO_AZURE_OPENAI_ENDPOINT }} + vertex-project: ${{ vars.AIXGO_VERTEX_PROJECT }} + vertex-location: ${{ vars.AIXGO_VERTEX_LOCATION }} version: ${{ github.sha }} # model: my-gpt-5-4-deployment secrets: - azure-openai-api-key: ${{ secrets.AIXGO_AZURE_OPENAI_API_KEY }} + vertex-api-key: ${{ secrets.AIXGO_VERTEX_API_KEY }} github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }} comment: @@ -70,12 +72,13 @@ jobs: issue-number: ${{ github.event.issue.pull_request == null && github.event.issue.number || '' }} pr-number: ${{ github.event.issue.pull_request != null && github.event.issue.number || '' }} actor-login: ${{ github.event.comment.user.login }} - azure-openai-endpoint: ${{ vars.AIXGO_AZURE_OPENAI_ENDPOINT }} + vertex-project: ${{ vars.AIXGO_VERTEX_PROJECT }} + vertex-location: ${{ vars.AIXGO_VERTEX_LOCATION }} # Same commit the other two jobs use. Without it this job installs the # released tag, so the comment path is the one path that cannot catch a # regression before it ships, and a version default bumped ahead of its # tag breaks this job alone. version: ${{ github.sha }} secrets: - azure-openai-api-key: ${{ secrets.AIXGO_AZURE_OPENAI_API_KEY }} + vertex-api-key: ${{ secrets.AIXGO_VERTEX_API_KEY }} github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }} diff --git a/README.md b/README.md index de72dce..7ba7bb5 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,20 @@ Aixgo Code is an autonomous coding agent you install into your own GitHub. Your > Beta. Current release: `v0.5.0`. Product overview: [aixgo.dev/code](https://aixgo.dev/code?utm_source=github&utm_medium=readme&utm_campaign=code). See [Status](#status). + +## Contents + +- [Product overview](#product-overview) +- [Why teams use it](#why-teams-use-it) +- [Installation](#installation) +- [How it works](#how-it-works) +- [Configuration](#configuration) — `.github/aixgo.yml` options +- [Engines](#engines) — Codex (Azure), Claude, Gemini (Vertex): required env vars +- [Deployment model](#deployment-model) +- [Status](#status) +- [Security](#security) +- [Full setup walkthrough](docs/setup.md) + ## Product overview Aixgo Code turns a GitHub issue into a proposed code change inside your own environment. Your team keeps the repository, runners, secrets, and branch protection rules. The agent does the implementation work, but it never merges its own pull requests. @@ -145,7 +159,9 @@ That runs the whole loop, including the model and your own gate. It makes no Git ## Configuration -Configuration lives in `.github/aixgo.yml`. A minimal file looks like this: +Configuration lives in `.github/aixgo.yml`. Engine credentials are **environment variables or Actions secrets**, never committed — see [Engines](#engines) for the table, and [docs/setup.md](docs/setup.md) for the full walkthrough. + +A minimal file looks like this: ```yaml labelPrefix: ax @@ -185,17 +201,55 @@ Getting the gate right is where first runs stall: it has to be green on your own ## Engines -The model that writes the code sits behind a pluggable `Runner` interface, so you bring your own provider. +The model that writes the code sits behind a pluggable `Runner` interface, so you bring your own provider. Set `engine:` in [`.github/aixgo.yml`](#configuration). The loop, roles, and gate are identical for every engine. + +| Engine | Config | Local CLI | GitHub Actions | Credentials | +| --- | --- | --- | --- | --- | +| **codex** (default) | omit `engine:` or `engine: codex` | yes | yes | `AIXGO_AZURE_OPENAI_ENDPOINT`, `AIXGO_AZURE_OPENAI_API_KEY` | +| **claude** | `engine: claude` | yes | not yet (workflow does not install Claude CLI) | your existing `claude` CLI auth | +| **gemini** | `engine: gemini` | yes | yes | `AIXGO_VERTEX_PROJECT`, `AIXGO_VERTEX_API_KEY`, optional `AIXGO_VERTEX_LOCATION` (default `us-central1`) | + +Full install steps and Actions wiring: [docs/setup.md](docs/setup.md) (see *Local CLI* / *GitHub Actions* under engine choice). -Codex on Azure is the engine the reusable GitHub Actions workflow can run today. `engine: claude` selects the Claude Code adapter, which runs headless (`claude -p`) against whatever credentials that CLI is already configured with. That path now works for local CLI runs and does not need Azure variables, but the reusable workflow still installs only the Codex CLI and still requires Azure inputs and secrets, so GitHub Actions cannot use Claude yet. The loop, the roles, and the gate are identical either way; the engine is the only thing that changes. +### Codex on Azure (default) ```yaml gate: make check +# engine: codex +``` + +```sh +export AIXGO_AZURE_OPENAI_ENDPOINT="https://.openai.azure.com" +export AIXGO_AZURE_OPENAI_API_KEY="" +``` + +Optional model override defaults to `gpt-5.4` if unset. + +### Claude (local CLI) +```yaml +gate: make check engine: claude ``` -The first engine adapter targets the Codex CLI running against Azure OpenAI. Today the shipped GitHub Actions setup needs an Azure endpoint, an API key, and optionally a deployment name override if you are not using the default `gpt-5.4`. The Claude Code adapter is written and tested behind `engine: claude`, and today is reachable from a local CLI run only. The `Runner` interface is the seam where other engines plug in. +Uses headless `claude -p` with whatever credentials that CLI already has. No Azure variables. + +### Gemini on Vertex AI + +```yaml +gate: make check +engine: gemini +``` + +```sh +export AIXGO_VERTEX_PROJECT="" +export AIXGO_VERTEX_LOCATION="us-central1" # optional +# Plain Vertex API key, OR the full contents of a service-account JSON key +# (JSON with "type":"service_account" is written to a temp file as ADC). +export AIXGO_VERTEX_API_KEY="" +``` + +In GitHub Actions, pass reusable-workflow inputs `vertex-project`, `vertex-location`, and secret `vertex-api-key` instead of Azure. The workflow installs `@google/gemini-cli` when `vertex-project` is set. Default model is `gemini-2.5-pro` (override with `--model` / workflow `model:`). ## Deployment model @@ -236,6 +290,7 @@ Roadmap, roughly in order: - Wiring the read-only reviewer role into the loop so a diff is reviewed before it reaches a human. - The self-onboarding flow via `init` and `init --workflow`. **Done.** - The Codex on Azure OpenAI engine adapter. **Done.** +- Gemini on Vertex AI (`engine: gemini`). **Done** on this branch / upcoming release. - Self-hosted models on Hugging Face. If you are evaluating it now, read that as beta software rather than a polished product. The core loops work; reviewer wiring is still in progress, and self-onboarding shipped as `init` and `init --workflow`. diff --git a/docs/setup.md b/docs/setup.md index f8649b9..e526b76 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -11,6 +11,13 @@ You will: - run a one-time self-test - hand the first issue to the agent +## Contents + +- [Before you begin](#before-you-begin) +- [Install and configure](#install-and-configure) +- [Local CLI vs GitHub Actions](#local-cli-vs-github-actions) — Codex / Claude / **Gemini (Vertex)** +- [Watching it run before it writes](#watching-it-run-before-it-writes) + ## Before you begin You need: