Skip to content

Commit 572ff1a

Browse files
committed
fix(ci): stabilize auto agent selection checks
1 parent e1e45bc commit 572ff1a

8 files changed

Lines changed: 179 additions & 119 deletions

File tree

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

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -181,17 +181,34 @@ const legacyAgentFlagError = (token: string): ParseError | null => {
181181
return null
182182
}
183183

184-
const parseRawOptionsStep = (
185-
args: ReadonlyArray<string>,
186-
index: number,
187-
raw: RawOptions
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
188197
): ParseRawOptionsStep => {
189-
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 => {
190209
const inlineApplied = parseInlineValueToken(raw, token)
191210
if (inlineApplied !== null) {
192-
return Either.isLeft(inlineApplied)
193-
? { _tag: "error", error: inlineApplied.left }
194-
: { _tag: "ok", raw: inlineApplied.right, nextIndex: index + 1 }
211+
return toParseStep(inlineApplied, index + 1)
195212
}
196213

197214
const booleanApplied = applyCommandBooleanFlag(raw, token)
@@ -204,19 +221,25 @@ const parseRawOptionsStep = (
204221
return { _tag: "error", error: deprecatedAgentFlag }
205222
}
206223

207-
if (!token.startsWith("-")) {
208-
return { _tag: "error", error: { _tag: "UnexpectedArgument", value: 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
209236
}
210237

211-
const value = args[index + 1]
212-
if (value === undefined) {
213-
return { _tag: "error", error: { _tag: "MissingOptionValue", option: token } }
238+
if (!token.startsWith("-")) {
239+
return { _tag: "error", error: { _tag: "UnexpectedArgument", value: token } }
214240
}
215241

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

222245
export const parseRawOptions = (args: ReadonlyArray<string>): Either.Either<RawOptions, ParseError> => {
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: 1 addition & 20 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,26 +232,6 @@ type BuildTemplateConfigInput = {
231232
readonly agentAuto: boolean
232233
}
233234

234-
const resolveAutoAgentFlags = (
235-
raw: RawOptions
236-
): Either.Either<{ readonly agentMode: AgentMode | undefined; readonly agentAuto: boolean }, ParseError> => {
237-
const requested = raw.agentAutoMode
238-
if (requested === undefined) {
239-
return Either.right({ agentMode: undefined, agentAuto: false })
240-
}
241-
if (requested === "auto") {
242-
return Either.right({ agentMode: undefined, agentAuto: true })
243-
}
244-
if (requested === "claude" || requested === "codex") {
245-
return Either.right({ agentMode: requested, agentAuto: true })
246-
}
247-
return Either.left({
248-
_tag: "InvalidOption",
249-
option: "--auto",
250-
reason: "expected one of: claude, codex"
251-
})
252-
}
253-
254235
const buildTemplateConfig = ({
255236
agentAuto,
256237
agentMode,

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

packages/lib/src/usecases/actions/create-project.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,31 @@ const maybeOpenSsh = (
198198
yield* _(openSshBestEffort(projectConfig, remoteCommand))
199199
}).pipe(Effect.asVoid)
200200

201+
const resolveFinalAgentConfig = (
202+
resolvedConfig: CreateCommand["config"]
203+
): Effect.Effect<CreateCommand["config"], ParseError | PlatformError, FileSystem.FileSystem | Path.Path> =>
204+
Effect.gen(function*(_) {
205+
const resolvedAgentMode = yield* _(resolveAutoAgentMode(resolvedConfig))
206+
if (
207+
(resolvedConfig.agentAuto ?? false) && resolvedConfig.agentMode === undefined && resolvedAgentMode !== undefined
208+
) {
209+
yield* _(Effect.log(`Auto agent selected: ${resolvedAgentMode}`))
210+
}
211+
return resolvedAgentMode === undefined ? resolvedConfig : { ...resolvedConfig, agentMode: resolvedAgentMode }
212+
})
213+
214+
const maybeCleanupAfterAgent = (
215+
waitForAgent: boolean,
216+
resolvedOutDir: string
217+
): Effect.Effect<void, DockerCommandError | PlatformError, CommandExecutor.CommandExecutor> =>
218+
Effect.gen(function*(_) {
219+
if (!waitForAgent) {
220+
return
221+
}
222+
yield* _(Effect.log("Agent finished. Cleaning up container..."))
223+
yield* _(runDockerDownCleanup(resolvedOutDir))
224+
})
225+
201226
const runCreateProject = (
202227
path: Path.Path,
203228
command: CreateCommand
@@ -211,15 +236,7 @@ const runCreateProject = (
211236
const resolvedOutDir = path.resolve(ctx.resolveRootPath(command.outDir))
212237

213238
const resolvedConfig = yield* _(resolveCreateConfig(command, ctx, resolvedOutDir))
214-
const resolvedAgentMode = yield* _(resolveAutoAgentMode(resolvedConfig))
215-
if (
216-
(resolvedConfig.agentAuto ?? false) && resolvedConfig.agentMode === undefined && resolvedAgentMode !== undefined
217-
) {
218-
yield* _(Effect.log(`Auto agent selected: ${resolvedAgentMode}`))
219-
}
220-
const finalConfig = resolvedAgentMode === undefined
221-
? resolvedConfig
222-
: { ...resolvedConfig, agentMode: resolvedAgentMode }
239+
const finalConfig = yield* _(resolveFinalAgentConfig(resolvedConfig))
223240
const { globalConfig, projectConfig } = buildProjectConfigs(path, ctx.baseDir, resolvedOutDir, finalConfig)
224241

225242
yield* _(migrateProjectOrchLayout(ctx.baseDir, globalConfig, ctx.resolveRootPath))
@@ -248,10 +265,7 @@ const runCreateProject = (
248265
yield* _(logDockerAccessInfo(resolvedOutDir, projectConfig))
249266
}
250267

251-
if (waitForAgent) {
252-
yield* _(Effect.log("Agent finished. Cleaning up container..."))
253-
yield* _(runDockerDownCleanup(resolvedOutDir))
254-
}
268+
yield* _(maybeCleanupAfterAgent(waitForAgent, resolvedOutDir))
255269

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

packages/lib/src/usecases/agent-auto-select.ts

Lines changed: 55 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,30 +5,14 @@ import { Effect } from "effect"
55

66
import type { AgentMode, ParseError, TemplateConfig } from "../core/domain.js"
77
import { normalizeAccountLabel } from "./auth-helpers.js"
8+
import { hasNonEmptyFile } from "./auth-sync-helpers.js"
89

910
const autoOptionError = (reason: string): ParseError => ({
1011
_tag: "InvalidOption",
1112
option: "--auto",
1213
reason
1314
})
1415

15-
const isNonEmptyFile = (
16-
fs: FileSystem.FileSystem,
17-
filePath: string
18-
): Effect.Effect<boolean, PlatformError> =>
19-
Effect.gen(function*(_) {
20-
const exists = yield* _(fs.exists(filePath))
21-
if (!exists) {
22-
return false
23-
}
24-
const info = yield* _(fs.stat(filePath))
25-
if (info.type !== "File") {
26-
return false
27-
}
28-
const text = yield* _(fs.readFileString(filePath), Effect.orElseSucceed(() => ""))
29-
return text.trim().length > 0
30-
})
31-
3216
const isRegularFile = (
3317
fs: FileSystem.FileSystem,
3418
filePath: string
@@ -51,7 +35,7 @@ const hasCodexAuth = (
5135
const authPath = normalized === "default"
5236
? `${rootPath}/auth.json`
5337
: `${rootPath}/${normalized}/auth.json`
54-
return isNonEmptyFile(fs, authPath)
38+
return hasNonEmptyFile(fs, authPath)
5539
}
5640

5741
const resolveClaudeAccountPath = (rootPath: string, label: string | undefined): ReadonlyArray<string> => {
@@ -69,7 +53,7 @@ const hasClaudeAuth = (
6953
): Effect.Effect<boolean, PlatformError> =>
7054
Effect.gen(function*(_) {
7155
for (const accountPath of resolveClaudeAccountPath(rootPath, label)) {
72-
const oauthToken = yield* _(isNonEmptyFile(fs, `${accountPath}/.oauth-token`))
56+
const oauthToken = yield* _(hasNonEmptyFile(fs, `${accountPath}/.oauth-token`))
7357
if (oauthToken) {
7458
return true
7559
}
@@ -88,6 +72,53 @@ const hasClaudeAuth = (
8872
return false
8973
})
9074

75+
const resolveClaudeRoot = (codexSharedAuthPath: string): string =>
76+
`${codexSharedAuthPath.slice(0, codexSharedAuthPath.lastIndexOf("/"))}/claude`
77+
78+
const resolveAvailableAgentAuth = (
79+
fs: FileSystem.FileSystem,
80+
config: Pick<TemplateConfig, "claudeAuthLabel" | "codexAuthLabel" | "codexSharedAuthPath">
81+
): Effect.Effect<{ readonly claudeAvailable: boolean; readonly codexAvailable: boolean }, PlatformError> =>
82+
Effect.gen(function*(_) {
83+
const claudeAvailable = yield* _(
84+
hasClaudeAuth(fs, resolveClaudeRoot(config.codexSharedAuthPath), config.claudeAuthLabel)
85+
)
86+
const codexAvailable = yield* _(hasCodexAuth(fs, config.codexSharedAuthPath, config.codexAuthLabel))
87+
return { claudeAvailable, codexAvailable }
88+
})
89+
90+
const resolveExplicitAutoAgentMode = (
91+
available: { readonly claudeAvailable: boolean; readonly codexAvailable: boolean },
92+
mode: AgentMode | undefined
93+
): Effect.Effect<AgentMode | undefined, ParseError> => {
94+
if (mode === "claude") {
95+
return available.claudeAvailable
96+
? Effect.succeed("claude")
97+
: Effect.fail(autoOptionError("Claude auth not found"))
98+
}
99+
if (mode === "codex") {
100+
return available.codexAvailable
101+
? Effect.succeed("codex")
102+
: Effect.fail(autoOptionError("Codex auth not found"))
103+
}
104+
return Effect.sync(() => mode)
105+
}
106+
107+
const pickRandomAutoAgentMode = (
108+
available: { readonly claudeAvailable: boolean; readonly codexAvailable: boolean }
109+
): Effect.Effect<AgentMode, ParseError> => {
110+
if (!available.claudeAvailable && !available.codexAvailable) {
111+
return Effect.fail(autoOptionError("no Claude or Codex auth found"))
112+
}
113+
if (available.claudeAvailable && !available.codexAvailable) {
114+
return Effect.succeed("claude")
115+
}
116+
if (!available.claudeAvailable && available.codexAvailable) {
117+
return Effect.succeed("codex")
118+
}
119+
return Effect.sync(() => (process.hrtime.bigint() % 2n === 0n ? "claude" : "codex"))
120+
}
121+
91122
export const resolveAutoAgentMode = (
92123
config: Pick<TemplateConfig, "agentAuto" | "agentMode" | "claudeAuthLabel" | "codexAuthLabel" | "codexSharedAuthPath">
93124
): Effect.Effect<AgentMode | undefined, ParseError | PlatformError, FileSystem.FileSystem | Path.Path> =>
@@ -98,36 +129,11 @@ export const resolveAutoAgentMode = (
98129
return config.agentMode
99130
}
100131

101-
if (config.agentMode === "claude") {
102-
const claudeRoot = `${config.codexSharedAuthPath.slice(0, config.codexSharedAuthPath.lastIndexOf("/"))}/claude`
103-
const available = yield* _(hasClaudeAuth(fs, claudeRoot, config.claudeAuthLabel))
104-
if (!available) {
105-
return yield* _(Effect.fail(autoOptionError("Claude auth not found")))
106-
}
107-
return "claude"
108-
}
109-
110-
if (config.agentMode === "codex") {
111-
const available = yield* _(hasCodexAuth(fs, config.codexSharedAuthPath, config.codexAuthLabel))
112-
if (!available) {
113-
return yield* _(Effect.fail(autoOptionError("Codex auth not found")))
114-
}
115-
return "codex"
116-
}
117-
118-
const claudeRoot = `${config.codexSharedAuthPath.slice(0, config.codexSharedAuthPath.lastIndexOf("/"))}/claude`
119-
const claudeAvailable = yield* _(hasClaudeAuth(fs, claudeRoot, config.claudeAuthLabel))
120-
const codexAvailable = yield* _(hasCodexAuth(fs, config.codexSharedAuthPath, config.codexAuthLabel))
121-
122-
if (!claudeAvailable && !codexAvailable) {
123-
return yield* _(Effect.fail(autoOptionError("no Claude or Codex auth found")))
124-
}
125-
if (claudeAvailable && !codexAvailable) {
126-
return "claude"
127-
}
128-
if (!claudeAvailable && codexAvailable) {
129-
return "codex"
132+
const available = yield* _(resolveAvailableAgentAuth(fs, config))
133+
const explicitMode = yield* _(resolveExplicitAutoAgentMode(available, config.agentMode))
134+
if (explicitMode !== undefined) {
135+
return explicitMode
130136
}
131137

132-
return process.hrtime.bigint() % 2n === 0n ? "claude" : "codex"
138+
return yield* _(pickRandomAutoAgentMode(available))
133139
})

packages/lib/src/usecases/auth-sync-claude-seed.ts

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Effect } from "effect"
66
import {
77
hasClaudeCredentials,
88
hasClaudeOauthAccount,
9+
hasNonEmptyFile,
910
parseJsonRecord,
1011
resolvePathFromBase
1112
} from "./auth-sync-helpers.js"
@@ -95,25 +96,6 @@ const syncClaudeCredentialsJson = (
9596
updateLabel: "Claude credentials"
9697
})
9798

98-
const hasNonEmptyFile = (
99-
fs: FileSystem.FileSystem,
100-
filePath: string
101-
): Effect.Effect<boolean, PlatformError> =>
102-
Effect.gen(function*(_) {
103-
const exists = yield* _(fs.exists(filePath))
104-
if (!exists) {
105-
return false
106-
}
107-
108-
const info = yield* _(fs.stat(filePath))
109-
if (info.type !== "File") {
110-
return false
111-
}
112-
113-
const text = yield* _(fs.readFileString(filePath), Effect.orElseSucceed(() => ""))
114-
return text.trim().length > 0
115-
})
116-
11799
// CHANGE: seed docker-git Claude auth store from host-level Claude files
118100
// WHY: Claude Code (v2+) keeps OAuth session in ~/.claude.json and ~/.claude/.credentials.json
119101
// QUOTE(ТЗ): "глобальная авторизация для клода ... должна сама везде настроиться"

0 commit comments

Comments
 (0)