diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index d798cd3e..9af63ebb 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -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 diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts index 5a518621..8b563c8f 100644 --- a/packages/api/src/api/schema.ts +++ b/packages/api/src/api/schema.ts @@ -25,6 +25,7 @@ export const CreateProjectRequestSchema = Schema.Struct({ gitTokenLabel: OptionalString, codexTokenLabel: OptionalString, claudeTokenLabel: OptionalString, + agentAutoMode: OptionalString, up: OptionalBoolean, openSsh: OptionalBoolean, force: OptionalBoolean, diff --git a/packages/api/src/services/projects.ts b/packages/api/src/services/projects.ts index 11568e73..15b0a792 100644 --- a/packages/api/src/services/projects.ts +++ b/packages/api/src/services/projects.ts @@ -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 }), diff --git a/packages/app/src/docker-git/cli/parser-options.ts b/packages/app/src/docker-git/cli/parser-options.ts index bd0cb523..9923480f 100644 --- a/packages/app/src/docker-git/cli/parser-options.ts +++ b/packages/app/src/docker-git/cli/parser-options.ts @@ -34,6 +34,7 @@ interface ValueOptionSpec { | "outDir" | "projectDir" | "lines" + | "agentAutoMode" } const valueOptionSpecs: ReadonlyArray = [ @@ -67,7 +68,8 @@ const valueOptionSpecs: ReadonlyArray = [ { 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 = new Map( @@ -89,9 +91,7 @@ const booleanFlagUpdaters: Readonly 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 } = { @@ -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 => { @@ -162,17 +163,52 @@ const parseInlineValueToken = ( return applyCommandValueFlag(raw, flag, inlineValue) } -const parseRawOptionsStep = ( - args: ReadonlyArray, - 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, + 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) @@ -180,19 +216,30 @@ const parseRawOptionsStep = ( 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, + 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): Either.Either => { diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index 39e303fa..33e129bb 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -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 diff --git a/packages/app/tests/docker-git/parser.test.ts b/packages/app/tests/docker-git/parser.test.ts index f5db7d24..8f39f7e0 100644 --- a/packages/app/tests/docker-git/parser.test.ts +++ b/packages/app/tests/docker-git/parser.test.ts @@ -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) diff --git a/packages/lib/src/core/auto-agent-flags.ts b/packages/lib/src/core/auto-agent-flags.ts new file mode 100644 index 00000000..590c5a24 --- /dev/null +++ b/packages/lib/src/core/auto-agent-flags.ts @@ -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" + }) +} diff --git a/packages/lib/src/core/command-builders.ts b/packages/lib/src/core/command-builders.ts index 54de3ae0..8b5d8510 100644 --- a/packages/lib/src/core/command-builders.ts +++ b/packages/lib/src/core/command-builders.ts @@ -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, @@ -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, @@ -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", diff --git a/packages/lib/src/core/command-options.ts b/packages/lib/src/core/command-options.ts index fe130578..cd2bf820 100644 --- a/packages/lib/src/core/command-options.ts +++ b/packages/lib/src/core/command-options.ts @@ -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 diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index ba7238bf..d39a2e06 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -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` diff --git a/packages/lib/src/usecases/actions/create-project.ts b/packages/lib/src/usecases/actions/create-project.ts index 9e16d965..5f990fe6 100644 --- a/packages/lib/src/usecases/actions/create-project.ts +++ b/packages/lib/src/usecases/actions/create-project.ts @@ -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" @@ -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" @@ -39,6 +40,7 @@ type CreateProjectError = | DockerAccessError | DockerCommandError | PortProbeError + | ParseError | PlatformError type CreateContext = { @@ -196,6 +198,31 @@ const maybeOpenSsh = ( yield* _(openSshBestEffort(projectConfig, remoteCommand)) }).pipe(Effect.asVoid) +const resolveFinalAgentConfig = ( + resolvedConfig: CreateCommand["config"] +): Effect.Effect => + 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 => + Effect.gen(function*(_) { + if (!waitForAgent) { + return + } + yield* _(Effect.log("Agent finished. Cleaning up container...")) + yield* _(runDockerDownCleanup(resolvedOutDir)) + }) + const runCreateProject = ( path: Path.Path, command: CreateCommand @@ -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)) @@ -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, { @@ -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)) diff --git a/packages/lib/src/usecases/agent-auto-select.ts b/packages/lib/src/usecases/agent-auto-select.ts new file mode 100644 index 00000000..3f4877ff --- /dev/null +++ b/packages/lib/src/usecases/agent-auto-select.ts @@ -0,0 +1,139 @@ +import type { PlatformError } from "@effect/platform/Error" +import * as FileSystem from "@effect/platform/FileSystem" +import type * as Path from "@effect/platform/Path" +import { Effect } from "effect" + +import type { AgentMode, ParseError, TemplateConfig } from "../core/domain.js" +import { normalizeAccountLabel } from "./auth-helpers.js" +import { hasNonEmptyFile } from "./auth-sync-helpers.js" + +const autoOptionError = (reason: string): ParseError => ({ + _tag: "InvalidOption", + option: "--auto", + reason +}) + +const isRegularFile = ( + fs: FileSystem.FileSystem, + filePath: string +): Effect.Effect => + Effect.gen(function*(_) { + const exists = yield* _(fs.exists(filePath)) + if (!exists) { + return false + } + const info = yield* _(fs.stat(filePath)) + return info.type === "File" + }) + +const hasCodexAuth = ( + fs: FileSystem.FileSystem, + rootPath: string, + label: string | undefined +): Effect.Effect => { + const normalized = normalizeAccountLabel(label ?? null, "default") + const authPath = normalized === "default" + ? `${rootPath}/auth.json` + : `${rootPath}/${normalized}/auth.json` + return hasNonEmptyFile(fs, authPath) +} + +const resolveClaudeAccountPath = (rootPath: string, label: string | undefined): ReadonlyArray => { + const normalized = normalizeAccountLabel(label ?? null, "default") + if (normalized !== "default") { + return [`${rootPath}/${normalized}`] + } + return [rootPath, `${rootPath}/default`] +} + +const hasClaudeAuth = ( + fs: FileSystem.FileSystem, + rootPath: string, + label: string | undefined +): Effect.Effect => + Effect.gen(function*(_) { + for (const accountPath of resolveClaudeAccountPath(rootPath, label)) { + const oauthToken = yield* _(hasNonEmptyFile(fs, `${accountPath}/.oauth-token`)) + if (oauthToken) { + return true + } + + const credentials = yield* _(isRegularFile(fs, `${accountPath}/.credentials.json`)) + if (credentials) { + return true + } + + const nestedCredentials = yield* _(isRegularFile(fs, `${accountPath}/.claude/.credentials.json`)) + if (nestedCredentials) { + return true + } + } + + return false + }) + +const resolveClaudeRoot = (codexSharedAuthPath: string): string => + `${codexSharedAuthPath.slice(0, codexSharedAuthPath.lastIndexOf("/"))}/claude` + +const resolveAvailableAgentAuth = ( + fs: FileSystem.FileSystem, + config: Pick +): Effect.Effect<{ readonly claudeAvailable: boolean; readonly codexAvailable: boolean }, PlatformError> => + Effect.gen(function*(_) { + const claudeAvailable = yield* _( + hasClaudeAuth(fs, resolveClaudeRoot(config.codexSharedAuthPath), config.claudeAuthLabel) + ) + const codexAvailable = yield* _(hasCodexAuth(fs, config.codexSharedAuthPath, config.codexAuthLabel)) + return { claudeAvailable, codexAvailable } + }) + +const resolveExplicitAutoAgentMode = ( + available: { readonly claudeAvailable: boolean; readonly codexAvailable: boolean }, + mode: AgentMode | undefined +): Effect.Effect => { + if (mode === "claude") { + return available.claudeAvailable + ? Effect.succeed("claude") + : Effect.fail(autoOptionError("Claude auth not found")) + } + if (mode === "codex") { + return available.codexAvailable + ? Effect.succeed("codex") + : Effect.fail(autoOptionError("Codex auth not found")) + } + return Effect.sync(() => mode) +} + +const pickRandomAutoAgentMode = ( + available: { readonly claudeAvailable: boolean; readonly codexAvailable: boolean } +): Effect.Effect => { + if (!available.claudeAvailable && !available.codexAvailable) { + return Effect.fail(autoOptionError("no Claude or Codex auth found")) + } + if (available.claudeAvailable && !available.codexAvailable) { + return Effect.succeed("claude") + } + if (!available.claudeAvailable && available.codexAvailable) { + return Effect.succeed("codex") + } + return Effect.sync(() => (process.hrtime.bigint() % 2n === 0n ? "claude" : "codex")) +} + +export const resolveAutoAgentMode = ( + config: Pick +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + + if (config.agentAuto !== true) { + return config.agentMode + } + + const available = yield* _(resolveAvailableAgentAuth(fs, config)) + const explicitMode = yield* _(resolveExplicitAutoAgentMode(available, config.agentMode)) + if (explicitMode !== undefined) { + return explicitMode + } + + return yield* _(pickRandomAutoAgentMode(available)) + }) diff --git a/packages/lib/src/usecases/auth-sync-claude-seed.ts b/packages/lib/src/usecases/auth-sync-claude-seed.ts index 7ad05046..36314b43 100644 --- a/packages/lib/src/usecases/auth-sync-claude-seed.ts +++ b/packages/lib/src/usecases/auth-sync-claude-seed.ts @@ -6,6 +6,7 @@ import { Effect } from "effect" import { hasClaudeCredentials, hasClaudeOauthAccount, + hasNonEmptyFile, parseJsonRecord, resolvePathFromBase } from "./auth-sync-helpers.js" @@ -95,25 +96,6 @@ const syncClaudeCredentialsJson = ( updateLabel: "Claude credentials" }) -const hasNonEmptyFile = ( - fs: FileSystem.FileSystem, - filePath: string -): Effect.Effect => - Effect.gen(function*(_) { - const exists = yield* _(fs.exists(filePath)) - if (!exists) { - return false - } - - const info = yield* _(fs.stat(filePath)) - if (info.type !== "File") { - return false - } - - const text = yield* _(fs.readFileString(filePath), Effect.orElseSucceed(() => "")) - return text.trim().length > 0 - }) - // CHANGE: seed docker-git Claude auth store from host-level Claude files // WHY: Claude Code (v2+) keeps OAuth session in ~/.claude.json and ~/.claude/.credentials.json // QUOTE(ТЗ): "глобальная авторизация для клода ... должна сама везде настроиться" diff --git a/packages/lib/src/usecases/auth-sync-helpers.ts b/packages/lib/src/usecases/auth-sync-helpers.ts index 1d655ada..a55aef1a 100644 --- a/packages/lib/src/usecases/auth-sync-helpers.ts +++ b/packages/lib/src/usecases/auth-sync-helpers.ts @@ -1,4 +1,5 @@ import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" import * as ParseResult from "@effect/schema/ParseResult" import * as Schema from "@effect/schema/Schema" import { Effect, Either } from "effect" @@ -124,6 +125,25 @@ export const hasClaudeCredentials = (record: JsonRecord | null): boolean => export const isGithubTokenKey = (key: string): boolean => key === "GITHUB_TOKEN" || key === "GH_TOKEN" || key.startsWith("GITHUB_TOKEN__") +export const hasNonEmptyFile = ( + fs: FileSystem.FileSystem, + filePath: string +): Effect.Effect => + Effect.gen(function*(_) { + const exists = yield* _(fs.exists(filePath)) + if (!exists) { + return false + } + + const info = yield* _(fs.stat(filePath)) + if (info.type !== "File") { + return false + } + + const text = yield* _(fs.readFileString(filePath), Effect.orElseSucceed(() => "")) + return text.trim().length > 0 + }) + export type AuthPaths = { readonly envGlobalPath: string readonly envProjectPath: string diff --git a/packages/lib/tests/usecases/agent-auto-select.test.ts b/packages/lib/tests/usecases/agent-auto-select.test.ts new file mode 100644 index 00000000..c0ec1739 --- /dev/null +++ b/packages/lib/tests/usecases/agent-auto-select.test.ts @@ -0,0 +1,179 @@ +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { NodeContext } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import type { TemplateConfig } from "../../src/core/domain.js" +import { resolveAutoAgentMode } from "../../src/usecases/agent-auto-select.js" + +const withTempDir = ( + use: (tempDir: string) => Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const tempDir = yield* _( + fs.makeTempDirectoryScoped({ + prefix: "docker-git-auto-agent-" + }) + ) + return yield* _(use(tempDir)) + }) + ) + +const makeConfig = (root: string, path: Path.Path): TemplateConfig => ({ + containerName: "dg-test", + serviceName: "dg-test", + sshUser: "dev", + sshPort: 2222, + repoUrl: "https://github.com/org/repo.git", + repoRef: "issue-119", + targetDir: "/home/dev/org/repo", + volumeName: "dg-test-home", + dockerGitPath: path.join(root, ".docker-git"), + authorizedKeysPath: path.join(root, "authorized_keys"), + envGlobalPath: path.join(root, ".orch/env/global.env"), + envProjectPath: path.join(root, ".orch/env/project.env"), + codexAuthPath: path.join(root, ".orch/auth/codex"), + codexSharedAuthPath: path.join(root, ".orch/auth/codex"), + codexHome: "/home/dev/.codex", + dockerNetworkMode: "shared", + dockerSharedNetworkName: "docker-git-shared", + enableMcpPlaywright: false, + pnpmVersion: "10.27.0", + agentAuto: true +}) + +describe("resolveAutoAgentMode", () => { + it.effect("chooses Claude when only Claude auth exists", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const config = makeConfig(root, path) + const claudeRoot = path.join(root, ".orch/auth/claude/default") + + yield* _(fs.makeDirectory(claudeRoot, { recursive: true })) + yield* _(fs.writeFileString(path.join(claudeRoot, ".oauth-token"), "token\n")) + + const mode = yield* _(resolveAutoAgentMode(config)) + expect(mode).toBe("claude") + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("keeps explicit Claude mode when Claude auth exists", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const config: TemplateConfig = { ...makeConfig(root, path), agentMode: "claude" } + const claudeRoot = path.join(root, ".orch/auth/claude/default") + + yield* _(fs.makeDirectory(claudeRoot, { recursive: true })) + yield* _(fs.writeFileString(path.join(claudeRoot, ".oauth-token"), "token\n")) + + const mode = yield* _(resolveAutoAgentMode(config)) + expect(mode).toBe("claude") + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("chooses Codex when only Codex auth exists", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const config = makeConfig(root, path) + const codexRoot = path.join(root, ".orch/auth/codex") + + yield* _(fs.makeDirectory(codexRoot, { recursive: true })) + yield* _(fs.writeFileString(path.join(codexRoot, "auth.json"), "{\"ok\":true}\n")) + + const mode = yield* _(resolveAutoAgentMode(config)) + expect(mode).toBe("codex") + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("keeps explicit Codex mode when Codex auth exists", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const config: TemplateConfig = { ...makeConfig(root, path), agentMode: "codex" } + const codexRoot = path.join(root, ".orch/auth/codex") + + yield* _(fs.makeDirectory(codexRoot, { recursive: true })) + yield* _(fs.writeFileString(path.join(codexRoot, "auth.json"), "{\"ok\":true}\n")) + + const mode = yield* _(resolveAutoAgentMode(config)) + expect(mode).toBe("codex") + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("returns one of the available agents when both Claude and Codex auth exist", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const config = makeConfig(root, path) + const claudeRoot = path.join(root, ".orch/auth/claude/default") + const codexRoot = path.join(root, ".orch/auth/codex") + + yield* _(fs.makeDirectory(claudeRoot, { recursive: true })) + yield* _(fs.makeDirectory(codexRoot, { recursive: true })) + yield* _(fs.writeFileString(path.join(claudeRoot, ".oauth-token"), "token\n")) + yield* _(fs.writeFileString(path.join(codexRoot, "auth.json"), "{\"ok\":true}\n")) + + const mode = yield* _(resolveAutoAgentMode(config)) + expect(["claude", "codex"]).toContain(mode) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("fails explicit Claude mode when Claude auth is missing", () => + withTempDir((root) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const config: TemplateConfig = { ...makeConfig(root, path), agentMode: "claude" } + + const exit = yield* _( + resolveAutoAgentMode(config).pipe( + Effect.flip, + Effect.map((error) => error._tag) + ) + ) + expect(exit).toBe("InvalidOption") + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("fails explicit Codex mode when Codex auth is missing", () => + withTempDir((root) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const config: TemplateConfig = { ...makeConfig(root, path), agentMode: "codex" } + + const exit = yield* _( + resolveAutoAgentMode(config).pipe( + Effect.flip, + Effect.map((error) => error._tag) + ) + ) + expect(exit).toBe("InvalidOption") + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("fails when no auth exists", () => + withTempDir((root) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const config = makeConfig(root, path) + + const exit = yield* _( + resolveAutoAgentMode(config).pipe( + Effect.flip, + Effect.map((error) => error._tag) + ) + ) + expect(exit).toBe("InvalidOption") + }) + ).pipe(Effect.provide(NodeContext.layer))) +})