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