Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,15 +198,15 @@ 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

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

Expand Down
10 changes: 5 additions & 5 deletions STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
34 changes: 20 additions & 14 deletions cmd/simplycubed/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
65 changes: 47 additions & 18 deletions cmd/simplycubed/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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") {
Expand All @@ -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)
}
Expand All @@ -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")
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
17 changes: 17 additions & 0 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,19 @@ export AZURE_OPENAI_API_KEY="<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
Expand All @@ -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.
Expand Down