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
1 change: 1 addition & 0 deletions packages/api/src/api/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export type CreateProjectRequest = {
readonly gitTokenLabel?: string | undefined
readonly codexTokenLabel?: string | undefined
readonly claudeTokenLabel?: string | undefined
readonly agentAutoMode?: string | undefined
readonly up?: boolean | undefined
readonly openSsh?: boolean | undefined
readonly force?: boolean | undefined
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const CreateProjectRequestSchema = Schema.Struct({
gitTokenLabel: OptionalString,
codexTokenLabel: OptionalString,
claudeTokenLabel: OptionalString,
agentAutoMode: OptionalString,
up: OptionalBoolean,
openSsh: OptionalBoolean,
force: OptionalBoolean,
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/services/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export const createProjectFromRequest = (
...(request.gitTokenLabel === undefined ? {} : { gitTokenLabel: request.gitTokenLabel }),
...(request.codexTokenLabel === undefined ? {} : { codexTokenLabel: request.codexTokenLabel }),
...(request.claudeTokenLabel === undefined ? {} : { claudeTokenLabel: request.claudeTokenLabel }),
...(request.agentAutoMode === undefined ? {} : { agentAutoMode: request.agentAutoMode }),
...(request.up === undefined ? {} : { up: request.up }),
...(request.openSsh === undefined ? {} : { openSsh: request.openSsh }),
...(request.force === undefined ? {} : { force: request.force }),
Expand Down
91 changes: 69 additions & 22 deletions packages/app/src/docker-git/cli/parser-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface ValueOptionSpec {
| "outDir"
| "projectDir"
| "lines"
| "agentAutoMode"
}

const valueOptionSpecs: ReadonlyArray<ValueOptionSpec> = [
Expand Down Expand Up @@ -67,7 +68,8 @@ const valueOptionSpecs: ReadonlyArray<ValueOptionSpec> = [
{ flag: "-m", key: "message" },
{ flag: "--out-dir", key: "outDir" },
{ flag: "--project-dir", key: "projectDir" },
{ flag: "--lines", key: "lines" }
{ flag: "--lines", key: "lines" },
{ flag: "--auto", key: "agentAutoMode" }
]

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

const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: string) => RawOptions } = {
Expand Down Expand Up @@ -122,7 +122,8 @@ const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: st
message: (raw, value) => ({ ...raw, message: value }),
outDir: (raw, value) => ({ ...raw, outDir: value }),
projectDir: (raw, value) => ({ ...raw, projectDir: value }),
lines: (raw, value) => ({ ...raw, lines: value })
lines: (raw, value) => ({ ...raw, lines: value }),
agentAutoMode: (raw, value) => ({ ...raw, agentAutoMode: value.trim().toLowerCase() })
}

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

const parseRawOptionsStep = (
args: ReadonlyArray<string>,
index: number,
raw: RawOptions
const legacyAgentFlagError = (token: string): ParseError | null => {
if (token === "--claude") {
return {
_tag: "InvalidOption",
option: token,
reason: "use --auto=claude"
}
}
if (token === "--codex") {
return {
_tag: "InvalidOption",
option: token,
reason: "use --auto=codex"
}
}
return null
}

const toParseStep = (
parsed: Either.Either<RawOptions, ParseError>,
nextIndex: number
): ParseRawOptionsStep =>
Either.isLeft(parsed)
? { _tag: "error", error: parsed.left }
: { _tag: "ok", raw: parsed.right, nextIndex }

const parseValueOptionStep = (
raw: RawOptions,
token: string,
value: string | undefined,
index: number
): ParseRawOptionsStep => {
const token = args[index] ?? ""
if (value === undefined) {
return { _tag: "error", error: { _tag: "MissingOptionValue", option: token } }
}
return toParseStep(applyCommandValueFlag(raw, token, value), index + 2)
}

const parseSpecialFlagStep = (
raw: RawOptions,
token: string,
index: number
): ParseRawOptionsStep | null => {
const inlineApplied = parseInlineValueToken(raw, token)
if (inlineApplied !== null) {
return Either.isLeft(inlineApplied)
? { _tag: "error", error: inlineApplied.left }
: { _tag: "ok", raw: inlineApplied.right, nextIndex: index + 1 }
return toParseStep(inlineApplied, index + 1)
}

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

if (!token.startsWith("-")) {
return { _tag: "error", error: { _tag: "UnexpectedArgument", value: token } }
const deprecatedAgentFlag = legacyAgentFlagError(token)
if (deprecatedAgentFlag !== null) {
return { _tag: "error", error: deprecatedAgentFlag }
}

const value = args[index + 1]
if (value === undefined) {
return { _tag: "error", error: { _tag: "MissingOptionValue", option: token } }
return null
}

const parseRawOptionsStep = (
args: ReadonlyArray<string>,
index: number,
raw: RawOptions
): ParseRawOptionsStep => {
const token = args[index] ?? ""
const specialStep = parseSpecialFlagStep(raw, token, index)
if (specialStep !== null) {
return specialStep
}

if (!token.startsWith("-")) {
return { _tag: "error", error: { _tag: "UnexpectedArgument", value: token } }
}

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

export const parseRawOptions = (args: ReadonlyArray<string>): Either.Either<RawOptions, ParseError> => {
Expand Down
4 changes: 1 addition & 3 deletions packages/app/src/docker-git/cli/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,7 @@ Options:
--up | --no-up Run docker compose up after init (default: --up)
--ssh | --no-ssh Auto-open SSH after create/clone (default: clone=--ssh, create=--no-ssh)
--mcp-playwright | --no-mcp-playwright Enable Playwright MCP + Chromium sidecar (default: --no-mcp-playwright)
--claude Start Claude Code agent inside container after clone
--codex Start Codex agent inside container after clone
--auto Auto-execute: agent completes the task, creates PR and pushes (requires --claude or --codex)
--auto[=claude|codex] Auto-execute an agent; without value picks by auth, random if both are available
--force Overwrite existing files and wipe compose volumes (docker compose down -v)
--force-env Reset project env defaults only (keep workspace volume/data)
-h, --help Show this help
Expand Down
27 changes: 27 additions & 0 deletions packages/app/tests/docker-git/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,33 @@ describe("parseArgs", () => {
expect(command.openSsh).toBe(true)
}))

it.effect("parses bare --auto for clone", () =>
expectCreateCommand(["clone", "https://github.com/org/repo.git", "--auto"], (command) => {
expect(command.config.agentAuto).toBe(true)
expect(command.config.agentMode).toBeUndefined()
}))

it.effect("parses --auto=claude for clone", () =>
expectCreateCommand(["clone", "https://github.com/org/repo.git", "--auto=claude"], (command) => {
expect(command.config.agentAuto).toBe(true)
expect(command.config.agentMode).toBe("claude")
}))

it.effect("parses --auto=codex for clone", () =>
expectCreateCommand(["clone", "https://github.com/org/repo.git", "--auto=codex"], (command) => {
expect(command.config.agentAuto).toBe(true)
expect(command.config.agentMode).toBe("codex")
}))

it.effect("rejects legacy --claude flag", () =>
expectParseErrorTag(["clone", "https://github.com/org/repo.git", "--claude", "--auto"], "InvalidOption"))

it.effect("rejects legacy --codex flag", () =>
expectParseErrorTag(["clone", "https://github.com/org/repo.git", "--codex", "--auto"], "InvalidOption"))

it.effect("rejects invalid --auto value", () =>
expectParseErrorTag(["clone", "https://github.com/org/repo.git", "--auto=foo"], "InvalidOption"))

it.effect("parses force-env flag for clone", () =>
expectCreateCommand(["clone", "https://github.com/org/repo.git", "--force-env"], (command) => {
expect(command.force).toBe(false)
Expand Down
24 changes: 24 additions & 0 deletions packages/lib/src/core/auto-agent-flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Either } from "effect"

import type { RawOptions } from "./command-options.js"
import type { AgentMode, ParseError } from "./domain.js"

export const resolveAutoAgentFlags = (
raw: RawOptions
): Either.Either<{ readonly agentMode: AgentMode | undefined; readonly agentAuto: boolean }, ParseError> => {
const requested = raw.agentAutoMode
if (requested === undefined) {
return Either.right({ agentMode: undefined, agentAuto: false })
}
if (requested === "auto") {
return Either.right({ agentMode: undefined, agentAuto: true })
}
if (requested === "claude" || requested === "codex") {
return Either.right({ agentMode: requested, agentAuto: true })
}
return Either.left({
_tag: "InvalidOption",
option: "--auto",
reason: "expected one of: claude, codex"
})
}
10 changes: 2 additions & 8 deletions packages/lib/src/core/command-builders.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Either } from "effect"

import { expandContainerHome } from "../usecases/scrap-path.js"
import { resolveAutoAgentFlags } from "./auto-agent-flags.js"
import { type RawOptions } from "./command-options.js"
import {
type AgentMode,
Expand Down Expand Up @@ -231,12 +232,6 @@ type BuildTemplateConfigInput = {
readonly agentAuto: boolean
}

const resolveAgentMode = (raw: RawOptions): AgentMode | undefined => {
if (raw.agentClaude) return "claude"
if (raw.agentCodex) return "codex"
return undefined
}

const buildTemplateConfig = ({
agentAuto,
agentMode,
Expand Down Expand Up @@ -301,8 +296,7 @@ export const buildCreateCommand = (
const dockerSharedNetworkName = yield* _(
nonEmpty("--shared-network", raw.dockerSharedNetworkName, defaultTemplateConfig.dockerSharedNetworkName)
)
const agentMode = resolveAgentMode(raw)
const agentAuto = raw.agentAuto ?? false
const { agentAuto, agentMode } = yield* _(resolveAutoAgentFlags(raw))

return {
_tag: "Create",
Expand Down
4 changes: 1 addition & 3 deletions packages/lib/src/core/command-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,7 @@ export interface RawOptions {
readonly openSsh?: boolean
readonly force?: boolean
readonly forceEnv?: boolean
readonly agentClaude?: boolean
readonly agentCodex?: boolean
readonly agentAuto?: boolean
readonly agentAutoMode?: string
}

// CHANGE: helper type alias for builder signatures that produce parse errors
Expand Down
12 changes: 11 additions & 1 deletion packages/lib/src/core/templates/dockerfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,17 @@ RUN claude --version`

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

Expand Down
41 changes: 33 additions & 8 deletions packages/lib/src/usecases/actions/create-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as FileSystem from "@effect/platform/FileSystem"
import * as Path from "@effect/platform/Path"
import { Effect } from "effect"

import type { CreateCommand } from "../../core/domain.js"
import type { CreateCommand, ParseError } from "../../core/domain.js"
import { deriveRepoPathParts } from "../../core/domain.js"
import { runCommandWithExitCodes } from "../../shell/command-runner.js"
import { ensureDockerDaemonAccess } from "../../shell/docker.js"
Expand All @@ -18,6 +18,7 @@ import type {
PortProbeError
} from "../../shell/errors.js"
import { logDockerAccessInfo } from "../access-log.js"
import { resolveAutoAgentMode } from "../agent-auto-select.js"
import { renderError } from "../errors.js"
import { applyGithubForkConfig } from "../github-fork.js"
import { defaultProjectsRoot } from "../menu-helpers.js"
Expand All @@ -39,6 +40,7 @@ type CreateProjectError =
| DockerAccessError
| DockerCommandError
| PortProbeError
| ParseError
| PlatformError

type CreateContext = {
Expand Down Expand Up @@ -196,6 +198,31 @@ const maybeOpenSsh = (
yield* _(openSshBestEffort(projectConfig, remoteCommand))
}).pipe(Effect.asVoid)

const resolveFinalAgentConfig = (
resolvedConfig: CreateCommand["config"]
): Effect.Effect<CreateCommand["config"], ParseError | PlatformError, FileSystem.FileSystem | Path.Path> =>
Effect.gen(function*(_) {
const resolvedAgentMode = yield* _(resolveAutoAgentMode(resolvedConfig))
if (
(resolvedConfig.agentAuto ?? false) && resolvedConfig.agentMode === undefined && resolvedAgentMode !== undefined
) {
yield* _(Effect.log(`Auto agent selected: ${resolvedAgentMode}`))
}
return resolvedAgentMode === undefined ? resolvedConfig : { ...resolvedConfig, agentMode: resolvedAgentMode }
})

const maybeCleanupAfterAgent = (
waitForAgent: boolean,
resolvedOutDir: string
): Effect.Effect<void, DockerCommandError | PlatformError, CommandExecutor.CommandExecutor> =>
Effect.gen(function*(_) {
if (!waitForAgent) {
return
}
yield* _(Effect.log("Agent finished. Cleaning up container..."))
yield* _(runDockerDownCleanup(resolvedOutDir))
})

const runCreateProject = (
path: Path.Path,
command: CreateCommand
Expand All @@ -209,7 +236,8 @@ const runCreateProject = (
const resolvedOutDir = path.resolve(ctx.resolveRootPath(command.outDir))

const resolvedConfig = yield* _(resolveCreateConfig(command, ctx, resolvedOutDir))
const { globalConfig, projectConfig } = buildProjectConfigs(path, ctx.baseDir, resolvedOutDir, resolvedConfig)
const finalConfig = yield* _(resolveFinalAgentConfig(resolvedConfig))
const { globalConfig, projectConfig } = buildProjectConfigs(path, ctx.baseDir, resolvedOutDir, finalConfig)

yield* _(migrateProjectOrchLayout(ctx.baseDir, globalConfig, ctx.resolveRootPath))

Expand All @@ -221,8 +249,8 @@ const runCreateProject = (
)
yield* _(logCreatedProject(resolvedOutDir, createdFiles))

const hasAgent = resolvedConfig.agentMode !== undefined
const waitForAgent = hasAgent && (resolvedConfig.agentAuto ?? false)
const hasAgent = finalConfig.agentMode !== undefined
const waitForAgent = hasAgent && (finalConfig.agentAuto ?? false)

yield* _(
runDockerUpIfNeeded(resolvedOutDir, projectConfig, {
Expand All @@ -237,10 +265,7 @@ const runCreateProject = (
yield* _(logDockerAccessInfo(resolvedOutDir, projectConfig))
}

if (waitForAgent) {
yield* _(Effect.log("Agent finished. Cleaning up container..."))
yield* _(runDockerDownCleanup(resolvedOutDir))
}
yield* _(maybeCleanupAfterAgent(waitForAgent, resolvedOutDir))

yield* _(autoSyncState(`chore(state): update ${formatStateSyncLabel(projectConfig.repoUrl)}`))
yield* _(maybeOpenSsh(command, hasAgent, waitForAgent, projectConfig))
Expand Down
Loading
Loading