Skip to content

Commit a6bc92f

Browse files
authored
feat(init): select engine via --engine or interactive menu (#148)
* feat(init): select engine via --engine or interactive menu init writes engine: into .github/aixgo.yml and a conditional caller that only wires Codex/Azure, Gemini/Vertex, or Claude (App-only + #147 note). Non-TTY defaults to codex; --force overwrites starters to switch providers. Part 1/2: docs and caller template. * feat(init): sync caller embed template for conditional engines * docs(readme): init engine select and conditional caller * docs(setup): init chooses engine; conditional caller * test: small size probe * feat(init): add init_select.go for engine selection and conditional caller * feat(init): add engine-select tests (gemini is Vertex-only) * style: fix init_select.go import grouping * chore: remove size-probe leftover from MCP push testing * feat(init): slim main.go — engine select lives in init_select.go * feat(init): restore slim main.go for engine select (init_select.go) Unbreaks branch after accidental path-ref stub. Engine select lives in init_select.go; gemini init writes Vertex-only caller. Dogfood aixgo-caller.yml stays Vertex-only. * fix(init): gofmt main.go and update init tests for --engine CI failed on gofmt. Tests now pass --engine codex and expect the engine-specific next-steps wording; renderCallerWorkflow calls take the third engine argument.
1 parent de5a03d commit a6bc92f

8 files changed

Lines changed: 670 additions & 346 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,10 @@ To run the loop inside your own GitHub Actions:
6565
Disable the App webhook: the App is an identity that mints per-job tokens, and there is no Aixgo server to receive deliveries.
6666
Set install visibility to `Any account`.
6767
Install it on the repo.
68-
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.
68+
2. In the adopter repo, run `aixgo-code init --workflow --app-name <your-app>`. 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).
6969
3. Fill in the real `gate:` in `.github/aixgo.yml`.
70-
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`.
71-
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.
70+
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)).
71+
These are per-repository and are never inherited from Aixgo: a reusable workflow runs with the calling repository's own variables and secrets.
7272
The private key secret must be the full PEM contents, including the `-----BEGIN` and `-----END` lines.
7373
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.
7474
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
201201

202202
## Engines
203203

204-
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.
204+
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.
205205

206206
| Engine | Config | Local CLI | GitHub Actions | Credentials |
207207
| --- | --- | --- | --- | --- |

‎cmd/aixgo-code/aixgo-caller.yml.tmpl‎

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# Caller workflow written by `aixgo-code init --workflow`.
2+
# Credentials match the engine chosen at init (--engine or the interactive menu).
3+
# Re-run with --force --engine <name> to rewrite config and this file for a
4+
# different provider. Existing files are left unchanged without --force.
15
name: aixgo
26

37
on:
@@ -17,11 +21,9 @@ jobs:
1721
github-app-client-id: ${{ vars.AIXGO_GH_APP_CLIENT_ID }}
1822
issue-number: ${{ github.event.issue.number }}
1923
actor-login: ${{ github.event.sender.login }}
20-
azure-openai-endpoint: ${{ vars.AIXGO_AZURE_OPENAI_ENDPOINT }}
21-
# model: my-gpt-5-4-deployment
24+
# __AIXGO_ENGINE_INPUTS__
2225
secrets:
23-
azure-openai-api-key: ${{ secrets.AIXGO_AZURE_OPENAI_API_KEY }}
24-
github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }}
26+
# __AIXGO_ENGINE_SECRETS__
2527

2628
address:
2729
if: >-
@@ -33,11 +35,9 @@ jobs:
3335
github-app-client-id: ${{ vars.AIXGO_GH_APP_CLIENT_ID }}
3436
pr-number: ${{ github.event.pull_request.number }}
3537
actor-login: ${{ github.event.review.user.login }}
36-
azure-openai-endpoint: ${{ vars.AIXGO_AZURE_OPENAI_ENDPOINT }}
37-
# model: my-gpt-5-4-deployment
38+
# __AIXGO_ENGINE_INPUTS__
3839
secrets:
39-
azure-openai-api-key: ${{ secrets.AIXGO_AZURE_OPENAI_API_KEY }}
40-
github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }}
40+
# __AIXGO_ENGINE_SECRETS__
4141

4242
comment:
4343
# issue_comment fires for any user on a public repository, so the command
@@ -58,7 +58,6 @@ jobs:
5858
issue-number: ${{ github.event.issue.pull_request == null && github.event.issue.number || '' }}
5959
pr-number: ${{ github.event.issue.pull_request != null && github.event.issue.number || '' }}
6060
actor-login: ${{ github.event.comment.user.login }}
61-
azure-openai-endpoint: ${{ vars.AIXGO_AZURE_OPENAI_ENDPOINT }}
61+
# __AIXGO_ENGINE_INPUTS__
6262
secrets:
63-
azure-openai-api-key: ${{ secrets.AIXGO_AZURE_OPENAI_API_KEY }}
64-
github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }}
63+
# __AIXGO_ENGINE_SECRETS__

‎cmd/aixgo-code/init_select.go‎

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"flag"
7+
"fmt"
8+
"io"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
13+
"github.com/aixgo-dev/code/internal/app"
14+
"github.com/aixgo-dev/code/internal/buildinfo"
15+
"github.com/aixgo-dev/code/internal/config"
16+
forgegh "github.com/aixgo-dev/code/internal/forge/gh"
17+
)
18+
19+
const (
20+
callerWorkflowEngineToken = "__AIXGO_ENGINE__"
21+
callerWorkflowInputsToken = "# __AIXGO_ENGINE_INPUTS__"
22+
callerWorkflowSecretsToken = "# __AIXGO_ENGINE_SECRETS__"
23+
defaultInitEngine = "codex"
24+
)
25+
26+
// initStdinIsTTY reports whether stdin is an interactive terminal. Tests
27+
// override it so non-TTY defaulting and the menu path are both reachable.
28+
var initStdinIsTTY = func() bool {
29+
fi, err := os.Stdin.Stat()
30+
if err != nil {
31+
return false
32+
}
33+
return fi.Mode()&os.ModeCharDevice != 0
34+
}
35+
36+
// initPromptIn is the reader for the interactive engine menu. Tests swap it.
37+
var initPromptIn io.Reader = os.Stdin
38+
39+
func initCmd(argv []string, stdout io.Writer) error {
40+
fs := flag.NewFlagSet("init", flag.ContinueOnError)
41+
repoDir := fs.String("repo-dir", ".", "path to the target repo checkout")
42+
writeWorkflow := fs.Bool("workflow", false, "also write the Actions caller workflow")
43+
appNameFlag := fs.String("app-name", "", "the GitHub App you installed, for example acme-code; comment commands address it")
44+
engineFlag := fs.String("engine", "", "model engine: codex (Azure), gemini (Vertex), or claude")
45+
force := fs.Bool("force", false, "overwrite existing starter config and workflow files")
46+
if _, err := parseInterleaved(fs, argv); err != nil {
47+
return err
48+
}
49+
50+
engine, err := resolveEngine(*engineFlag, initStdinIsTTY(), initPromptIn, stdout)
51+
if err != nil {
52+
return err
53+
}
54+
55+
appName, err := resolveAppName(*repoDir, *appNameFlag)
56+
if err != nil {
57+
return err
58+
}
59+
60+
configPath := filepath.Join(*repoDir, ".github", "aixgo.yml")
61+
wroteConfig, err := writeStarterConfig(configPath, appName, engine, *force)
62+
if err != nil {
63+
return err
64+
}
65+
workflowPath := filepath.Join(*repoDir, ".github", "workflows", "aixgo.yml")
66+
selftestPath := filepath.Join(*repoDir, ".github", "workflows", "aixgo-selftest.yml")
67+
wroteWorkflow := false
68+
wroteSelftest := false
69+
if *writeWorkflow {
70+
wroteWorkflow, err = writeStarterFile(workflowPath, renderCallerWorkflow(buildinfo.Version, appName, engine), *force)
71+
if err != nil {
72+
return err
73+
}
74+
wroteSelftest, err = writeStarterFile(selftestPath, selftestWorkflowTemplate, *force)
75+
if err != nil {
76+
return err
77+
}
78+
}
79+
80+
labels := app.StateLabels(config.DefaultLabelPrefix)
81+
created, err := (&forgegh.Forge{Dir: *repoDir}).EnsureLabels(context.Background(), labels)
82+
if err != nil {
83+
return err
84+
}
85+
86+
if wroteConfig {
87+
fmt.Fprintf(stdout, "wrote %s\n", configPath)
88+
} else {
89+
fmt.Fprintf(stdout, "left existing %s unchanged\n", configPath)
90+
}
91+
if *writeWorkflow {
92+
if wroteWorkflow {
93+
fmt.Fprintf(stdout, "wrote %s\n", workflowPath)
94+
} else {
95+
fmt.Fprintf(stdout, "left existing %s unchanged\n", workflowPath)
96+
}
97+
if wroteSelftest {
98+
fmt.Fprintf(stdout, "wrote %s\n", selftestPath)
99+
} else {
100+
fmt.Fprintf(stdout, "left existing %s unchanged\n", selftestPath)
101+
}
102+
}
103+
if len(created) == 0 {
104+
fmt.Fprintln(stdout, "labels already present: no changes")
105+
} else {
106+
fmt.Fprintf(stdout, "created labels: %s\n", strings.Join(created, ", "))
107+
}
108+
printInitNextSteps(stdout, *repoDir, engine)
109+
return nil
110+
}
111+
112+
// resolveEngine picks the model engine for starter files. An explicit --engine
113+
// wins; otherwise a TTY gets a short menu; otherwise codex is the default so
114+
// existing non-interactive scripts keep working.
115+
func resolveEngine(flagValue string, interactive bool, in io.Reader, out io.Writer) (string, error) {
116+
if strings.TrimSpace(flagValue) != "" {
117+
return normalizeEngine(flagValue)
118+
}
119+
if interactive {
120+
return promptEngine(in, out)
121+
}
122+
fmt.Fprintf(out, "using engine: %s (default; pass --engine or run interactively to choose)\n", defaultInitEngine)
123+
return defaultInitEngine, nil
124+
}
125+
126+
func normalizeEngine(v string) (string, error) {
127+
switch e := strings.ToLower(strings.TrimSpace(v)); e {
128+
case "codex", "claude", "gemini":
129+
return e, nil
130+
default:
131+
return "", fmt.Errorf("--engine must be codex, claude, or gemini, got %q", v)
132+
}
133+
}
134+
135+
func promptEngine(in io.Reader, out io.Writer) (string, error) {
136+
fmt.Fprintln(out, "Select the model engine:")
137+
fmt.Fprintln(out, " 1) Codex / Azure OpenAI (default)")
138+
fmt.Fprintln(out, " 2) Gemini / Vertex AI")
139+
fmt.Fprintln(out, " 3) Claude (local CLI; Actions support pending)")
140+
fmt.Fprint(out, "Enter choice [1]: ")
141+
buf := make([]byte, 0, 16)
142+
tmp := make([]byte, 1)
143+
for {
144+
n, err := in.Read(tmp)
145+
if n > 0 {
146+
if tmp[0] == '\n' {
147+
break
148+
}
149+
if tmp[0] != '\r' {
150+
buf = append(buf, tmp[0])
151+
}
152+
}
153+
if err != nil {
154+
if len(buf) == 0 && err == io.EOF {
155+
break
156+
}
157+
if err != io.EOF {
158+
return "", err
159+
}
160+
break
161+
}
162+
}
163+
choice := strings.TrimSpace(string(buf))
164+
switch choice {
165+
case "", "1":
166+
return "codex", nil
167+
case "2":
168+
return "gemini", nil
169+
case "3":
170+
return "claude", nil
171+
default:
172+
if e, err := normalizeEngine(choice); err == nil {
173+
return e, nil
174+
}
175+
return "", fmt.Errorf("unknown engine choice %q (use 1, 2, 3, or codex|gemini|claude)", choice)
176+
}
177+
}
178+
179+
func printInitNextSteps(stdout io.Writer, repoDir, engine string) {
180+
createURL, resolved := appCreateURL(repoDir)
181+
fmt.Fprintln(stdout, "next steps:")
182+
fmt.Fprintln(stdout, " 1. Create your OWN GitHub App. It has to be yours: its private key is what mints")
183+
fmt.Fprintln(stdout, " the tokens that act on your repository, so a shared key would let its holder act")
184+
fmt.Fprintln(stdout, " on every other installation. This is what keeps the agent inside your GitHub.")
185+
fmt.Fprintln(stdout, " "+createURL)
186+
if !resolved {
187+
fmt.Fprintln(stdout, " (if this repository belongs to an organisation, use")
188+
fmt.Fprintln(stdout, " https://github.com/organizations/<org>/settings/apps/new instead)")
189+
}
190+
fmt.Fprintln(stdout, " Name it anything you like; App names are globally unique, so you cannot reuse ours.")
191+
fmt.Fprintln(stdout, " Permissions, and nothing else: Contents: Read and write")
192+
fmt.Fprintln(stdout, " Issues: Read and write")
193+
fmt.Fprintln(stdout, " Pull requests: Read and write")
194+
fmt.Fprintln(stdout, " Uncheck Active under Webhook. Actions triggers this runtime; an enabled webhook")
195+
fmt.Fprintln(stdout, " with nothing listening only generates failures.")
196+
fmt.Fprintln(stdout, " Set Any account under Where can this GitHub App be installed, if the App belongs")
197+
fmt.Fprintln(stdout, " to your personal account and the repository belongs to an organisation. An App")
198+
fmt.Fprintln(stdout, " restricted to its owner cannot be installed anywhere else, and this is the step")
199+
fmt.Fprintln(stdout, " most often missed.")
200+
fmt.Fprintln(stdout, " 2. Generate a private key on the App settings page and keep the download. GitHub")
201+
fmt.Fprintln(stdout, " shows it once. Note the Client ID there too, the Iv23 string.")
202+
fmt.Fprintln(stdout, " 3. Install the App on this repository, from Install App on the same page.")
203+
fmt.Fprintln(stdout, " Reference: https://docs.github.com/apps/creating-github-apps")
204+
fmt.Fprintln(stdout, " - write the real gate in .github/aixgo.yml")
205+
fmt.Fprintln(stdout, " - verify that gate is green on your main branch")
206+
printInitCredentialSteps(stdout, engine)
207+
fmt.Fprintln(stdout, " Variables and Secrets are different tabs. A value filed under the wrong one reads back")
208+
fmt.Fprintln(stdout, " as empty, and the run fails without saying why.")
209+
fmt.Fprintln(stdout, " - merge the PR containing the config and workflow changes")
210+
fmt.Fprintln(stdout, " - run the self-test once: gh workflow run aixgo-selftest")
211+
fmt.Fprintln(stdout, " - delete .github/workflows/aixgo-selftest.yml once it passes")
212+
fmt.Fprintln(stdout, " - file an issue and apply the ax:go label")
213+
fmt.Fprintln(stdout, " Existing starter files are left unchanged; pass --force to overwrite them")
214+
fmt.Fprintln(stdout, " (for example to switch engines with --engine).")
215+
}
216+
217+
func printInitCredentialSteps(stdout io.Writer, engine string) {
218+
fmt.Fprintln(stdout, " - add repository VARIABLES, under Settings > Secrets and variables > Actions > Variables:")
219+
fmt.Fprintln(stdout, " AIXGO_GH_APP_CLIENT_ID the App Client ID, the Iv23 string on the App settings page")
220+
switch engine {
221+
case "gemini":
222+
fmt.Fprintln(stdout, " AIXGO_VERTEX_PROJECT Google Cloud project for Vertex AI")
223+
fmt.Fprintln(stdout, " AIXGO_VERTEX_LOCATION optional; default us-central1")
224+
fmt.Fprintln(stdout, " - add repository SECRETS, on the Secrets tab of that same page:")
225+
fmt.Fprintln(stdout, " AIXGO_GH_APP_PRIVATE_KEY the full PEM, including the BEGIN and END lines")
226+
fmt.Fprintln(stdout, " AIXGO_VERTEX_API_KEY Vertex API key or service-account JSON contents")
227+
case "claude":
228+
fmt.Fprintln(stdout, " - add repository SECRETS, on the Secrets tab of that same page:")
229+
fmt.Fprintln(stdout, " AIXGO_GH_APP_PRIVATE_KEY the full PEM, including the BEGIN and END lines")
230+
fmt.Fprintln(stdout, " - Claude locally: use your existing `claude` CLI login; no Azure or Vertex vars.")
231+
fmt.Fprintln(stdout, " - Claude on Actions is not wired yet (aixgo-dev/code#147). The caller has no")
232+
fmt.Fprintln(stdout, " Anthropic secret input until that lands; prefer local `aixgo-code` for Claude today.")
233+
default:
234+
fmt.Fprintln(stdout, " AIXGO_AZURE_OPENAI_ENDPOINT e.g. https://<resource>.openai.azure.com")
235+
fmt.Fprintln(stdout, " - add repository SECRETS, on the Secrets tab of that same page:")
236+
fmt.Fprintln(stdout, " AIXGO_GH_APP_PRIVATE_KEY the full PEM, including the BEGIN and END lines")
237+
fmt.Fprintln(stdout, " AIXGO_AZURE_OPENAI_API_KEY the Azure OpenAI key")
238+
}
239+
}
240+
241+
func writeStarterConfig(path, appName, engine string, force bool) (bool, error) {
242+
body := strings.ReplaceAll(starterConfig, callerWorkflowAppNameToken, appName)
243+
body = strings.ReplaceAll(body, callerWorkflowEngineToken, engine)
244+
return writeStarterFile(path, body, force)
245+
}
246+
247+
// resolveAppName decides the handle to write into both files. An explicit
248+
// --app-name wins; otherwise an existing config keeps what it already says, so
249+
// re-running init to pick up a new release does not silently change the handle
250+
// a team already types.
251+
func resolveAppName(repoDir, flagValue string) (string, error) {
252+
if v := strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(flagValue), "@"), "[bot]"); v != "" {
253+
return v, nil
254+
}
255+
if cfg, err := config.Load(filepath.Join(repoDir, ".github", "aixgo.yml")); err == nil && cfg.AppName != "" {
256+
return cfg.AppName, nil
257+
}
258+
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")
259+
}
260+
261+
func writeStarterFile(path, body string, force bool) (bool, error) {
262+
if !force {
263+
if _, err := os.Stat(path); err == nil {
264+
return false, nil
265+
} else if !os.IsNotExist(err) {
266+
return false, err
267+
}
268+
}
269+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
270+
return false, err
271+
}
272+
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
273+
return false, err
274+
}
275+
return true, nil
276+
}
277+
278+
// renderCallerWorkflow writes the trigger for the App this repository installed
279+
// and only the credentials for the chosen engine. The handle cannot be a
280+
// constant: App names are globally unique, so every adopter's bot has its own,
281+
// and the trigger has to match theirs or no comment ever starts a run. It is
282+
// templated here rather than matched loosely at runtime so a mention of a
283+
// colleague does not spin up a runner.
284+
func renderCallerWorkflow(version, appName, engine string) string {
285+
inputs, secrets := callerEngineBlocks(engine)
286+
out := strings.ReplaceAll(callerWorkflowTemplate, callerWorkflowTagToken, workflowTemplateTag(version))
287+
out = strings.ReplaceAll(out, callerWorkflowAppNameToken, appName)
288+
out = strings.ReplaceAll(out, callerWorkflowInputsToken, inputs)
289+
out = strings.ReplaceAll(out, callerWorkflowSecretsToken, secrets)
290+
return out
291+
}
292+
293+
func callerEngineBlocks(engine string) (inputs, secrets string) {
294+
switch engine {
295+
case "gemini":
296+
inputs = " vertex-project: ${{ vars.AIXGO_VERTEX_PROJECT }}\n" +
297+
" vertex-location: ${{ vars.AIXGO_VERTEX_LOCATION }}\n" +
298+
" # model: gemini-2.5-pro"
299+
secrets = " vertex-api-key: ${{ secrets.AIXGO_VERTEX_API_KEY }}\n" +
300+
" github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }}"
301+
case "claude":
302+
inputs = " # Claude on Actions is not wired yet (aixgo-dev/code#147):\n" +
303+
" # the reusable workflow does not install the Claude CLI or accept\n" +
304+
" # an Anthropic secret. Local `aixgo-code` with engine: claude uses\n" +
305+
" # your existing claude CLI login."
306+
secrets = " github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }}"
307+
default:
308+
inputs = " azure-openai-endpoint: ${{ vars.AIXGO_AZURE_OPENAI_ENDPOINT }}\n" +
309+
" # model: my-gpt-5-4-deployment"
310+
secrets = " azure-openai-api-key: ${{ secrets.AIXGO_AZURE_OPENAI_API_KEY }}\n" +
311+
" github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }}"
312+
}
313+
return inputs, secrets
314+
}

0 commit comments

Comments
 (0)