Skip to content

Commit 2b3c9f9

Browse files
authored
Merge pull request #131 from ProverCoderAI/feat-auto-agent-selection
feat(auto): add agent auto-selection
2 parents 6e7c19c + 572ff1a commit 2b3c9f9

File tree

15 files changed

+510
-64
lines changed

15 files changed

+510
-64
lines changed

packages/api/src/api/contracts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export type CreateProjectRequest = {
4949
readonly gitTokenLabel?: string | undefined
5050
readonly codexTokenLabel?: string | undefined
5151
readonly claudeTokenLabel?: string | undefined
52+
readonly agentAutoMode?: string | undefined
5253
readonly up?: boolean | undefined
5354
readonly openSsh?: boolean | undefined
5455
readonly force?: boolean | undefined

packages/api/src/api/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const CreateProjectRequestSchema = Schema.Struct({
2525
gitTokenLabel: OptionalString,
2626
codexTokenLabel: OptionalString,
2727
claudeTokenLabel: OptionalString,
28+
agentAutoMode: OptionalString,
2829
up: OptionalBoolean,
2930
openSsh: OptionalBoolean,
3031
force: OptionalBoolean,

packages/api/src/services/projects.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ export const createProjectFromRequest = (
199199
...(request.gitTokenLabel === undefined ? {} : { gitTokenLabel: request.gitTokenLabel }),
200200
...(request.codexTokenLabel === undefined ? {} : { codexTokenLabel: request.codexTokenLabel }),
201201
...(request.claudeTokenLabel === undefined ? {} : { claudeTokenLabel: request.claudeTokenLabel }),
202+
...(request.agentAutoMode === undefined ? {} : { agentAutoMode: request.agentAutoMode }),
202203
...(request.up === undefined ? {} : { up: request.up }),
203204
...(request.openSsh === undefined ? {} : { openSsh: request.openSsh }),
204205
...(request.force === undefined ? {} : { force: request.force }),

packages/app/src/docker-git/cli/parser-options.ts

Lines changed: 69 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ interface ValueOptionSpec {
3434
| "outDir"
3535
| "projectDir"
3636
| "lines"
37+
| "agentAutoMode"
3738
}
3839

3940
const valueOptionSpecs: ReadonlyArray<ValueOptionSpec> = [
@@ -67,7 +68,8 @@ const valueOptionSpecs: ReadonlyArray<ValueOptionSpec> = [
6768
{ flag: "-m", key: "message" },
6869
{ flag: "--out-dir", key: "outDir" },
6970
{ flag: "--project-dir", key: "projectDir" },
70-
{ flag: "--lines", key: "lines" }
71+
{ flag: "--lines", key: "lines" },
72+
{ flag: "--auto", key: "agentAutoMode" }
7173
]
7274

7375
const valueOptionSpecByFlag: ReadonlyMap<string, ValueOptionSpec> = new Map(
@@ -89,9 +91,7 @@ const booleanFlagUpdaters: Readonly<Record<string, (raw: RawOptions) => RawOptio
8991
"--no-wipe": (raw) => ({ ...raw, wipe: false }),
9092
"--web": (raw) => ({ ...raw, authWeb: true }),
9193
"--include-default": (raw) => ({ ...raw, includeDefault: true }),
92-
"--claude": (raw) => ({ ...raw, agentClaude: true }),
93-
"--codex": (raw) => ({ ...raw, agentCodex: true }),
94-
"--auto": (raw) => ({ ...raw, agentAuto: true })
94+
"--auto": (raw) => ({ ...raw, agentAutoMode: "auto" })
9595
}
9696

9797
const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: string) => RawOptions } = {
@@ -122,7 +122,8 @@ const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: st
122122
message: (raw, value) => ({ ...raw, message: value }),
123123
outDir: (raw, value) => ({ ...raw, outDir: value }),
124124
projectDir: (raw, value) => ({ ...raw, projectDir: value }),
125-
lines: (raw, value) => ({ ...raw, lines: value })
125+
lines: (raw, value) => ({ ...raw, lines: value }),
126+
agentAutoMode: (raw, value) => ({ ...raw, agentAutoMode: value.trim().toLowerCase() })
126127
}
127128

128129
export const applyCommandBooleanFlag = (raw: RawOptions, token: string): RawOptions | null => {
@@ -162,37 +163,83 @@ const parseInlineValueToken = (
162163
return applyCommandValueFlag(raw, flag, inlineValue)
163164
}
164165

165-
const parseRawOptionsStep = (
166-
args: ReadonlyArray<string>,
167-
index: number,
168-
raw: RawOptions
166+
const legacyAgentFlagError = (token: string): ParseError | null => {
167+
if (token === "--claude") {
168+
return {
169+
_tag: "InvalidOption",
170+
option: token,
171+
reason: "use --auto=claude"
172+
}
173+
}
174+
if (token === "--codex") {
175+
return {
176+
_tag: "InvalidOption",
177+
option: token,
178+
reason: "use --auto=codex"
179+
}
180+
}
181+
return null
182+
}
183+
184+
const toParseStep = (
185+
parsed: Either.Either<RawOptions, ParseError>,
186+
nextIndex: number
187+
): ParseRawOptionsStep =>
188+
Either.isLeft(parsed)
189+
? { _tag: "error", error: parsed.left }
190+
: { _tag: "ok", raw: parsed.right, nextIndex }
191+
192+
const parseValueOptionStep = (
193+
raw: RawOptions,
194+
token: string,
195+
value: string | undefined,
196+
index: number
169197
): ParseRawOptionsStep => {
170-
const token = args[index] ?? ""
198+
if (value === undefined) {
199+
return { _tag: "error", error: { _tag: "MissingOptionValue", option: token } }
200+
}
201+
return toParseStep(applyCommandValueFlag(raw, token, value), index + 2)
202+
}
203+
204+
const parseSpecialFlagStep = (
205+
raw: RawOptions,
206+
token: string,
207+
index: number
208+
): ParseRawOptionsStep | null => {
171209
const inlineApplied = parseInlineValueToken(raw, token)
172210
if (inlineApplied !== null) {
173-
return Either.isLeft(inlineApplied)
174-
? { _tag: "error", error: inlineApplied.left }
175-
: { _tag: "ok", raw: inlineApplied.right, nextIndex: index + 1 }
211+
return toParseStep(inlineApplied, index + 1)
176212
}
177213

178214
const booleanApplied = applyCommandBooleanFlag(raw, token)
179215
if (booleanApplied !== null) {
180216
return { _tag: "ok", raw: booleanApplied, nextIndex: index + 1 }
181217
}
182218

183-
if (!token.startsWith("-")) {
184-
return { _tag: "error", error: { _tag: "UnexpectedArgument", value: token } }
219+
const deprecatedAgentFlag = legacyAgentFlagError(token)
220+
if (deprecatedAgentFlag !== null) {
221+
return { _tag: "error", error: deprecatedAgentFlag }
185222
}
186223

187-
const value = args[index + 1]
188-
if (value === undefined) {
189-
return { _tag: "error", error: { _tag: "MissingOptionValue", option: token } }
224+
return null
225+
}
226+
227+
const parseRawOptionsStep = (
228+
args: ReadonlyArray<string>,
229+
index: number,
230+
raw: RawOptions
231+
): ParseRawOptionsStep => {
232+
const token = args[index] ?? ""
233+
const specialStep = parseSpecialFlagStep(raw, token, index)
234+
if (specialStep !== null) {
235+
return specialStep
236+
}
237+
238+
if (!token.startsWith("-")) {
239+
return { _tag: "error", error: { _tag: "UnexpectedArgument", value: token } }
190240
}
191241

192-
const nextRaw = applyCommandValueFlag(raw, token, value)
193-
return Either.isLeft(nextRaw)
194-
? { _tag: "error", error: nextRaw.left }
195-
: { _tag: "ok", raw: nextRaw.right, nextIndex: index + 2 }
242+
return parseValueOptionStep(raw, token, args[index + 1], index)
196243
}
197244

198245
export const parseRawOptions = (args: ReadonlyArray<string>): Either.Either<RawOptions, ParseError> => {

packages/app/src/docker-git/cli/usage.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,7 @@ Options:
6565
--up | --no-up Run docker compose up after init (default: --up)
6666
--ssh | --no-ssh Auto-open SSH after create/clone (default: clone=--ssh, create=--no-ssh)
6767
--mcp-playwright | --no-mcp-playwright Enable Playwright MCP + Chromium sidecar (default: --no-mcp-playwright)
68-
--claude Start Claude Code agent inside container after clone
69-
--codex Start Codex agent inside container after clone
70-
--auto Auto-execute: agent completes the task, creates PR and pushes (requires --claude or --codex)
68+
--auto[=claude|codex] Auto-execute an agent; without value picks by auth, random if both are available
7169
--force Overwrite existing files and wipe compose volumes (docker compose down -v)
7270
--force-env Reset project env defaults only (keep workspace volume/data)
7371
-h, --help Show this help

packages/app/tests/docker-git/parser.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,33 @@ describe("parseArgs", () => {
107107
expect(command.openSsh).toBe(true)
108108
}))
109109

110+
it.effect("parses bare --auto for clone", () =>
111+
expectCreateCommand(["clone", "https://github.com/org/repo.git", "--auto"], (command) => {
112+
expect(command.config.agentAuto).toBe(true)
113+
expect(command.config.agentMode).toBeUndefined()
114+
}))
115+
116+
it.effect("parses --auto=claude for clone", () =>
117+
expectCreateCommand(["clone", "https://github.com/org/repo.git", "--auto=claude"], (command) => {
118+
expect(command.config.agentAuto).toBe(true)
119+
expect(command.config.agentMode).toBe("claude")
120+
}))
121+
122+
it.effect("parses --auto=codex for clone", () =>
123+
expectCreateCommand(["clone", "https://github.com/org/repo.git", "--auto=codex"], (command) => {
124+
expect(command.config.agentAuto).toBe(true)
125+
expect(command.config.agentMode).toBe("codex")
126+
}))
127+
128+
it.effect("rejects legacy --claude flag", () =>
129+
expectParseErrorTag(["clone", "https://github.com/org/repo.git", "--claude", "--auto"], "InvalidOption"))
130+
131+
it.effect("rejects legacy --codex flag", () =>
132+
expectParseErrorTag(["clone", "https://github.com/org/repo.git", "--codex", "--auto"], "InvalidOption"))
133+
134+
it.effect("rejects invalid --auto value", () =>
135+
expectParseErrorTag(["clone", "https://github.com/org/repo.git", "--auto=foo"], "InvalidOption"))
136+
110137
it.effect("parses force-env flag for clone", () =>
111138
expectCreateCommand(["clone", "https://github.com/org/repo.git", "--force-env"], (command) => {
112139
expect(command.force).toBe(false)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Either } from "effect"
2+
3+
import type { RawOptions } from "./command-options.js"
4+
import type { AgentMode, ParseError } from "./domain.js"
5+
6+
export const resolveAutoAgentFlags = (
7+
raw: RawOptions
8+
): Either.Either<{ readonly agentMode: AgentMode | undefined; readonly agentAuto: boolean }, ParseError> => {
9+
const requested = raw.agentAutoMode
10+
if (requested === undefined) {
11+
return Either.right({ agentMode: undefined, agentAuto: false })
12+
}
13+
if (requested === "auto") {
14+
return Either.right({ agentMode: undefined, agentAuto: true })
15+
}
16+
if (requested === "claude" || requested === "codex") {
17+
return Either.right({ agentMode: requested, agentAuto: true })
18+
}
19+
return Either.left({
20+
_tag: "InvalidOption",
21+
option: "--auto",
22+
reason: "expected one of: claude, codex"
23+
})
24+
}

packages/lib/src/core/command-builders.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Either } from "effect"
22

33
import { expandContainerHome } from "../usecases/scrap-path.js"
4+
import { resolveAutoAgentFlags } from "./auto-agent-flags.js"
45
import { type RawOptions } from "./command-options.js"
56
import {
67
type AgentMode,
@@ -231,12 +232,6 @@ type BuildTemplateConfigInput = {
231232
readonly agentAuto: boolean
232233
}
233234

234-
const resolveAgentMode = (raw: RawOptions): AgentMode | undefined => {
235-
if (raw.agentClaude) return "claude"
236-
if (raw.agentCodex) return "codex"
237-
return undefined
238-
}
239-
240235
const buildTemplateConfig = ({
241236
agentAuto,
242237
agentMode,
@@ -301,8 +296,7 @@ export const buildCreateCommand = (
301296
const dockerSharedNetworkName = yield* _(
302297
nonEmpty("--shared-network", raw.dockerSharedNetworkName, defaultTemplateConfig.dockerSharedNetworkName)
303298
)
304-
const agentMode = resolveAgentMode(raw)
305-
const agentAuto = raw.agentAuto ?? false
299+
const { agentAuto, agentMode } = yield* _(resolveAutoAgentFlags(raw))
306300

307301
return {
308302
_tag: "Create",

packages/lib/src/core/command-options.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,7 @@ export interface RawOptions {
4747
readonly openSsh?: boolean
4848
readonly force?: boolean
4949
readonly forceEnv?: boolean
50-
readonly agentClaude?: boolean
51-
readonly agentCodex?: boolean
52-
readonly agentAuto?: boolean
50+
readonly agentAutoMode?: string
5351
}
5452

5553
// CHANGE: helper type alias for builder signatures that produce parse errors

packages/lib/src/core/templates/dockerfile.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,17 @@ RUN claude --version`
6464

6565
const renderDockerfileOpenCode = (): string =>
6666
`# Tooling: OpenCode (binary)
67-
RUN curl -fsSL https://opencode.ai/install | HOME=/usr/local bash -s -- --no-modify-path
67+
RUN set -eu; \
68+
for attempt in 1 2 3 4 5; do \
69+
if curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 https://opencode.ai/install \
70+
| HOME=/usr/local bash -s -- --no-modify-path; then \
71+
exit 0; \
72+
fi; \
73+
echo "opencode install attempt \${attempt} failed; retrying..." >&2; \
74+
sleep $((attempt * 2)); \
75+
done; \
76+
echo "opencode install failed after retries" >&2; \
77+
exit 1
6878
RUN ln -sf /usr/local/.opencode/bin/opencode /usr/local/bin/opencode
6979
RUN opencode --version`
7080

0 commit comments

Comments
 (0)