diff --git a/README.md b/README.md index a68bac1..c50a548 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ Getting the gate right is where first runs stall: it has to be green on your own The model that writes the code sits behind a pluggable `Runner` interface, so you bring your own provider. -Codex on Azure is the engine you can run today. `engine: claude` selects the Claude Code adapter, which runs headless (`claude -p`) against whatever credentials that CLI is already configured with, but it is not usable yet: the CLI still demands Azure credentials whatever engine you pick, and the reusable workflow installs only the Codex CLI, so a GitHub Actions run cannot use it at all. [#95](https://github.com/simplycubed/code/issues/95) tracks closing that. The loop, the roles, and the gate are identical either way; the engine is the only thing that changes. +Codex on Azure is the engine the reusable GitHub Actions workflow can run today. `engine: claude` selects the Claude Code adapter, which runs headless (`claude -p`) against whatever credentials that CLI is already configured with. That path now works for local CLI runs and does not need Azure variables, but the reusable workflow still installs only the Codex CLI and still requires Azure inputs and secrets, so GitHub Actions cannot use Claude yet. The loop, the roles, and the gate are identical either way; the engine is the only thing that changes. ```yaml gate: make check @@ -206,7 +206,7 @@ gate: make check engine: claude ``` -The first engine adapter targets the Codex CLI running against Azure OpenAI. Today the shipped setup needs an Azure endpoint, an API key, and optionally a deployment name override if you are not using the default `gpt-5.4`. The Claude Code adapter is written and tested behind `engine: claude`, and is not reachable through the documented install yet. The `Runner` interface is the seam where other engines plug in. +The first engine adapter targets the Codex CLI running against Azure OpenAI. Today the shipped GitHub Actions setup needs an Azure endpoint, an API key, and optionally a deployment name override if you are not using the default `gpt-5.4`. The Claude Code adapter is written and tested behind `engine: claude`, and today is reachable from a local CLI run only. The `Runner` interface is the seam where other engines plug in. ## Status diff --git a/STATUS.md b/STATUS.md index 0f9f210..e0f9825 100644 --- a/STATUS.md +++ b/STATUS.md @@ -79,11 +79,11 @@ via `.github/workflows/check.yml`; the required status check on `main` is the ## What is next -- **Engine roadmap:** Codex on Azure is the engine you can actually run. The - Claude Code adapter is written and `engine: claude` selects it, but nothing - around it has caught up: the CLI demands Azure credentials whatever engine you - pick, and the reusable workflow installs only the Codex CLI, so the Actions - path cannot run it at all. See #95. Self-hosted +- **Engine roadmap:** Codex on Azure is the engine the reusable GitHub Actions + workflow can actually run. The Claude Code adapter is written and + `engine: claude` now works from a local CLI run, but the reusable workflow + still installs only the Codex CLI and still requires Azure inputs and + secrets, so the Actions path cannot run Claude yet. See #95. Self-hosted models on Hugging Face are next. Each is another `Runner` implementation; no core rework. diff --git a/cmd/simplycubed/main.go b/cmd/simplycubed/main.go index 785ae78..1d9c1fd 100644 --- a/cmd/simplycubed/main.go +++ b/cmd/simplycubed/main.go @@ -142,11 +142,14 @@ var callerWorkflowTemplate string //go:embed simplycubed-selftest.yml.tmpl var selftestWorkflowTemplate string -// engineEnv validates the engine settings and returns the normalized endpoint. -// It is the single implementation: `prepare` calls it before a run, and the -// `preflight` command calls it so a workflow can fail early without a second -// copy of these rules written in shell. -func engineEnv() (string, error) { +// 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 engineEnv(cfg *config.Config) (string, error) { + if cfg != nil && cfg.Engine == "claude" { + return "", nil + } endpoint := strings.TrimRight(os.Getenv("AZURE_OPENAI_ENDPOINT"), "/") if endpoint == "" { return "", fmt.Errorf("AZURE_OPENAI_ENDPOINT is not set. It is a repository variable on your own repository; a reusable workflow never inherits variables from SimplyCubed") @@ -377,10 +380,11 @@ func preflightCmd(argv []string, stdout io.Writer) error { if _, err := parseInterleaved(fs, argv); err != nil { return err } - if _, err := config.Load(filepath.Join(*repoDir, ".github", "simplycubed.yml")); err != nil { + cfg, err := config.Load(filepath.Join(*repoDir, ".github", "simplycubed.yml")) + if err != nil { return fmt.Errorf("load config: %w", err) } - if _, err := engineEnv(); err != nil { + if _, err := engineEnv(cfg); err != nil { return err } fmt.Fprintln(stdout, "preflight ok: config and engine settings are present") @@ -441,18 +445,20 @@ func prepare(name string, argv []string) (*commonFlags, []string, error) { return nil, nil, fmt.Errorf("load config: %w", err) } - endpoint, err := engineEnv() + endpoint, err := engineEnv(cfg) if err != nil { return nil, nil, err } codexHome := filepath.Join(*stateDir, "codex-home") - if _, err := codex.WriteConfig(codexHome, codex.ProviderConfig{ - Model: *model, - BaseURL: endpoint + "/openai/v1", - EnvKey: "AZURE_OPENAI_API_KEY", - }); err != nil { - return nil, nil, fmt.Errorf("write codex config: %w", err) + if cfg.Engine != "claude" { + if _, err := codex.WriteConfig(codexHome, codex.ProviderConfig{ + Model: *model, + BaseURL: endpoint + "/openai/v1", + EnvKey: "AZURE_OPENAI_API_KEY", + }); err != nil { + return nil, nil, fmt.Errorf("write codex config: %w", err) + } } prefix := cfg.LabelPrefix diff --git a/cmd/simplycubed/main_test.go b/cmd/simplycubed/main_test.go index c865f1e..e8f0a14 100644 --- a/cmd/simplycubed/main_test.go +++ b/cmd/simplycubed/main_test.go @@ -415,7 +415,7 @@ func TestEngineEnvValidatesEndpointAndKey(t *testing.T) { } // A trailing slash is normalized away, because the engine appends a path. set("https://r.openai.azure.com/", "k") - got, err := engineEnv() + got, err := engineEnv(&config.Config{}) if err != nil || got != "https://r.openai.azure.com" { t.Fatalf("engineEnv() = %q, %v", got, err) } @@ -429,31 +429,39 @@ func TestEngineEnvValidatesEndpointAndKey(t *testing.T) { "no host": {"https://", "k"}, } { set(c[0], c[1]) - if _, err := engineEnv(); err == nil { + if _, err := engineEnv(&config.Config{}); err == nil { t.Fatalf("%s: expected an error", name) } } } -func TestPreflightCmd(t *testing.T) { - writeConfig := func(t *testing.T) string { - t.Helper() - dir := t.TempDir() - path := filepath.Join(dir, ".github", "simplycubed.yml") - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte("gate: make check\n"), 0o644); err != nil { - t.Fatal(err) - } - return dir +func TestEngineEnvSkipsAzureForClaude(t *testing.T) { + t.Setenv("AZURE_OPENAI_ENDPOINT", "") + t.Setenv("AZURE_OPENAI_API_KEY", "") + got, err := engineEnv(&config.Config{Engine: "claude"}) + if err != nil || got != "" { + t.Fatalf("engineEnv(claude) = %q, %v", got, err) } +} +func TestPreflightCmd(t *testing.T) { t.Run("reports ok when config and engine settings are present", func(t *testing.T) { t.Setenv("AZURE_OPENAI_ENDPOINT", "https://r.openai.azure.com") t.Setenv("AZURE_OPENAI_API_KEY", "k") var out bytes.Buffer - if err := preflightCmd([]string{"--repo-dir", writeConfig(t)}, &out); err != nil { + if err := preflightCmd([]string{"--repo-dir", repoWithConfigBody(t, "gate: make check\n")}, &out); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out.String(), "preflight ok") { + t.Fatalf("output = %q", out.String()) + } + }) + + t.Run("allows claude with no Azure settings", func(t *testing.T) { + t.Setenv("AZURE_OPENAI_ENDPOINT", "") + t.Setenv("AZURE_OPENAI_API_KEY", "") + var out bytes.Buffer + if err := preflightCmd([]string{"--repo-dir", repoWithConfigBody(t, "gate: make check\nengine: claude\n")}, &out); err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(out.String(), "preflight ok") { @@ -475,7 +483,7 @@ func TestPreflightCmd(t *testing.T) { t.Run("names the missing endpoint", func(t *testing.T) { t.Setenv("AZURE_OPENAI_ENDPOINT", "") t.Setenv("AZURE_OPENAI_API_KEY", "k") - err := preflightCmd([]string{"--repo-dir", writeConfig(t)}, io.Discard) + err := preflightCmd([]string{"--repo-dir", repoWithConfigBody(t, "gate: make check\n")}, io.Discard) if err == nil || !strings.Contains(err.Error(), "AZURE_OPENAI_ENDPOINT") { t.Fatalf("err = %v, want the endpoint named", err) } @@ -493,7 +501,7 @@ func TestEngineEnvRejectsAnUnparseableEndpoint(t *testing.T) { // branch from the scheme and host checks. t.Setenv("AZURE_OPENAI_ENDPOINT", "https://r.openai.azure.com/\x7f") t.Setenv("AZURE_OPENAI_API_KEY", "k") - if _, err := engineEnv(); err == nil { + if _, err := engineEnv(&config.Config{}); err == nil { t.Fatal("expected an error for an unparseable endpoint") } } @@ -573,13 +581,18 @@ func TestDispatch(t *testing.T) { } func repoWithConfig(t *testing.T) string { + t.Helper() + return repoWithConfigBody(t, "gate: make check\nlabelPrefix: sc\n") +} + +func repoWithConfigBody(t *testing.T, body string) string { t.Helper() dir := t.TempDir() path := filepath.Join(dir, ".github", "simplycubed.yml") if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(path, []byte("gate: make check\nlabelPrefix: sc\n"), 0o644); err != nil { + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } return dir @@ -624,6 +637,22 @@ func TestPrepare(t *testing.T) { } }) + t.Run("allows claude with no Azure settings and skips codex config", func(t *testing.T) { + t.Setenv("AZURE_OPENAI_ENDPOINT", "") + t.Setenv("AZURE_OPENAI_API_KEY", "") + stateDir := t.TempDir() + c, _, err := prepare("run", []string{"--repo-dir", repoWithConfigBody(t, "gate: make check\nengine: claude\n"), "--state-dir", stateDir}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := c.deps.Runner.(*claude.Runner); !ok { + t.Fatalf("expected the Claude runner, got %#v", c.deps.Runner) + } + if _, err := os.Stat(filepath.Join(stateDir, "codex-home", "config.toml")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("claude runs must not render a codex config, stat err = %v", err) + } + }) + t.Run("rejects an unknown flag", func(t *testing.T) { if _, _, err := prepare("run", []string{"--nope"}); err == nil { t.Fatal("expected an error for an unknown flag") diff --git a/docs/setup.md b/docs/setup.md index 3c3e235..ea51a83 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -182,6 +182,19 @@ export AZURE_OPENAI_API_KEY="" simplycubed run owner/repo#123 --repo-dir . ``` +To use Claude Code locally instead, set `engine: claude` in +`.github/simplycubed.yml`. That local path uses your existing `claude` CLI +authentication and does not need Azure variables: + +```yaml +gate: make check +engine: claude +``` + +```sh +simplycubed run owner/repo#123 --repo-dir . +``` + ### GitHub Actions For the hosted-in-your-GitHub path, the caller workflow in your repository @@ -199,6 +212,10 @@ passes: The reusable workflow installs the CLI, exports the endpoint and key for the job, and runs `simplycubed run` or `simplycubed address`. +That hosted path is still Codex-on-Azure only. The reusable workflow installs +the Codex CLI, not the Claude CLI, and its inputs and secrets still require the +Azure endpoint and API key. + ## Watching it run before it writes Two commands answer "is this configured correctly" without changing anything.