Skip to content

Commit 18c63ab

Browse files
mariomeyerclaude
andcommitted
feat: add Codex CLI support with Docker Bake build system
Add OpenAI Codex CLI as an alternative agent alongside Claude Code. Users choose their agent at CLI launch time, and the system builds separate Docker images per agent via a stack × agent matrix. - Add docker-bake.hcl with stack × agent matrix (shared base layer) - Split Dockerfile into Dockerfile.base, Dockerfile.claude, Dockerfile.codex - Update entrypoint.sh and ralph-loop.sh to branch on AGENT_CLI env var - Add detectCodexCredentials() to CLI with API key and auth.json support - Add agent selection prompt to CLI flow - Make stack image resolution agent-aware - Update CI workflow to build/publish per agent variant - Update stream-pretty.sh to handle both Claude and Codex JSON formats Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4d8feb0 commit 18c63ab

13 files changed

Lines changed: 338 additions & 58 deletions

File tree

.github/workflows/release.yml

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ jobs:
6767
npm pkg fix
6868
npm publish --access public --provenance
6969
70-
# Build each platform natively per stack (no QEMU emulation)
70+
# Build each platform natively per stack × agent (no QEMU emulation)
7171
build:
7272
needs: [release]
7373
if: |
@@ -80,6 +80,7 @@ jobs:
8080
fail-fast: true
8181
matrix:
8282
stack: [laravel]
83+
agent: [claude, codex]
8384
platform: [linux/amd64, linux/arm64]
8485
include:
8586
- platform: linux/amd64
@@ -101,15 +102,28 @@ jobs:
101102
username: ${{ github.actor }}
102103
password: ${{ secrets.GITHUB_TOKEN }}
103104

104-
- name: Build and push by digest
105+
- name: Build base image
106+
uses: docker/build-push-action@v6
107+
with:
108+
context: stacks/${{ matrix.stack }}
109+
file: stacks/${{ matrix.stack }}/Dockerfile.base
110+
platforms: ${{ matrix.platform }}
111+
load: true
112+
tags: base:local
113+
cache-from: type=gha,scope=${{ matrix.stack }}-base-${{ matrix.platform }}
114+
cache-to: type=gha,scope=${{ matrix.stack }}-base-${{ matrix.platform }},mode=max
115+
116+
- name: Build and push agent image by digest
105117
id: build
106118
uses: docker/build-push-action@v6
107119
with:
108120
context: stacks/${{ matrix.stack }}
121+
file: stacks/${{ matrix.stack }}/Dockerfile.${{ matrix.agent }}
109122
platforms: ${{ matrix.platform }}
110-
outputs: type=image,name=${{ env.REGISTRY }}/reyemtech/${{ matrix.stack }}-upgrade-agent,push-by-digest=true,name-canonical=true,push=true
111-
cache-from: type=gha,scope=${{ matrix.stack }}-${{ matrix.platform }}
112-
cache-to: type=gha,scope=${{ matrix.stack }}-${{ matrix.platform }},mode=max
123+
build-contexts: base=docker-image://base:local
124+
outputs: type=image,name=${{ env.REGISTRY }}/reyemtech/${{ matrix.stack }}-upgrade-agent-${{ matrix.agent }},push-by-digest=true,name-canonical=true,push=true
125+
cache-from: type=gha,scope=${{ matrix.stack }}-${{ matrix.agent }}-${{ matrix.platform }}
126+
cache-to: type=gha,scope=${{ matrix.stack }}-${{ matrix.agent }}-${{ matrix.platform }},mode=max
113127

114128
- name: Export digest
115129
run: |
@@ -120,12 +134,12 @@ jobs:
120134
- name: Upload digest
121135
uses: actions/upload-artifact@v4
122136
with:
123-
name: digests-${{ matrix.stack }}-${{ matrix.runner }}
137+
name: digests-${{ matrix.stack }}-${{ matrix.agent }}-${{ matrix.runner }}
124138
path: /tmp/digests/*
125139
if-no-files-found: error
126140
retention-days: 1
127141

128-
# Merge per-platform images into a single multi-arch manifest per stack
142+
# Merge per-platform images into a single multi-arch manifest per stack × agent
129143
publish:
130144
needs: [release, build]
131145
runs-on: ubuntu-latest
@@ -135,6 +149,7 @@ jobs:
135149
strategy:
136150
matrix:
137151
stack: [laravel]
152+
agent: [claude, codex]
138153
steps:
139154
- uses: actions/checkout@v4
140155
with:
@@ -165,7 +180,7 @@ jobs:
165180
id: meta
166181
uses: docker/metadata-action@v5
167182
with:
168-
images: ${{ env.REGISTRY }}/reyemtech/${{ matrix.stack }}-upgrade-agent
183+
images: ${{ env.REGISTRY }}/reyemtech/${{ matrix.stack }}-upgrade-agent-${{ matrix.agent }}
169184
tags: |
170185
type=raw,value=latest
171186
type=semver,pattern={{version}},value=${{ steps.version.outputs.tag }}
@@ -177,16 +192,16 @@ jobs:
177192
uses: actions/download-artifact@v4
178193
with:
179194
path: /tmp/digests
180-
pattern: digests-${{ matrix.stack }}-*
195+
pattern: digests-${{ matrix.stack }}-${{ matrix.agent }}-*
181196
merge-multiple: true
182197

183198
- name: Create manifest list and push
184199
working-directory: /tmp/digests
185200
run: |
186201
docker buildx imagetools create \
187202
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
188-
$(printf '${{ env.REGISTRY }}/reyemtech/${{ matrix.stack }}-upgrade-agent@sha256:%s ' *)
203+
$(printf '${{ env.REGISTRY }}/reyemtech/${{ matrix.stack }}-upgrade-agent-${{ matrix.agent }}@sha256:%s ' *)
189204
190205
- name: Inspect image
191206
run: |
192-
docker buildx imagetools inspect ${{ env.REGISTRY }}/reyemtech/${{ matrix.stack }}-upgrade-agent:${{ steps.meta.outputs.version }}
207+
docker buildx imagetools inspect ${{ env.REGISTRY }}/reyemtech/${{ matrix.stack }}-upgrade-agent-${{ matrix.agent }}:${{ steps.meta.outputs.version }}

cli/src/credentials.js

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { readFile } from 'node:fs/promises';
1+
import { readFile, access } from 'node:fs/promises';
22
import { execFileSync } from 'node:child_process';
33
import { homedir } from 'node:os';
44
import { join } from 'node:path';
@@ -113,3 +113,75 @@ export async function detectClaudeCredentials({ promptIfMissing = true } = {}) {
113113

114114
return result;
115115
}
116+
117+
/**
118+
* Auto-detect Codex credentials.
119+
* Priority: saved config > env vars > ~/.codex/auth.json > prompt
120+
* @param {{ promptIfMissing?: boolean }} options
121+
* @returns {{ type: 'apikey' | 'oauth', value: string, source: string } | null}
122+
*/
123+
export async function detectCodexCredentials({ promptIfMissing = true } = {}) {
124+
// 1. Saved config (~/.stack-upgrade/config.json)
125+
const saved = getConfig('codexCredentials');
126+
if (saved?.value) {
127+
return {
128+
type: saved.type,
129+
value: saved.value,
130+
source: '~/.stack-upgrade/config.json',
131+
};
132+
}
133+
134+
// 2. OPENAI_API_KEY env var
135+
if (process.env.OPENAI_API_KEY) {
136+
return {
137+
type: 'apikey',
138+
value: process.env.OPENAI_API_KEY,
139+
source: 'OPENAI_API_KEY env var',
140+
};
141+
}
142+
143+
// 3. ~/.codex/auth.json
144+
try {
145+
const authPath = join(homedir(), '.codex', 'auth.json');
146+
await access(authPath);
147+
const raw = await readFile(authPath, 'utf-8');
148+
const b64 = Buffer.from(raw).toString('base64');
149+
return {
150+
type: 'oauth',
151+
value: b64,
152+
source: '~/.codex/auth.json',
153+
};
154+
} catch {
155+
// File doesn't exist — continue
156+
}
157+
158+
// 4. Prompt user (or return null if not allowed)
159+
if (!promptIfMissing) return null;
160+
161+
const method = await p.select({
162+
message: 'Codex credentials not found. How do you want to authenticate?',
163+
options: [
164+
{ value: 'apikey', label: 'Paste OpenAI API key' },
165+
{ value: 'authjson', label: 'Paste auth.json content (base64)' },
166+
],
167+
});
168+
if (p.isCancel(method)) process.exit(0);
169+
170+
let result;
171+
172+
if (method === 'apikey') {
173+
const value = await p.password({ message: 'Paste your OpenAI API key:' });
174+
if (p.isCancel(value)) process.exit(0);
175+
result = { type: 'apikey', value, source: 'manual input' };
176+
} else {
177+
const value = await p.password({ message: 'Paste your auth.json content (base64-encoded):' });
178+
if (p.isCancel(value)) process.exit(0);
179+
result = { type: 'oauth', value, source: 'manual input' };
180+
}
181+
182+
// Save to config for next time
183+
saveConfig({ codexCredentials: { type: result.type, value: result.value } });
184+
p.log.success('Credentials saved to ~/.stack-upgrade/config.json');
185+
186+
return result;
187+
}

cli/src/docker.js

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ function pullImage(image) {
3535
* Launch a single Docker container for an upgrade.
3636
* @returns {{ containerName: string, outputDir: string }}
3737
*/
38-
function startContainer({ repoUrl, targetVersion, push, suffix, ghToken, claudeCreds, image, stack, envKey }) {
38+
function startContainer({ repoUrl, targetVersion, push, suffix, ghToken, agentCreds, agentChoice, image, stack, envKey }) {
3939
const containerName = deriveName(repoUrl, stack, targetVersion, suffix);
4040
const repoShort = repoUrl.replace(/.*[:/]/, '').replace(/\.git$/, '');
4141
const outputDir = resolve(process.cwd(), 'output', repoShort);
@@ -51,10 +51,18 @@ function startContainer({ repoUrl, targetVersion, push, suffix, ghToken, claudeC
5151

5252
if (suffix) env.BRANCH_SUFFIX = suffix;
5353

54-
if (claudeCreds.type === 'oauth') {
55-
env.CLAUDE_CODE_OAUTH_TOKEN = claudeCreds.value;
56-
} else {
57-
env.ANTHROPIC_API_KEY = claudeCreds.value;
54+
if (agentChoice === 'claude') {
55+
if (agentCreds.type === 'oauth') {
56+
env.CLAUDE_CODE_OAUTH_TOKEN = agentCreds.value;
57+
} else {
58+
env.ANTHROPIC_API_KEY = agentCreds.value;
59+
}
60+
} else if (agentChoice === 'codex') {
61+
if (agentCreds.type === 'apikey') {
62+
env.OPENAI_API_KEY = agentCreds.value;
63+
} else {
64+
env.CODEX_AUTH_JSON_B64 = agentCreds.value;
65+
}
5866
}
5967

6068
for (const [key, val] of Object.entries(env)) {

cli/src/index.js

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import * as p from '@clack/prompts';
44
import pc from 'picocolors';
55
import { getGhToken, getGhUser, discoverRepos, selectRepos } from './github.js';
6-
import { detectClaudeCredentials } from './credentials.js';
6+
import { detectClaudeCredentials, detectCodexCredentials } from './credentials.js';
77
import { askRunTarget, askTargetVersion, askPush, askSuffix } from './prompts.js';
88
import { hasDocker, launchDocker } from './docker.js';
99
import { hasKubectl, launchKubernetes } from './kubectl.js';
@@ -20,7 +20,6 @@ async function main() {
2020
let ghToken = getGhToken();
2121
if (!ghToken) ghToken = getConfig('ghToken') || null;
2222
const ghUser = ghToken ? getGhUser() : null;
23-
const claudeAutoDetect = await detectClaudeCredentials({ promptIfMissing: false });
2423

2524
if (ghUser) {
2625
p.log.message(`${pc.green('\u2713')} GitHub CLI authenticated (${ghUser})`);
@@ -30,13 +29,34 @@ async function main() {
3029

3130
preSpinner.stop('Prerequisites checked');
3231

33-
// Claude credentials — prompt after spinner if not auto-detected
34-
let claudeCreds;
35-
if (claudeAutoDetect) {
36-
p.log.message(`${pc.green('\u2713')} Claude credentials found (${claudeAutoDetect.source})`);
37-
claudeCreds = claudeAutoDetect;
32+
// --- Agent selection ---
33+
const agentChoice = await p.select({
34+
message: 'Which AI agent?',
35+
options: [
36+
{ value: 'claude', label: 'Claude Code', hint: 'Anthropic' },
37+
{ value: 'codex', label: 'Codex CLI', hint: 'OpenAI' },
38+
],
39+
});
40+
if (p.isCancel(agentChoice)) process.exit(0);
41+
42+
// Detect credentials for the selected agent
43+
let agentCreds;
44+
if (agentChoice === 'claude') {
45+
const autoDetect = await detectClaudeCredentials({ promptIfMissing: false });
46+
if (autoDetect) {
47+
p.log.message(`${pc.green('\u2713')} Claude credentials found (${autoDetect.source})`);
48+
agentCreds = autoDetect;
49+
} else {
50+
agentCreds = await detectClaudeCredentials({ promptIfMissing: true });
51+
}
3852
} else {
39-
claudeCreds = await detectClaudeCredentials({ promptIfMissing: true });
53+
const autoDetect = await detectCodexCredentials({ promptIfMissing: false });
54+
if (autoDetect) {
55+
p.log.message(`${pc.green('\u2713')} Codex credentials found (${autoDetect.source})`);
56+
agentCreds = autoDetect;
57+
} else {
58+
agentCreds = await detectCodexCredentials({ promptIfMissing: true });
59+
}
4060
}
4161

4262
// Save GH token if we got one from `gh auth`
@@ -104,8 +124,9 @@ async function main() {
104124
push,
105125
suffix: suffix || '',
106126
ghToken,
107-
claudeCreds,
108-
image: stack.image,
127+
agentCreds,
128+
agentChoice,
129+
image: stack.image(agentChoice),
109130
stack: repo.stack,
110131
stackName: stack.name,
111132
envKey: stack.envKey,

cli/src/kubectl.js

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ async function selectNamespace() {
123123
/**
124124
* Ensure the upgrade-agent secret exists in the namespace.
125125
*/
126-
function ensureSecret(namespace, ghToken, claudeCreds) {
126+
function ensureSecret(namespace, ghToken, agentCreds, agentChoice) {
127127
try {
128128
execFileSync('kubectl', ['delete', 'secret', SECRET_NAME, '-n', namespace], { stdio: 'ignore' });
129129
} catch {
@@ -141,14 +141,22 @@ function ensureSecret(namespace, ghToken, claudeCreds) {
141141
args.push(`--from-literal=GH_TOKEN=${ghToken}`);
142142
}
143143

144-
if (claudeCreds.type === 'oauth') {
145-
args.push(`--from-literal=CLAUDE_CODE_OAUTH_TOKEN=${claudeCreds.value}`);
146-
} else {
147-
args.push(`--from-literal=ANTHROPIC_API_KEY=${claudeCreds.value}`);
144+
if (agentChoice === 'claude') {
145+
if (agentCreds.type === 'oauth') {
146+
args.push(`--from-literal=CLAUDE_CODE_OAUTH_TOKEN=${agentCreds.value}`);
147+
} else {
148+
args.push(`--from-literal=ANTHROPIC_API_KEY=${agentCreds.value}`);
149+
}
150+
} else if (agentChoice === 'codex') {
151+
if (agentCreds.type === 'apikey') {
152+
args.push(`--from-literal=OPENAI_API_KEY=${agentCreds.value}`);
153+
} else {
154+
args.push(`--from-literal=CODEX_AUTH_JSON_B64=${agentCreds.value}`);
155+
}
148156
}
149157

150158
execFileSync('kubectl', args, { stdio: 'ignore' });
151-
p.log.success(`Secret created (Claude: ${claudeCreds.type}, GitHub: ${ghToken ? 'yes' : 'no'})`);
159+
p.log.success(`Secret created (${agentChoice}: ${agentCreds.type}, GitHub: ${ghToken ? 'yes' : 'no'})`);
152160
}
153161

154162
/**
@@ -166,7 +174,7 @@ function startPod({ namespace, repoUrl, targetVersion, push, suffix, ghToken, cl
166174
if (suffix) envVars.push({ name: 'BRANCH_SUFFIX', value: suffix });
167175

168176
// Secret-backed env vars
169-
const secretEnvs = ['GH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'];
177+
const secretEnvs = ['GH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'CODEX_AUTH_JSON_B64'];
170178
for (const key of secretEnvs) {
171179
envVars.push({
172180
name: key,
@@ -211,8 +219,8 @@ export async function launchKubernetes(upgrades) {
211219
const namespace = await selectNamespace();
212220

213221
// Ensure secret (uses first upgrade's creds — they're shared)
214-
const { ghToken, claudeCreds } = upgrades[0];
215-
ensureSecret(namespace, ghToken, claudeCreds);
222+
const { ghToken, agentCreds, agentChoice } = upgrades[0];
223+
ensureSecret(namespace, ghToken, agentCreds, agentChoice);
216224

217225
// Launch all pods
218226
const launched = [];

cli/src/stacks.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export const STACKS = {
22
laravel: {
33
name: 'Laravel',
4-
image: 'ghcr.io/reyemtech/laravel-upgrade-agent:latest',
4+
image: (agent) => `ghcr.io/reyemtech/laravel-upgrade-agent-${agent}:latest`,
55
detect: (composer) => composer?.require?.['laravel/framework'],
66
versionLabel: 'Target Laravel version',
77
branchPrefix: 'upgrade/laravel',

docker-bake.hcl

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
variable "REGISTRY" { default = "ghcr.io/reyemtech" }
2+
variable "VERSION" { default = "latest" }
3+
4+
group "default" {
5+
targets = ["upgrade-agent"]
6+
}
7+
8+
target "upgrade-agent" {
9+
name = "${item.stack}-upgrade-agent-${item.agent}"
10+
matrix = {
11+
item = [
12+
{ stack = "laravel", agent = "claude" },
13+
{ stack = "laravel", agent = "codex" },
14+
]
15+
}
16+
context = "stacks/${item.stack}"
17+
dockerfile = "Dockerfile.${item.agent}"
18+
contexts = {
19+
base = "target:base-${item.stack}"
20+
}
21+
tags = [
22+
"${REGISTRY}/${item.stack}-upgrade-agent-${item.agent}:${VERSION}",
23+
"${REGISTRY}/${item.stack}-upgrade-agent-${item.agent}:latest",
24+
]
25+
platforms = ["linux/amd64", "linux/arm64"]
26+
}
27+
28+
target "base-laravel" {
29+
context = "stacks/laravel"
30+
dockerfile = "Dockerfile.base"
31+
tags = [] # Not pushed — used as build dependency only
32+
}

0 commit comments

Comments
 (0)