diff --git a/README.md b/README.md index 7ba7bb5..5524c35 100644 --- a/README.md +++ b/README.md @@ -65,10 +65,10 @@ To run the loop inside your own GitHub Actions: Disable the App webhook: the App is an identity that mints per-job tokens, and there is no Aixgo server to receive deliveries. Set install visibility to `Any account`. Install it on the repo. -2. In the adopter repo, run `aixgo-code init --workflow`. That writes `.github/aixgo.yml`, writes `.github/workflows/aixgo.yml` pinned to a released reusable-workflow tag, and creates the `ax:*` labels through your local `gh` auth. +2. In the adopter repo, run `aixgo-code init --workflow --app-name `. Interactively (TTY), init asks which engine to use; pass `--engine codex|gemini|claude` to skip the menu. Non-interactive runs default to `codex` and print that default. That writes `.github/aixgo.yml` with `engine:`, writes a **conditional** `.github/workflows/aixgo.yml` that only wires credentials for the chosen engine (pinned to a released reusable-workflow tag), and creates the `ax:*` labels through your local `gh` auth. Existing starter files are left unchanged; pass `--force` to overwrite (for example to switch engines). 3. Fill in the real `gate:` in `.github/aixgo.yml`. -4. Add repository variable `AIXGO_GH_APP_CLIENT_ID` (the App Client ID, the `Iv23` string on the App settings page), repository secret `AIXGO_GH_APP_PRIVATE_KEY`, repository variable `AIXGO_AZURE_OPENAI_ENDPOINT`, and repository secret `AIXGO_AZURE_OPENAI_API_KEY`. - These are per-repository and are never inherited from Aixgo: a reusable workflow runs with the calling repository's own variables and secrets, so you bring your own Azure endpoint and key, and pay for your own tokens. +4. Add repository variable `AIXGO_GH_APP_CLIENT_ID` (the App Client ID, the `Iv23` string on the App settings page) and repository secret `AIXGO_GH_APP_PRIVATE_KEY`, plus the engine credentials init listed for your choice (Azure for Codex, Vertex for Gemini; Claude is local-CLI today — Actions wiring tracked in [#147](https://github.com/aixgo-dev/code/issues/147)). + These are per-repository and are never inherited from Aixgo: a reusable workflow runs with the calling repository's own variables and secrets. The private key secret must be the full PEM contents, including the `-----BEGIN` and `-----END` lines. 5. Open a setup pull request in the adopter repo and merge it yourself. Setup files are written locally by `aixgo-code init` and merged by a human, because the runtime holds no `workflows` permission and cannot add its own workflow files. 6. File an issue and apply `ax:go`. Reviews submitted on the resulting pull request call back into the same reusable workflow for the fix-on-request loop. @@ -201,7 +201,7 @@ 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. Set `engine:` in [`.github/aixgo.yml`](#configuration). The loop, roles, and gate are identical for every engine. +The model that writes the code sits behind a pluggable `Runner` interface, so you bring your own provider. `aixgo-code init` chooses the engine (`--engine` or an interactive menu) and writes `engine:` into [`.github/aixgo.yml`](#configuration) plus a caller workflow that only passes that provider's inputs/secrets. You can also set `engine:` by hand. The loop, roles, and gate are identical for every engine. | Engine | Config | Local CLI | GitHub Actions | Credentials | | --- | --- | --- | --- | --- | diff --git a/cmd/aixgo-code/aixgo-caller.yml.tmpl b/cmd/aixgo-code/aixgo-caller.yml.tmpl index d1c683e..2d91ff5 100644 --- a/cmd/aixgo-code/aixgo-caller.yml.tmpl +++ b/cmd/aixgo-code/aixgo-caller.yml.tmpl @@ -1,3 +1,7 @@ +# Caller workflow written by `aixgo-code init --workflow`. +# Credentials match the engine chosen at init (--engine or the interactive menu). +# Re-run with --force --engine to rewrite config and this file for a +# different provider. Existing files are left unchanged without --force. name: aixgo on: @@ -17,11 +21,9 @@ 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 }} - # model: my-gpt-5-4-deployment +# __AIXGO_ENGINE_INPUTS__ secrets: - azure-openai-api-key: ${{ secrets.AIXGO_AZURE_OPENAI_API_KEY }} - github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }} +# __AIXGO_ENGINE_SECRETS__ address: if: >- @@ -33,11 +35,9 @@ 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 }} - # model: my-gpt-5-4-deployment +# __AIXGO_ENGINE_INPUTS__ secrets: - azure-openai-api-key: ${{ secrets.AIXGO_AZURE_OPENAI_API_KEY }} - github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }} +# __AIXGO_ENGINE_SECRETS__ comment: # issue_comment fires for any user on a public repository, so the command @@ -58,7 +58,6 @@ 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 }} +# __AIXGO_ENGINE_INPUTS__ secrets: - azure-openai-api-key: ${{ secrets.AIXGO_AZURE_OPENAI_API_KEY }} - github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }} +# __AIXGO_ENGINE_SECRETS__ diff --git a/cmd/aixgo-code/init_select.go b/cmd/aixgo-code/init_select.go new file mode 100644 index 0000000..d87d334 --- /dev/null +++ b/cmd/aixgo-code/init_select.go @@ -0,0 +1,314 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/aixgo-dev/code/internal/app" + "github.com/aixgo-dev/code/internal/buildinfo" + "github.com/aixgo-dev/code/internal/config" + forgegh "github.com/aixgo-dev/code/internal/forge/gh" +) + +const ( + callerWorkflowEngineToken = "__AIXGO_ENGINE__" + callerWorkflowInputsToken = "# __AIXGO_ENGINE_INPUTS__" + callerWorkflowSecretsToken = "# __AIXGO_ENGINE_SECRETS__" + defaultInitEngine = "codex" +) + +// initStdinIsTTY reports whether stdin is an interactive terminal. Tests +// override it so non-TTY defaulting and the menu path are both reachable. +var initStdinIsTTY = func() bool { + fi, err := os.Stdin.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} + +// initPromptIn is the reader for the interactive engine menu. Tests swap it. +var initPromptIn io.Reader = os.Stdin + +func initCmd(argv []string, stdout io.Writer) error { + fs := flag.NewFlagSet("init", flag.ContinueOnError) + repoDir := fs.String("repo-dir", ".", "path to the target repo checkout") + writeWorkflow := fs.Bool("workflow", false, "also write the Actions caller workflow") + appNameFlag := fs.String("app-name", "", "the GitHub App you installed, for example acme-code; comment commands address it") + engineFlag := fs.String("engine", "", "model engine: codex (Azure), gemini (Vertex), or claude") + force := fs.Bool("force", false, "overwrite existing starter config and workflow files") + if _, err := parseInterleaved(fs, argv); err != nil { + return err + } + + engine, err := resolveEngine(*engineFlag, initStdinIsTTY(), initPromptIn, stdout) + if err != nil { + return err + } + + appName, err := resolveAppName(*repoDir, *appNameFlag) + if err != nil { + return err + } + + configPath := filepath.Join(*repoDir, ".github", "aixgo.yml") + wroteConfig, err := writeStarterConfig(configPath, appName, engine, *force) + if err != nil { + return err + } + workflowPath := filepath.Join(*repoDir, ".github", "workflows", "aixgo.yml") + selftestPath := filepath.Join(*repoDir, ".github", "workflows", "aixgo-selftest.yml") + wroteWorkflow := false + wroteSelftest := false + if *writeWorkflow { + wroteWorkflow, err = writeStarterFile(workflowPath, renderCallerWorkflow(buildinfo.Version, appName, engine), *force) + if err != nil { + return err + } + wroteSelftest, err = writeStarterFile(selftestPath, selftestWorkflowTemplate, *force) + if err != nil { + return err + } + } + + labels := app.StateLabels(config.DefaultLabelPrefix) + created, err := (&forgegh.Forge{Dir: *repoDir}).EnsureLabels(context.Background(), labels) + if err != nil { + return err + } + + if wroteConfig { + fmt.Fprintf(stdout, "wrote %s\n", configPath) + } else { + fmt.Fprintf(stdout, "left existing %s unchanged\n", configPath) + } + if *writeWorkflow { + if wroteWorkflow { + fmt.Fprintf(stdout, "wrote %s\n", workflowPath) + } else { + fmt.Fprintf(stdout, "left existing %s unchanged\n", workflowPath) + } + if wroteSelftest { + fmt.Fprintf(stdout, "wrote %s\n", selftestPath) + } else { + fmt.Fprintf(stdout, "left existing %s unchanged\n", selftestPath) + } + } + if len(created) == 0 { + fmt.Fprintln(stdout, "labels already present: no changes") + } else { + fmt.Fprintf(stdout, "created labels: %s\n", strings.Join(created, ", ")) + } + printInitNextSteps(stdout, *repoDir, engine) + return nil +} + +// resolveEngine picks the model engine for starter files. An explicit --engine +// wins; otherwise a TTY gets a short menu; otherwise codex is the default so +// existing non-interactive scripts keep working. +func resolveEngine(flagValue string, interactive bool, in io.Reader, out io.Writer) (string, error) { + if strings.TrimSpace(flagValue) != "" { + return normalizeEngine(flagValue) + } + if interactive { + return promptEngine(in, out) + } + fmt.Fprintf(out, "using engine: %s (default; pass --engine or run interactively to choose)\n", defaultInitEngine) + return defaultInitEngine, nil +} + +func normalizeEngine(v string) (string, error) { + switch e := strings.ToLower(strings.TrimSpace(v)); e { + case "codex", "claude", "gemini": + return e, nil + default: + return "", fmt.Errorf("--engine must be codex, claude, or gemini, got %q", v) + } +} + +func promptEngine(in io.Reader, out io.Writer) (string, error) { + fmt.Fprintln(out, "Select the model engine:") + fmt.Fprintln(out, " 1) Codex / Azure OpenAI (default)") + fmt.Fprintln(out, " 2) Gemini / Vertex AI") + fmt.Fprintln(out, " 3) Claude (local CLI; Actions support pending)") + fmt.Fprint(out, "Enter choice [1]: ") + buf := make([]byte, 0, 16) + tmp := make([]byte, 1) + for { + n, err := in.Read(tmp) + if n > 0 { + if tmp[0] == '\n' { + break + } + if tmp[0] != '\r' { + buf = append(buf, tmp[0]) + } + } + if err != nil { + if len(buf) == 0 && err == io.EOF { + break + } + if err != io.EOF { + return "", err + } + break + } + } + choice := strings.TrimSpace(string(buf)) + switch choice { + case "", "1": + return "codex", nil + case "2": + return "gemini", nil + case "3": + return "claude", nil + default: + if e, err := normalizeEngine(choice); err == nil { + return e, nil + } + return "", fmt.Errorf("unknown engine choice %q (use 1, 2, 3, or codex|gemini|claude)", choice) + } +} + +func printInitNextSteps(stdout io.Writer, repoDir, engine string) { + createURL, resolved := appCreateURL(repoDir) + fmt.Fprintln(stdout, "next steps:") + fmt.Fprintln(stdout, " 1. Create your OWN GitHub App. It has to be yours: its private key is what mints") + fmt.Fprintln(stdout, " the tokens that act on your repository, so a shared key would let its holder act") + fmt.Fprintln(stdout, " on every other installation. This is what keeps the agent inside your GitHub.") + fmt.Fprintln(stdout, " "+createURL) + if !resolved { + fmt.Fprintln(stdout, " (if this repository belongs to an organisation, use") + fmt.Fprintln(stdout, " https://github.com/organizations//settings/apps/new instead)") + } + fmt.Fprintln(stdout, " Name it anything you like; App names are globally unique, so you cannot reuse ours.") + fmt.Fprintln(stdout, " Permissions, and nothing else: Contents: Read and write") + fmt.Fprintln(stdout, " Issues: Read and write") + fmt.Fprintln(stdout, " Pull requests: Read and write") + fmt.Fprintln(stdout, " Uncheck Active under Webhook. Actions triggers this runtime; an enabled webhook") + fmt.Fprintln(stdout, " with nothing listening only generates failures.") + fmt.Fprintln(stdout, " Set Any account under Where can this GitHub App be installed, if the App belongs") + fmt.Fprintln(stdout, " to your personal account and the repository belongs to an organisation. An App") + fmt.Fprintln(stdout, " restricted to its owner cannot be installed anywhere else, and this is the step") + fmt.Fprintln(stdout, " most often missed.") + fmt.Fprintln(stdout, " 2. Generate a private key on the App settings page and keep the download. GitHub") + fmt.Fprintln(stdout, " shows it once. Note the Client ID there too, the Iv23 string.") + fmt.Fprintln(stdout, " 3. Install the App on this repository, from Install App on the same page.") + fmt.Fprintln(stdout, " Reference: https://docs.github.com/apps/creating-github-apps") + fmt.Fprintln(stdout, " - write the real gate in .github/aixgo.yml") + fmt.Fprintln(stdout, " - verify that gate is green on your main branch") + printInitCredentialSteps(stdout, engine) + fmt.Fprintln(stdout, " Variables and Secrets are different tabs. A value filed under the wrong one reads back") + fmt.Fprintln(stdout, " as empty, and the run fails without saying why.") + fmt.Fprintln(stdout, " - merge the PR containing the config and workflow changes") + fmt.Fprintln(stdout, " - run the self-test once: gh workflow run aixgo-selftest") + fmt.Fprintln(stdout, " - delete .github/workflows/aixgo-selftest.yml once it passes") + fmt.Fprintln(stdout, " - file an issue and apply the ax:go label") + fmt.Fprintln(stdout, " Existing starter files are left unchanged; pass --force to overwrite them") + fmt.Fprintln(stdout, " (for example to switch engines with --engine).") +} + +func printInitCredentialSteps(stdout io.Writer, engine string) { + fmt.Fprintln(stdout, " - add repository VARIABLES, under Settings > Secrets and variables > Actions > Variables:") + fmt.Fprintln(stdout, " AIXGO_GH_APP_CLIENT_ID the App Client ID, the Iv23 string on the App settings page") + switch engine { + case "gemini": + fmt.Fprintln(stdout, " AIXGO_VERTEX_PROJECT Google Cloud project for Vertex AI") + fmt.Fprintln(stdout, " AIXGO_VERTEX_LOCATION optional; default us-central1") + fmt.Fprintln(stdout, " - add repository SECRETS, on the Secrets tab of that same page:") + fmt.Fprintln(stdout, " AIXGO_GH_APP_PRIVATE_KEY the full PEM, including the BEGIN and END lines") + fmt.Fprintln(stdout, " AIXGO_VERTEX_API_KEY Vertex API key or service-account JSON contents") + case "claude": + fmt.Fprintln(stdout, " - add repository SECRETS, on the Secrets tab of that same page:") + fmt.Fprintln(stdout, " AIXGO_GH_APP_PRIVATE_KEY the full PEM, including the BEGIN and END lines") + fmt.Fprintln(stdout, " - Claude locally: use your existing `claude` CLI login; no Azure or Vertex vars.") + fmt.Fprintln(stdout, " - Claude on Actions is not wired yet (aixgo-dev/code#147). The caller has no") + fmt.Fprintln(stdout, " Anthropic secret input until that lands; prefer local `aixgo-code` for Claude today.") + default: + fmt.Fprintln(stdout, " AIXGO_AZURE_OPENAI_ENDPOINT e.g. https://.openai.azure.com") + fmt.Fprintln(stdout, " - add repository SECRETS, on the Secrets tab of that same page:") + fmt.Fprintln(stdout, " AIXGO_GH_APP_PRIVATE_KEY the full PEM, including the BEGIN and END lines") + fmt.Fprintln(stdout, " AIXGO_AZURE_OPENAI_API_KEY the Azure OpenAI key") + } +} + +func writeStarterConfig(path, appName, engine string, force bool) (bool, error) { + body := strings.ReplaceAll(starterConfig, callerWorkflowAppNameToken, appName) + body = strings.ReplaceAll(body, callerWorkflowEngineToken, engine) + return writeStarterFile(path, body, force) +} + +// resolveAppName decides the handle to write into both files. An explicit +// --app-name wins; otherwise an existing config keeps what it already says, so +// re-running init to pick up a new release does not silently change the handle +// a team already types. +func resolveAppName(repoDir, flagValue string) (string, error) { + if v := strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(flagValue), "@"), "[bot]"); v != "" { + return v, nil + } + if cfg, err := config.Load(filepath.Join(repoDir, ".github", "aixgo.yml")); err == nil && cfg.AppName != "" { + return cfg.AppName, nil + } + return "", errors.New("--app-name is required: comment commands address the App you installed, and its name is unique to you. Pass the App's name without the \"[bot]\" suffix, for example --app-name acme-code") +} + +func writeStarterFile(path, body string, force bool) (bool, error) { + if !force { + if _, err := os.Stat(path); err == nil { + return false, nil + } else if !os.IsNotExist(err) { + return false, err + } + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return false, err + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + return false, err + } + return true, nil +} + +// renderCallerWorkflow writes the trigger for the App this repository installed +// and only the credentials for the chosen engine. The handle cannot be a +// constant: App names are globally unique, so every adopter's bot has its own, +// and the trigger has to match theirs or no comment ever starts a run. It is +// templated here rather than matched loosely at runtime so a mention of a +// colleague does not spin up a runner. +func renderCallerWorkflow(version, appName, engine string) string { + inputs, secrets := callerEngineBlocks(engine) + out := strings.ReplaceAll(callerWorkflowTemplate, callerWorkflowTagToken, workflowTemplateTag(version)) + out = strings.ReplaceAll(out, callerWorkflowAppNameToken, appName) + out = strings.ReplaceAll(out, callerWorkflowInputsToken, inputs) + out = strings.ReplaceAll(out, callerWorkflowSecretsToken, secrets) + return out +} + +func callerEngineBlocks(engine string) (inputs, secrets string) { + switch engine { + case "gemini": + inputs = " vertex-project: ${{ vars.AIXGO_VERTEX_PROJECT }}\n" + + " vertex-location: ${{ vars.AIXGO_VERTEX_LOCATION }}\n" + + " # model: gemini-2.5-pro" + secrets = " vertex-api-key: ${{ secrets.AIXGO_VERTEX_API_KEY }}\n" + + " github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }}" + case "claude": + inputs = " # Claude on Actions is not wired yet (aixgo-dev/code#147):\n" + + " # the reusable workflow does not install the Claude CLI or accept\n" + + " # an Anthropic secret. Local `aixgo-code` with engine: claude uses\n" + + " # your existing claude CLI login." + secrets = " github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }}" + default: + inputs = " azure-openai-endpoint: ${{ vars.AIXGO_AZURE_OPENAI_ENDPOINT }}\n" + + " # model: my-gpt-5-4-deployment" + secrets = " azure-openai-api-key: ${{ secrets.AIXGO_AZURE_OPENAI_API_KEY }}\n" + + " github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }}" + } + return inputs, secrets +} diff --git a/cmd/aixgo-code/init_select_test.go b/cmd/aixgo-code/init_select_test.go new file mode 100644 index 0000000..d22f854 --- /dev/null +++ b/cmd/aixgo-code/init_select_test.go @@ -0,0 +1,277 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func stubGHForInit(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("gh stub is a POSIX shell script") + } + prevTTY := initStdinIsTTY + initStdinIsTTY = func() bool { return false } + t.Cleanup(func() { initStdinIsTTY = prevTTY }) + stubDir := t.TempDir() + logPath := filepath.Join(stubDir, "gh.log") + stubPath := filepath.Join(stubDir, "gh") + script := `#!/bin/sh +printf '%s\n' "$*" >> "$GH_STUB_LOG" +if [ "$1 $2" = "label list" ]; then + echo '[]' + exit 0 +fi +exit 0 +` + if err := os.WriteFile(stubPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("GH_STUB_LOG", logPath) + t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func TestInitEngineFlagWritesConfigAndCaller(t *testing.T) { + stubGHForInit(t) + buildVersionForTest(t, "0.1.0") + + for _, tc := range []struct { + engine string + wantConfig string + wantInCaller []string + missingInCaller []string + wantNext []string + missingNext []string + }{ + { + engine: "codex", + wantConfig: "engine: codex", + wantInCaller: []string{ + "azure-openai-endpoint: ${{ vars.AIXGO_AZURE_OPENAI_ENDPOINT }}", + "azure-openai-api-key: ${{ secrets.AIXGO_AZURE_OPENAI_API_KEY }}", + }, + missingInCaller: []string{"vertex-project", "vertex-api-key", "Claude on Actions"}, + wantNext: []string{ + "AIXGO_AZURE_OPENAI_ENDPOINT", + "AIXGO_AZURE_OPENAI_API_KEY", + "AIXGO_GH_APP_CLIENT_ID", + "AIXGO_GH_APP_PRIVATE_KEY", + }, + missingNext: []string{"AIXGO_VERTEX_PROJECT", "aixgo-dev/code#147"}, + }, + { + engine: "gemini", + wantConfig: "engine: gemini", + wantInCaller: []string{ + "vertex-project: ${{ vars.AIXGO_VERTEX_PROJECT }}", + "vertex-location: ${{ vars.AIXGO_VERTEX_LOCATION }}", + "vertex-api-key: ${{ secrets.AIXGO_VERTEX_API_KEY }}", + }, + missingInCaller: []string{"azure-openai-endpoint", "azure-openai-api-key"}, + wantNext: []string{ + "AIXGO_VERTEX_PROJECT", + "AIXGO_VERTEX_API_KEY", + "AIXGO_GH_APP_CLIENT_ID", + "AIXGO_GH_APP_PRIVATE_KEY", + }, + missingNext: []string{"AIXGO_AZURE_OPENAI_ENDPOINT"}, + }, + { + engine: "claude", + wantConfig: "engine: claude", + wantInCaller: []string{ + "Claude on Actions is not wired yet (aixgo-dev/code#147)", + "github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }}", + }, + missingInCaller: []string{"azure-openai-endpoint", "vertex-project", "azure-openai-api-key", "vertex-api-key"}, + wantNext: []string{ + "AIXGO_GH_APP_CLIENT_ID", + "AIXGO_GH_APP_PRIVATE_KEY", + "aixgo-dev/code#147", + }, + missingNext: []string{"AIXGO_AZURE_OPENAI_ENDPOINT", "AIXGO_VERTEX_PROJECT"}, + }, + } { + t.Run(tc.engine, func(t *testing.T) { + repoDir := t.TempDir() + var out bytes.Buffer + if err := initCmd([]string{ + "--repo-dir", repoDir, + "--workflow", + "--app-name", "acme-code", + "--engine", tc.engine, + }, &out); err != nil { + t.Fatalf("initCmd: %v", err) + } + cfg, err := os.ReadFile(filepath.Join(repoDir, ".github", "aixgo.yml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(cfg), tc.wantConfig) { + t.Fatalf("config missing %q:\n%s", tc.wantConfig, cfg) + } + workflow, err := os.ReadFile(filepath.Join(repoDir, ".github", "workflows", "aixgo.yml")) + if err != nil { + t.Fatal(err) + } + body := string(workflow) + for _, want := range tc.wantInCaller { + if !strings.Contains(body, want) { + t.Fatalf("caller missing %q:\n%s", want, body) + } + } + for _, miss := range tc.missingInCaller { + if strings.Contains(body, miss) { + t.Fatalf("caller should not contain %q:\n%s", miss, body) + } + } + output := out.String() + if strings.Contains(output, "using engine: codex (default") { + t.Fatalf("explicit --engine should not print the non-TTY default message:\n%s", output) + } + for _, want := range tc.wantNext { + if !strings.Contains(output, want) { + t.Fatalf("next steps missing %q:\n%s", want, output) + } + } + for _, miss := range tc.missingNext { + if strings.Contains(output, miss) { + t.Fatalf("next steps should not mention %q:\n%s", miss, output) + } + } + }) + } +} + +func TestInitNonTTYDefaultsToCodex(t *testing.T) { + stubGHForInit(t) + prev := initStdinIsTTY + initStdinIsTTY = func() bool { return false } + t.Cleanup(func() { initStdinIsTTY = prev }) + + repoDir := t.TempDir() + var out bytes.Buffer + if err := initCmd([]string{"--repo-dir", repoDir, "--app-name", "acme-code"}, &out); err != nil { + t.Fatalf("initCmd: %v", err) + } + cfg, err := os.ReadFile(filepath.Join(repoDir, ".github", "aixgo.yml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(cfg), "engine: codex") { + t.Fatalf("expected engine: codex:\n%s", cfg) + } + if !strings.Contains(out.String(), "using engine: codex (default; pass --engine or run interactively to choose)") { + t.Fatalf("expected default-engine message:\n%s", out.String()) + } +} + +func TestInitInteractiveMenuSelectsGemini(t *testing.T) { + stubGHForInit(t) + prevTTY := initStdinIsTTY + prevIn := initPromptIn + initStdinIsTTY = func() bool { return true } + initPromptIn = strings.NewReader("2\n") + t.Cleanup(func() { + initStdinIsTTY = prevTTY + initPromptIn = prevIn + }) + + repoDir := t.TempDir() + var out bytes.Buffer + if err := initCmd([]string{"--repo-dir", repoDir, "--workflow", "--app-name", "acme-code"}, &out); err != nil { + t.Fatalf("initCmd: %v", err) + } + cfg, err := os.ReadFile(filepath.Join(repoDir, ".github", "aixgo.yml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(cfg), "engine: gemini") { + t.Fatalf("expected engine: gemini:\n%s", cfg) + } + workflow, err := os.ReadFile(filepath.Join(repoDir, ".github", "workflows", "aixgo.yml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(workflow), "vertex-project:") { + t.Fatalf("expected vertex inputs:\n%s", workflow) + } + if strings.Contains(string(workflow), "azure-openai-endpoint") { + t.Fatalf("gemini caller must not wire Azure:\n%s", workflow) + } + if !strings.Contains(out.String(), "Select the model engine:") { + t.Fatalf("expected interactive menu:\n%s", out.String()) + } +} + +func TestInitForceOverwritesEngine(t *testing.T) { + stubGHForInit(t) + buildVersionForTest(t, "0.1.0") + repoDir := t.TempDir() + + var first bytes.Buffer + if err := initCmd([]string{ + "--repo-dir", repoDir, "--workflow", "--app-name", "acme-code", "--engine", "codex", + }, &first); err != nil { + t.Fatalf("first init: %v", err) + } + var second bytes.Buffer + if err := initCmd([]string{ + "--repo-dir", repoDir, "--workflow", "--app-name", "acme-code", "--engine", "gemini", "--force", + }, &second); err != nil { + t.Fatalf("force init: %v", err) + } + cfg, err := os.ReadFile(filepath.Join(repoDir, ".github", "aixgo.yml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(cfg), "engine: gemini") { + t.Fatalf("force should rewrite engine:\n%s", cfg) + } + workflow, err := os.ReadFile(filepath.Join(repoDir, ".github", "workflows", "aixgo.yml")) + if err != nil { + t.Fatal(err) + } + body := string(workflow) + if !strings.Contains(body, "vertex-project:") || strings.Contains(body, "azure-openai-endpoint") { + t.Fatalf("force should rewrite caller to gemini:\n%s", body) + } + if !strings.Contains(second.String(), "wrote ") { + t.Fatalf("force should report writes:\n%s", second.String()) + } +} + +func TestNormalizeEngine(t *testing.T) { + t.Parallel() + for _, in := range []string{"codex", "Claude", " GEMINI "} { + if _, err := normalizeEngine(in); err != nil { + t.Fatalf("normalizeEngine(%q): %v", in, err) + } + } + if _, err := normalizeEngine("gpt"); err == nil { + t.Fatal("unknown engine must error") + } +} + +func TestRenderCallerWorkflowOmitsOtherEngines(t *testing.T) { + t.Parallel() + codex := renderCallerWorkflow("0.5.0", "acme", "codex") + gemini := renderCallerWorkflow("0.5.0", "acme", "gemini") + claude := renderCallerWorkflow("0.5.0", "acme", "claude") + if !strings.Contains(codex, "azure-openai-endpoint") || strings.Contains(codex, "vertex-project") { + t.Fatalf("codex caller:\n%s", codex) + } + if !strings.Contains(gemini, "vertex-project") || strings.Contains(gemini, "azure-openai") { + t.Fatalf("gemini caller:\n%s", gemini) + } + if strings.Contains(claude, "azure-openai") || strings.Contains(claude, "vertex-") { + t.Fatalf("claude caller:\n%s", claude) + } + if !strings.Contains(claude, "aixgo-dev/code#147") { + t.Fatalf("claude caller should document Actions limitation:\n%s", claude) + } +} diff --git a/cmd/aixgo-code/main.go b/cmd/aixgo-code/main.go index 7ee611e..81136e1 100644 --- a/cmd/aixgo-code/main.go +++ b/cmd/aixgo-code/main.go @@ -1,10 +1,3 @@ -// Command aixgo is the CLI for Aixgo Code. It runs the same engine -// the GitHub Action runs, locally, for development and debugging. -// -// aixgo-code version -// aixgo-code run [flags] -// -// This is an early scaffold. See STATUS.md. package main import ( @@ -43,10 +36,6 @@ func main() { os.Exit(dispatch(os.Args[1:], os.Stdout, os.Stderr)) } -// dispatch runs one command and returns the process exit code. main is only a -// wrapper around it, so the whole entry point (argument handling, exit codes, -// error reporting) is reachable from a test instead of being the one part of -// the CLI that nothing exercises. func dispatch(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { usage(stderr) @@ -54,9 +43,6 @@ func dispatch(args []string, stdout, stderr io.Writer) int { } fail := func(err error) int { fmt.Fprintln(stderr, "error:", err) - // A missing adopter-set value is something to go and set, not a bug to - // report. Its own exit code lets a caller tell those apart without - // matching on message text. if errors.Is(err, ErrConfigMissing) { return 3 } @@ -97,7 +83,7 @@ func usage(w io.Writer) { usage: aixgo-code version - aixgo-code init [--repo-dir .] [--workflow] + aixgo-code init [--repo-dir .] [--workflow] [--engine codex|claude|gemini] [--force] aixgo-code preflight [--repo-dir .] aixgo-code command --body "" [flags] aixgo-code run [flags] @@ -140,6 +126,11 @@ labelPrefix: ax # the two ever disagree. appName: __AIXGO_APP_NAME__ +# Model engine: codex (Azure OpenAI, default), gemini (Vertex AI), or claude +# (local CLI today; Actions install tracked in aixgo-dev/code#147). +# Chosen by "aixgo-code init --engine" or the interactive menu. +engine: __AIXGO_ENGINE__ + # Required. Fill this in with the real gate that is already green on main. gate: @@ -165,10 +156,6 @@ var callerWorkflowTemplate string //go:embed aixgo-selftest.yml.tmpl var selftestWorkflowTemplate string -// engineEnv validates the selected engine settings and returns the normalized -// 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 @@ -210,21 +197,8 @@ func engineEnv(cfg *config.Config) (string, error) { return endpoint, nil } -// newVCS builds the git layer with a committer identity. A GitHub Actions -// runner has none configured, so without this a commit fails with "Author -// identity unknown" after the change is already made and the gate has passed. -// The identity is the credential's own login, so commits are attributable to -// whoever the run authenticated as. -// ErrConfigMissing marks a missing adopter-set value, as opposed to an internal -// failure. The two want different responses: one is something to go and set, -// the other is a bug. dispatch turns this into its own exit code so a caller -// can tell them apart without parsing text. var ErrConfigMissing = errors.New("configuration missing") -// section is where a value lives in the adopter's repository settings. GitHub -// puts Variables and Secrets on different tabs, and a value filed under the -// wrong one reads back as empty rather than failing, so every message about a -// missing value has to say which tab it belongs on. type section string const ( @@ -232,9 +206,6 @@ const ( sectionSecret section = "secret" ) -// requireSet returns an error naming the value and its section when unset or -// empty. Empty matters as much as unset: an empty string is exactly what a -// value filed under the wrong tab looks like from here. func requireSet(name string, where section) error { if strings.TrimSpace(os.Getenv(name)) != "" { return nil @@ -252,24 +223,6 @@ func newVCS(self string) *vcsgit.Git { } } -// newRunner builds the engine runner, honouring AIXGO_SANDBOX. -// -// The sandbox stays on. Nothing in this repository widens it, and the knob -// exists only so an adopter who has genuinely sandboxed their runners -// externally can decide that for themselves. -// -// The default sandbox uses bubblewrap on Linux, which needs an unprivileged -// user namespace. Ubuntu 24.04 restricts those by AppArmor, so on a stock -// GitHub runner every command the engine tried died at startup: -// -// bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted -// -// The engine then read nothing and changed nothing while still exiting -// successfully, which is why it looked like a silent no-op. The reusable -// workflow now enables that kernel feature before the engine runs, which lets -// 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, model string) engine.Runner { if cfg.Engine == "claude" { return &claude.Runner{Model: model} @@ -287,8 +240,6 @@ func newRunner(cfg *config.Config, codexHome, model string) engine.Runner { return r } -// takeBody removes --body (in either form) from argv and returns it with the -// remaining arguments. func takeBody(argv []string) (body string, rest []string, err error) { for i := 0; i < len(argv); i++ { a := argv[i] @@ -309,9 +260,6 @@ func takeBody(argv []string) (body string, rest []string, err error) { return body, rest, nil } -// appNameFor reads the configured App handle from the same --repo-dir the rest -// of the command will use. It parses only that flag, because run and address -// own the full flag set and a second copy of it would drift. func appNameFor(argv []string) (string, error) { repoDir := "." for i, a := range argv { @@ -332,20 +280,11 @@ func appNameFor(argv []string) (string, error) { return cfg.AppName, nil } -// commandCmd routes a comment addressed to the agent to the matching loop. The -// comment body is untrusted, so it is parsed into a fixed vocabulary here and -// never interpreted: an unrecognised comment does nothing at all. func commandCmd(argv []string, stdout io.Writer) error { - // --body is pulled out by hand and everything else is forwarded untouched, - // because run and address own the rest of the flags. Parsing them here would - // mean maintaining a second copy of that flag set. body, rest, err := takeBody(argv) if err != nil { return err } - // The handle is per-repository, so the config has to be read before the - // comment can be parsed at all: which App this repository installed is the - // thing that decides what a command even looks like here. appName, err := appNameFor(rest) if err != nil { return err @@ -356,10 +295,6 @@ func commandCmd(argv []string, stdout io.Writer) error { case command.Help: return reply(rest, command.HelpTextFor(appName), stdout) case command.Go, command.Address: - // A verb aimed at the wrong surface is answered, not run. This lives on - // the comment path rather than inside run and address because someone - // typing `aixgo-code address owner/repo#96` at a shell wants an error, - // not a comment posted in their name. a, err := newAnswerer(rest) if err != nil { return err @@ -372,9 +307,6 @@ func commandCmd(argv []string, stdout io.Writer) error { } return addressCmd(rest) default: - // Only answer a comment that was actually addressed to the agent. - // Anything else is someone mentioning it in passing, and replying to - // that would make it the noisiest participant in every thread. if command.Addressed(body, appName) { return reply(rest, command.UnknownTextFor(appName), stdout) } @@ -383,31 +315,18 @@ func commandCmd(argv []string, stdout io.Writer) error { } } -// newCommentForge builds the forge used to answer a comment. It is a variable so -// the routing tests can answer without a network. var newCommentForge = func() forge2.Forge { return &forgegh.Forge{} } -// answerer is the little that is needed to reply to a comment: which thread it -// arrived on, and something to answer with. -// -// It deliberately does not go through prepare. Answering `help`, or telling -// someone they used the wrong verb, must work in a repository that has no config -// and no engine credentials, because those are exactly the repositories where -// someone is most likely to be asking. type answerer struct { forge forge2.Forge dry *dryrun.Forge ref domain.Issue onPR bool - // ok is false when the arguments carry no issue ref, which is what a local - // run driven by hand looks like. There is then nothing to answer on. - ok bool + ok bool } func newAnswerer(argv []string) (answerer, error) { var a answerer - // The ref is found by shape rather than by position, because the flags it - // sits among belong to run and address and are not parsed here. for _, arg := range argv { if ref, err := app.ParseIssueRef(arg); err == nil { a.ref, a.ok = ref, true @@ -431,9 +350,6 @@ func newAnswerer(argv []string) (answerer, error) { return a, nil } -// post answers on the thread and echoes the same text, so a run driven from a -// terminal still shows it where the operator is looking. Under a dry run the -// post is recorded rather than made, like every other GitHub write. func (a answerer) post(body string, stdout io.Writer) error { fmt.Fprintln(stdout, body) if !a.ok { @@ -453,8 +369,6 @@ func (a answerer) post(body string, stdout io.Writer) error { return a.forge.Comment(ctx, a.ref.Repo, a.ref.Number, body) } -// dryRunRequested reads the flag without owning it. run and address define the -// real flag set; this only needs to know whether to record instead of post. func dryRunRequested(argv []string) bool { if os.Getenv("AIXGO_DRY_RUN") != "" { return true @@ -468,7 +382,6 @@ func dryRunRequested(argv []string) bool { return false } -// reply answers on whatever thread the arguments point at. func reply(argv []string, body string, stdout io.Writer) error { a, err := newAnswerer(argv) if err != nil { @@ -477,27 +390,10 @@ func reply(argv []string, body string, stdout io.Writer) error { return a.post(body, stdout) } -// preflightCmd validates the repo config and engine settings, then exits. A -// workflow runs it before installing the rest of the toolchain, so a -// misconfigured repository finds out in seconds and the rules live in one place. -// checkMentionAgreement fails when the configured handle and the caller -// workflow's trigger disagree. -// -// The handle necessarily lives in two files: the parser reads it from config, -// which the App can push, while the trigger has to be a literal in the workflow, -// which the App deliberately cannot. Drift between them is silent in the worst -// way — comments simply stop working, with no error anywhere — so it is checked -// on every run rather than left to be discovered. func checkMentionAgreement(repoDir, appName string) error { - // Find the caller by what it does, not by what it is called. init writes - // .github/workflows/aixgo.yml, but an adopter can rename it, and in - // this repository that name belongs to the reusable workflow itself. A check - // keyed on the filename would pass on the wrong file, or fail on a valid - // install, which is worse than not checking. dir := filepath.Join(repoDir, ".github", "workflows") entries, err := os.ReadDir(dir) if err != nil { - // No workflows at all is a valid local setup, not a misconfiguration. return nil } want := "'" + command.MentionFor(appName) + "'" @@ -543,9 +439,6 @@ func preflightCmd(argv []string, stdout io.Writer) error { if err := checkMentionAgreement(*repoDir, cfg.AppName); err != nil { return err } - // The App credentials are only reachable when the caller workflow exports - // them. A local run authenticates as the operator and never uses them, so - // checking them there would fail a working setup. if *actions { for _, v := range []struct { name string @@ -563,23 +456,15 @@ func preflightCmd(argv []string, stdout io.Writer) error { return nil } -// commonFlags are the flags shared by run and address, plus the resolved config -// and dependency graph both commands need. type commonFlags struct { repoDir string base string actor string - // dry is set only on a dry run, and holds the writes that were skipped. - dry *dryrun.Forge - cfg *config.Config - deps app.Deps + dry *dryrun.Forge + cfg *config.Config + deps app.Deps } -// parseInterleaved parses argv with fs, allowing flags before or after -// positional arguments. Stdlib flag parsing stops at the first positional, so -// `run owner/repo#1 --repo-dir /x` would silently ignore the flag while the -// usage text advertises exactly that form. Positionals are collected in order -// and parsing resumes after each one, so both orders (and a mix) are honored. func parseInterleaved(fs *flag.FlagSet, argv []string) ([]string, error) { var positionals []string for { @@ -595,15 +480,6 @@ func parseInterleaved(fs *flag.FlagSet, argv []string) ([]string, error) { } } -// prepare parses the shared flags, loads the repo config, validates the engine -// environment, writes the codex config, and builds the dependency graph. It -// returns the remaining positional arguments so each command can parse its own -// reference (an issue for run, a pull request for address). -// isBotLogin reports whether a login belongs to a GitHub App installation. -// The Actions runtime always authenticates as an App, and that App deliberately -// holds no workflows permission, so any bot identity is restricted. An empty -// login means a local run under a human's own credential, which can push -// workflow files and must not be blocked. func isBotLogin(login string) bool { return strings.HasSuffix(login, "[bot]") } @@ -649,9 +525,6 @@ func prepare(name string, argv []string) (*commonFlags, []string, error) { forge := &forgegh.Forge{StateLabels: app.StateLabels(prefix), Self: os.Getenv("AIXGO_GH_APP_LOGIN")} dry := *dryRun || os.Getenv("AIXGO_DRY_RUN") != "" - // When the caller did not name the identity, ask the credential who it is. - // The workflow used to do this with a gh graphql call and hand the answer - // back in an environment variable; the product can just look. if forge.Self == "" { if login, err := forge.Whoami(context.Background()); err == nil { forge.Self = login @@ -668,9 +541,7 @@ func prepare(name string, argv []string) (*commonFlags, []string, error) { } deps := app.Deps{ - 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. + Runner: newRunner(cfg, codexHome, *model), Forge: forgeForLoop, VCS: vcs, Worktrees: &worktree.Manager{RepoDir: *repoDir, BaseDir: filepath.Join(*stateDir, "worktrees")}, @@ -680,9 +551,6 @@ func prepare(name string, argv []string) (*commonFlags, []string, error) { return &commonFlags{repoDir: *repoDir, base: *base, actor: *actor, cfg: cfg, deps: deps, dry: dryForge}, rest, nil } -// reportDryRun prints the writes a dry run skipped, and appends them to the -// GitHub Actions run summary when there is one, so the result is readable on -// the run page rather than buried in step logs. func reportDryRun(c *commonFlags, w io.Writer) { if c == nil || c.dry == nil { return @@ -769,116 +637,6 @@ func addressCmd(argv []string) error { return nil } -func initCmd(argv []string, stdout io.Writer) error { - fs := flag.NewFlagSet("init", flag.ContinueOnError) - repoDir := fs.String("repo-dir", ".", "path to the target repo checkout") - writeWorkflow := fs.Bool("workflow", false, "also write the Actions caller workflow") - appNameFlag := fs.String("app-name", "", "the GitHub App you installed, for example acme-code; comment commands address it") - if _, err := parseInterleaved(fs, argv); err != nil { - return err - } - - appName, err := resolveAppName(*repoDir, *appNameFlag) - if err != nil { - return err - } - - configPath := filepath.Join(*repoDir, ".github", "aixgo.yml") - wroteConfig, err := writeStarterConfig(configPath, appName) - if err != nil { - return err - } - workflowPath := filepath.Join(*repoDir, ".github", "workflows", "aixgo.yml") - selftestPath := filepath.Join(*repoDir, ".github", "workflows", "aixgo-selftest.yml") - wroteWorkflow := false - wroteSelftest := false - if *writeWorkflow { - wroteWorkflow, err = writeStarterFile(workflowPath, renderCallerWorkflow(buildinfo.Version, appName)) - if err != nil { - return err - } - // The self-test answers questions only a real runner can answer. It is - // written alongside the caller so a bad install fails in a minute here - // rather than silently during a real run, and the next steps tell the - // operator to remove it once it has done its job. - wroteSelftest, err = writeStarterFile(selftestPath, selftestWorkflowTemplate) - if err != nil { - return err - } - } - - labels := app.StateLabels(config.DefaultLabelPrefix) - created, err := (&forgegh.Forge{Dir: *repoDir}).EnsureLabels(context.Background(), labels) - if err != nil { - return err - } - - if wroteConfig { - fmt.Fprintf(stdout, "wrote %s\n", configPath) - } else { - fmt.Fprintf(stdout, "left existing %s unchanged\n", configPath) - } - if *writeWorkflow { - if wroteWorkflow { - fmt.Fprintf(stdout, "wrote %s\n", workflowPath) - } else { - fmt.Fprintf(stdout, "left existing %s unchanged\n", workflowPath) - } - if wroteSelftest { - fmt.Fprintf(stdout, "wrote %s\n", selftestPath) - } else { - fmt.Fprintf(stdout, "left existing %s unchanged\n", selftestPath) - } - } - if len(created) == 0 { - fmt.Fprintln(stdout, "labels already present: no changes") - } else { - fmt.Fprintf(stdout, "created labels: %s\n", strings.Join(created, ", ")) - } - createURL, resolved := appCreateURL(*repoDir) - fmt.Fprintln(stdout, "next steps:") - fmt.Fprintln(stdout, " 1. Create your OWN GitHub App. It has to be yours: its private key is what mints") - fmt.Fprintln(stdout, " the tokens that act on your repository, so a shared key would let its holder act") - fmt.Fprintln(stdout, " on every other installation. This is what keeps the agent inside your GitHub.") - fmt.Fprintln(stdout, " "+createURL) - if !resolved { - fmt.Fprintln(stdout, " (if this repository belongs to an organisation, use") - fmt.Fprintln(stdout, " https://github.com/organizations//settings/apps/new instead)") - } - fmt.Fprintln(stdout, " Name it anything you like; App names are globally unique, so you cannot reuse ours.") - fmt.Fprintln(stdout, " Permissions, and nothing else: Contents: Read and write") - fmt.Fprintln(stdout, " Issues: Read and write") - fmt.Fprintln(stdout, " Pull requests: Read and write") - fmt.Fprintln(stdout, " Uncheck Active under Webhook. Actions triggers this runtime; an enabled webhook") - fmt.Fprintln(stdout, " with nothing listening only generates failures.") - fmt.Fprintln(stdout, " Set Any account under Where can this GitHub App be installed, if the App belongs") - fmt.Fprintln(stdout, " to your personal account and the repository belongs to an organisation. An App") - fmt.Fprintln(stdout, " restricted to its owner cannot be installed anywhere else, and this is the step") - fmt.Fprintln(stdout, " most often missed.") - fmt.Fprintln(stdout, " 2. Generate a private key on the App settings page and keep the download. GitHub") - fmt.Fprintln(stdout, " shows it once. Note the Client ID there too, the Iv23 string.") - fmt.Fprintln(stdout, " 3. Install the App on this repository, from Install App on the same page.") - fmt.Fprintln(stdout, " Reference: https://docs.github.com/apps/creating-github-apps") - fmt.Fprintln(stdout, " - write the real gate in .github/aixgo.yml") - fmt.Fprintln(stdout, " - verify that gate is green on your main branch") - fmt.Fprintln(stdout, " - add two repository VARIABLES, under Settings > Secrets and variables > Actions > Variables:") - fmt.Fprintln(stdout, " AIXGO_GH_APP_CLIENT_ID the App Client ID, the Iv23 string on the App settings page") - fmt.Fprintln(stdout, " AIXGO_AZURE_OPENAI_ENDPOINT e.g. https://.openai.azure.com") - fmt.Fprintln(stdout, " - add two repository SECRETS, on the Secrets tab of that same page:") - fmt.Fprintln(stdout, " AIXGO_GH_APP_PRIVATE_KEY the full PEM, including the BEGIN and END lines") - fmt.Fprintln(stdout, " AIXGO_AZURE_OPENAI_API_KEY the Azure OpenAI key") - fmt.Fprintln(stdout, " Variables and Secrets are different tabs. A value filed under the wrong one reads back") - fmt.Fprintln(stdout, " as empty, and the run fails without saying why.") - fmt.Fprintln(stdout, " - merge the PR containing the config and workflow changes") - fmt.Fprintln(stdout, " - run the self-test once: gh workflow run aixgo-selftest") - fmt.Fprintln(stdout, " - delete .github/workflows/aixgo-selftest.yml once it passes") - fmt.Fprintln(stdout, " - file an issue and apply the ax:go label") - return nil -} - -// appCreateURL returns the GitHub form for creating an App, which differs by -// account type. Best effort: init must still work when gh cannot answer, so a -// failure returns the personal form and a note rather than an error. func appCreateURL(repoDir string) (string, bool) { out, err := exec.Command("gh", "repo", "view", "--json", "owner", "--jq", ".owner.login").Output() _ = repoDir @@ -899,49 +657,6 @@ func appCreateURL(repoDir string) (string, bool) { return "https://github.com/settings/apps/new", true } -func writeStarterConfig(path, appName string) (bool, error) { - return writeStarterFile(path, strings.ReplaceAll(starterConfig, callerWorkflowAppNameToken, appName)) -} - -// resolveAppName decides the handle to write into both files. An explicit -// --app-name wins; otherwise an existing config keeps what it already says, so -// re-running init to pick up a new release does not silently change the handle -// a team already types. -func resolveAppName(repoDir, flagValue string) (string, error) { - if v := strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(flagValue), "@"), "[bot]"); v != "" { - return v, nil - } - if cfg, err := config.Load(filepath.Join(repoDir, ".github", "aixgo.yml")); err == nil && cfg.AppName != "" { - return cfg.AppName, nil - } - return "", errors.New("--app-name is required: comment commands address the App you installed, and its name is unique to you. Pass the App's name without the \"[bot]\" suffix, for example --app-name acme-code") -} - -func writeStarterFile(path, body string) (bool, error) { - if _, err := os.Stat(path); err == nil { - return false, nil - } else if !os.IsNotExist(err) { - return false, err - } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return false, err - } - if err := os.WriteFile(path, []byte(body), 0o644); err != nil { - return false, err - } - return true, nil -} - -// renderCallerWorkflow writes the trigger for the App this repository installed. -// The handle cannot be a constant: App names are globally unique, so every -// adopter's bot has its own, and the trigger has to match theirs or no comment -// ever starts a run. It is templated here rather than matched loosely at -// runtime so a mention of a colleague does not spin up a runner. -func renderCallerWorkflow(version, appName string) string { - out := strings.ReplaceAll(callerWorkflowTemplate, callerWorkflowTagToken, workflowTemplateTag(version)) - return strings.ReplaceAll(out, callerWorkflowAppNameToken, appName) -} - func workflowTemplateTag(version string) string { version = strings.TrimPrefix(strings.TrimSpace(version), "v") if isReleaseVersion(version) { @@ -968,7 +683,6 @@ func isReleaseVersion(version string) bool { return true } -// fetchIssue fills the issue title and body from GitHub via gh. func fetchIssue(iss *domain.Issue) error { out, err := exec.Command("gh", "issue", "view", strconv.Itoa(iss.Number), "--repo", iss.Repo, "--json", "title,body").Output() diff --git a/cmd/aixgo-code/main_test.go b/cmd/aixgo-code/main_test.go index b46776d..7d6916d 100644 --- a/cmd/aixgo-code/main_test.go +++ b/cmd/aixgo-code/main_test.go @@ -89,7 +89,7 @@ exit 0 t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) var out bytes.Buffer - if err := initCmd([]string{"--repo-dir", repoDir, "--app-name", "acme-code"}, &out); err != nil { + if err := initCmd([]string{"--repo-dir", repoDir, "--app-name", "acme-code", "--engine", "codex"}, &out); err != nil { t.Fatalf("initCmd: %v", err) } @@ -145,10 +145,10 @@ exit 0 // Naming the section is the point: two of the four go under Variables // and two under Secrets, and a value filed on the wrong tab reads back // empty rather than erroring. - "add two repository VARIABLES", + "add repository VARIABLES", "AIXGO_GH_APP_CLIENT_ID the App Client ID, the Iv23 string on the App settings page", "AIXGO_AZURE_OPENAI_ENDPOINT e.g. https://.openai.azure.com", - "add two repository SECRETS", + "add repository SECRETS", "AIXGO_GH_APP_PRIVATE_KEY the full PEM, including the BEGIN and END lines", "AIXGO_AZURE_OPENAI_API_KEY the Azure OpenAI key", "Variables and Secrets are different tabs", @@ -193,7 +193,7 @@ exit 0 t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) var out bytes.Buffer - if err := initCmd([]string{"--repo-dir", repoDir, "--app-name", "acme-code"}, &out); err != nil { + if err := initCmd([]string{"--repo-dir", repoDir, "--app-name", "acme-code", "--engine", "codex"}, &out); err != nil { t.Fatalf("initCmd: %v", err) } @@ -247,7 +247,7 @@ exit 0 buildVersionForTest(t, "0.1.0") var out bytes.Buffer - if err := initCmd([]string{"--repo-dir", repoDir, "--workflow", "--app-name", "acme-code"}, &out); err != nil { + if err := initCmd([]string{"--repo-dir", repoDir, "--workflow", "--app-name", "acme-code", "--engine", "codex"}, &out); err != nil { t.Fatalf("initCmd: %v", err) } @@ -311,7 +311,7 @@ exit 0 t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) var out bytes.Buffer - if err := initCmd([]string{"--repo-dir", repoDir, "--app-name", "acme-code"}, &out); err != nil { + if err := initCmd([]string{"--repo-dir", repoDir, "--app-name", "acme-code", "--engine", "codex"}, &out); err != nil { t.Fatalf("initCmd: %v", err) } @@ -360,7 +360,7 @@ exit 0 t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) var out bytes.Buffer - if err := initCmd([]string{"--repo-dir", repoDir, "--workflow", "--app-name", "acme-code"}, &out); err != nil { + if err := initCmd([]string{"--repo-dir", repoDir, "--workflow", "--app-name", "acme-code", "--engine", "codex"}, &out); err != nil { t.Fatalf("initCmd: %v", err) } @@ -411,7 +411,7 @@ func TestCallerWorkflowTemplateMatchesDocsTemplate(t *testing.T) { // Render at the latest known tag with the sample handle the docs template // shows: a release bump or a trigger change that misses either copy fails // here. - got := []byte(renderCallerWorkflow(latestKnownWorkflowTag, "your-app-name")) + got := []byte(renderCallerWorkflow(latestKnownWorkflowTag, "your-app-name", "codex")) if !bytes.Equal(got, want) { t.Fatalf("rendered embedded template does not match docs template\nrendered:\n%s\n\ndocs:\n%s", got, want) } @@ -860,7 +860,7 @@ func TestInitWithWorkflowIsIdempotent(t *testing.T) { t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) var first bytes.Buffer - if err := initCmd([]string{"--repo-dir", repoDir, "--workflow", "--app-name", "acme-code"}, &first); err != nil { + if err := initCmd([]string{"--repo-dir", repoDir, "--workflow", "--app-name", "acme-code", "--engine", "codex"}, &first); err != nil { t.Fatalf("first init: %v", err) } selftest := filepath.Join(repoDir, ".github", "workflows", "aixgo-selftest.yml") @@ -869,7 +869,7 @@ func TestInitWithWorkflowIsIdempotent(t *testing.T) { } var second bytes.Buffer - if err := initCmd([]string{"--repo-dir", repoDir, "--workflow", "--app-name", "acme-code"}, &second); err != nil { + if err := initCmd([]string{"--repo-dir", repoDir, "--workflow", "--app-name", "acme-code", "--engine", "codex"}, &second); err != nil { t.Fatalf("second init: %v", err) } if !strings.Contains(second.String(), "left existing "+selftest+" unchanged") { diff --git a/docs/setup.md b/docs/setup.md index e526b76..cd5792b 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -44,8 +44,18 @@ With `v0.5.0`, `aixgo-code version` prints `0.5.0`. ```sh aixgo-code init --workflow --app-name +# or non-interactive / CI: +aixgo-code init --workflow --app-name --engine gemini ``` +On a TTY, init shows a short menu (1 Codex/Azure, 2 Gemini/Vertex, 3 Claude). +Pass `--engine codex|gemini|claude` to skip it. Without a TTY and without +`--engine`, it defaults to `codex` and prints one line saying so, so existing +scripts keep working. The starter `.github/aixgo.yml` gets `engine: `, +and the caller workflow only wires that engine's credentials (not a +multi-provider starter that always shows Azure). Existing starter files are +left unchanged; pass `--force` to overwrite them when switching providers. + `--app-name` is the GitHub App you created in step 4, without the `[bot]` suffix. **It is required, and it is yours, not ours.** App names are globally unique, so every installation has a different one, and it is what your team @@ -134,12 +144,17 @@ App on the repository from `Install App`. Then add these repository settings. **Variables and Secrets are different tabs** under Settings > Secrets and variables > Actions, and a value filed under the -wrong one reads back as empty rather than failing: +wrong one reads back as empty rather than failing. Always set: - Variable: `AIXGO_GH_APP_CLIENT_ID`, the App Client ID, the `Iv23` string on the App settings page - Secret: `AIXGO_GH_APP_PRIVATE_KEY` -- Variable: `AIXGO_AZURE_OPENAI_ENDPOINT` -- Secret: `AIXGO_AZURE_OPENAI_API_KEY` + +Plus the credentials for the engine `init` chose (it prints them in next steps): + +- **Codex:** variable `AIXGO_AZURE_OPENAI_ENDPOINT`, secret `AIXGO_AZURE_OPENAI_API_KEY` +- **Gemini:** variables `AIXGO_VERTEX_PROJECT` and optional `AIXGO_VERTEX_LOCATION`, secret `AIXGO_VERTEX_API_KEY` +- **Claude:** local CLI auth today; Actions Anthropic wiring is tracked in + [aixgo-dev/code#147](https://github.com/aixgo-dev/code/issues/147) The Actions runtime authenticates as your App, so the Client ID and private key are both required. Store the private key as the full PEM contents, including the @@ -304,27 +319,27 @@ aixgo-code run owner/repo#123 --repo-dir . ### GitHub Actions -For the hosted-in-your-GitHub path, the caller workflow in your repository -passes: - -- `vars.AIXGO_AZURE_OPENAI_ENDPOINT` to the reusable workflow input - `azure-openai-endpoint` -- `secrets.AIXGO_AZURE_OPENAI_API_KEY` to the reusable workflow secret - `azure-openai-api-key` -- `vars.AIXGO_GH_APP_CLIENT_ID` to the reusable workflow input - `github-app-client-id` -- `secrets.AIXGO_GH_APP_PRIVATE_KEY` to the reusable workflow secret - `github-app-private-key` - -The reusable workflow installs the CLI, exports the endpoint and key for the -job, and runs `aixgo-code run` or `aixgo-code address`. - -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. +`aixgo-code init --workflow` writes a **conditional** caller: only the chosen +engine's inputs and secrets appear. Always included: + +- `vars.AIXGO_GH_APP_CLIENT_ID` → `github-app-client-id` +- `secrets.AIXGO_GH_APP_PRIVATE_KEY` → `github-app-private-key` + +Plus, depending on `--engine` / the menu choice: + +- **Codex:** `azure-openai-endpoint` / `azure-openai-api-key` (Azure vars/secrets) +- **Gemini:** `vertex-project`, `vertex-location` / `vertex-api-key` +- **Claude:** App credentials only, with a comment that Actions Claude install + is not wired yet ([#147](https://github.com/aixgo-dev/code/issues/147)); use + the local CLI for Claude until then + +The reusable workflow (`.github/workflows/aixgo.yml` in this repository) stays +multi-engine with gated installs: Codex when `azure-openai-endpoint` is set, +Gemini when `vertex-project` is set. The adopter caller template from `init` is +what becomes single-engine. + +The hosted path follows `.github/aixgo.yml` `engine:`. Codex needs Azure; +`engine: gemini` needs Vertex; Claude remains a local-CLI path until #147. ## Watching it run before it writes diff --git a/docs/templates/aixgo-caller.yml b/docs/templates/aixgo-caller.yml index 6237025..ad9fd7a 100644 --- a/docs/templates/aixgo-caller.yml +++ b/docs/templates/aixgo-caller.yml @@ -1,3 +1,7 @@ +# Caller workflow written by `aixgo-code init --workflow`. +# Credentials match the engine chosen at init (--engine or the interactive menu). +# Re-run with --force --engine to rewrite config and this file for a +# different provider. Existing files are left unchanged without --force. name: aixgo on: @@ -59,6 +63,7 @@ jobs: 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 }} + # model: my-gpt-5-4-deployment secrets: azure-openai-api-key: ${{ secrets.AIXGO_AZURE_OPENAI_API_KEY }} github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }}