@prover-coder-ai/docker-git 1.0.30 → 1.0.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -361,6 +361,8 @@ class DockerAccessError extends Data.TaggedError("DockerAccessError") {
361
361
  }
362
362
  class CloneFailedError extends Data.TaggedError("CloneFailedError") {
363
363
  }
364
+ class AgentFailedError extends Data.TaggedError("AgentFailedError") {
365
+ }
364
366
  class PortProbeError extends Data.TaggedError("PortProbeError") {
365
367
  }
366
368
  class CommandFailedError extends Data.TaggedError("CommandFailedError") {
@@ -777,23 +779,26 @@ const renderDockerAccessActionPlan = (issue) => {
777
779
  ];
778
780
  return issue === "PermissionDenied" ? permissionDeniedPlan.join("\n") : daemonUnavailablePlan.join("\n");
779
781
  };
782
+ const renderDockerCommandError = ({ exitCode }) => [
783
+ `docker compose failed with exit code ${exitCode}`,
784
+ "Hint: ensure Docker daemon is running and current user can access /var/run/docker.sock (for example via the docker group).",
785
+ "Hint: if output above contains 'port is already allocated', retry with a free SSH port via --ssh-port <port> (for example --ssh-port 2235), or stop the conflicting project/container.",
786
+ "Hint: if output above contains 'all predefined address pools have been fully subnetted', run `docker network prune -f`, configure Docker `default-address-pools`, or use shared network mode (`--network-mode shared`).",
787
+ "Hint: if output above contains 'lookup auth.docker.io' or 'read udp ... [::1]:53 ... connection refused', fix Docker DNS resolver (set working DNS in host/daemon config) and retry."
788
+ ].join("\n");
789
+ const renderDockerAccessError = ({ details, issue }) => [
790
+ renderDockerAccessHeadline(issue),
791
+ "Hint: ensure Docker daemon is running and current user can access the docker socket.",
792
+ "Hint: if you use rootless Docker, set DOCKER_HOST to your user socket (for example unix:///run/user/$UID/docker.sock).",
793
+ renderDockerAccessActionPlan(issue),
794
+ `Details: ${details}`
795
+ ].join("\n");
780
796
  const renderPrimaryError = (error) => Match.value(error).pipe(
781
797
  Match.when({ _tag: "FileExistsError" }, ({ path }) => `File already exists: ${path} (use --force to overwrite)`),
782
- Match.when({ _tag: "DockerCommandError" }, ({ exitCode }) => [
783
- `docker compose failed with exit code ${exitCode}`,
784
- "Hint: ensure Docker daemon is running and current user can access /var/run/docker.sock (for example via the docker group).",
785
- "Hint: if output above contains 'port is already allocated', retry with a free SSH port via --ssh-port <port> (for example --ssh-port 2235), or stop the conflicting project/container.",
786
- "Hint: if output above contains 'all predefined address pools have been fully subnetted', run `docker network prune -f`, configure Docker `default-address-pools`, or use shared network mode (`--network-mode shared`).",
787
- "Hint: if output above contains 'lookup auth.docker.io' or 'read udp ... [::1]:53 ... connection refused', fix Docker DNS resolver (set working DNS in host/daemon config) and retry."
788
- ].join("\n")),
789
- Match.when({ _tag: "DockerAccessError" }, ({ details, issue }) => [
790
- renderDockerAccessHeadline(issue),
791
- "Hint: ensure Docker daemon is running and current user can access the docker socket.",
792
- "Hint: if you use rootless Docker, set DOCKER_HOST to your user socket (for example unix:///run/user/$UID/docker.sock).",
793
- renderDockerAccessActionPlan(issue),
794
- `Details: ${details}`
795
- ].join("\n")),
798
+ Match.when({ _tag: "DockerCommandError" }, renderDockerCommandError),
799
+ Match.when({ _tag: "DockerAccessError" }, renderDockerAccessError),
796
800
  Match.when({ _tag: "CloneFailedError" }, ({ repoRef, repoUrl, targetDir }) => `Clone failed for ${repoUrl} (${repoRef}) into ${targetDir}`),
801
+ Match.when({ _tag: "AgentFailedError" }, ({ agentMode, targetDir }) => `Agent (${agentMode}) failed in ${targetDir}`),
797
802
  Match.when({ _tag: "PortProbeError" }, ({ message, port }) => `SSH port check failed for ${port}: ${message}`),
798
803
  Match.when(
799
804
  { _tag: "CommandFailedError" },
@@ -846,6 +851,109 @@ const renderError = (error) => {
846
851
  }
847
852
  return renderNonParseError(error);
848
853
  };
854
+ const resolveEnvValue = (key) => {
855
+ const value = process.env[key]?.trim();
856
+ return value && value.length > 0 ? value : null;
857
+ };
858
+ const trimTrailingSlash$1 = (value) => {
859
+ let end = value.length;
860
+ while (end > 0) {
861
+ const char = value[end - 1];
862
+ if (char !== "/" && char !== "\\") {
863
+ break;
864
+ }
865
+ end -= 1;
866
+ }
867
+ return value.slice(0, end);
868
+ };
869
+ const pathStartsWith = (candidate, prefix) => candidate === prefix || candidate.startsWith(`${prefix}/`) || candidate.startsWith(`${prefix}\\`);
870
+ const translatePathPrefix = (candidate, sourcePrefix, targetPrefix) => pathStartsWith(candidate, sourcePrefix) ? `${targetPrefix}${candidate.slice(sourcePrefix.length)}` : null;
871
+ const resolveContainerProjectsRoot = () => {
872
+ const explicit = resolveEnvValue("DOCKER_GIT_PROJECTS_ROOT");
873
+ if (explicit !== null) {
874
+ return explicit;
875
+ }
876
+ const home = resolveEnvValue("HOME") ?? resolveEnvValue("USERPROFILE");
877
+ return home === null ? null : `${trimTrailingSlash$1(home)}/.docker-git`;
878
+ };
879
+ const resolveProjectsRootHostOverride = () => resolveEnvValue("DOCKER_GIT_PROJECTS_ROOT_HOST");
880
+ const resolveCurrentContainerId = (cwd) => {
881
+ const fromEnv = resolveEnvValue("HOSTNAME");
882
+ if (fromEnv !== null) {
883
+ return Effect.succeed(fromEnv);
884
+ }
885
+ return runCommandCapture(
886
+ {
887
+ cwd,
888
+ command: "hostname",
889
+ args: []
890
+ },
891
+ [0],
892
+ () => new Error("hostname failed")
893
+ ).pipe(
894
+ Effect.map((value) => value.trim()),
895
+ Effect.orElseSucceed(() => ""),
896
+ Effect.map((value) => value.length > 0 ? value : null)
897
+ );
898
+ };
899
+ const parseDockerInspectMounts = (raw) => raw.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0).flatMap((line) => {
900
+ const separator = line.indexOf(" ");
901
+ if (separator <= 0 || separator >= line.length - 1) {
902
+ return [];
903
+ }
904
+ const source = line.slice(0, separator).trim();
905
+ const destination = line.slice(separator + 1).trim();
906
+ if (source.length === 0 || destination.length === 0) {
907
+ return [];
908
+ }
909
+ return [{ source, destination }];
910
+ });
911
+ const remapDockerBindHostPathFromMounts = (hostPath, mounts) => {
912
+ let match = null;
913
+ for (const mount of mounts) {
914
+ if (!pathStartsWith(hostPath, mount.destination)) {
915
+ continue;
916
+ }
917
+ if (match === null || mount.destination.length > match.destination.length) {
918
+ match = mount;
919
+ }
920
+ }
921
+ if (match === null) {
922
+ return hostPath;
923
+ }
924
+ return `${match.source}${hostPath.slice(match.destination.length)}`;
925
+ };
926
+ const resolveDockerVolumeHostPath = (cwd, hostPath) => Effect.gen(function* (_) {
927
+ const containerProjectsRoot = resolveContainerProjectsRoot();
928
+ const hostProjectsRoot = resolveProjectsRootHostOverride();
929
+ if (containerProjectsRoot !== null && hostProjectsRoot !== null) {
930
+ const remapped = translatePathPrefix(hostPath, containerProjectsRoot, hostProjectsRoot);
931
+ if (remapped !== null) {
932
+ return remapped;
933
+ }
934
+ }
935
+ const containerId = yield* _(resolveCurrentContainerId(cwd));
936
+ if (containerId === null) {
937
+ return hostPath;
938
+ }
939
+ const mountsJson = yield* _(
940
+ runCommandCapture(
941
+ {
942
+ cwd,
943
+ command: "docker",
944
+ args: [
945
+ "inspect",
946
+ containerId,
947
+ "--format",
948
+ String.raw`{{range .Mounts}}{{println .Source "\t" .Destination}}{{end}}`
949
+ ]
950
+ },
951
+ [0],
952
+ () => new Error("docker inspect current container failed")
953
+ ).pipe(Effect.orElseSucceed(() => ""))
954
+ );
955
+ return remapDockerBindHostPathFromMounts(hostPath, parseDockerInspectMounts(mountsJson));
956
+ });
849
957
  const resolveDefaultDockerUser = () => {
850
958
  const getUid = Reflect.get(process, "getuid");
851
959
  const getGid = Reflect.get(process, "getgid");
@@ -893,17 +1001,44 @@ const buildDockerArgs = (spec) => {
893
1001
  }
894
1002
  return [...base, spec.image, ...spec.args];
895
1003
  };
896
- const runDockerAuth = (spec, okExitCodes, onFailure) => runCommandWithExitCodes(
897
- { cwd: spec.cwd, command: "docker", args: buildDockerArgs(spec) },
898
- okExitCodes,
899
- onFailure
900
- );
901
- const runDockerAuthCapture = (spec, okExitCodes, onFailure) => runCommandCapture(
902
- { cwd: spec.cwd, command: "docker", args: buildDockerArgs(spec) },
903
- okExitCodes,
904
- onFailure
905
- );
906
- const runDockerAuthExitCode = (spec) => runCommandExitCode({ cwd: spec.cwd, command: "docker", args: buildDockerArgs(spec) });
1004
+ const runDockerAuth = (spec, okExitCodes, onFailure) => Effect.gen(function* (_) {
1005
+ const hostPath = yield* _(resolveDockerVolumeHostPath(spec.cwd, spec.volume.hostPath));
1006
+ yield* _(
1007
+ runCommandWithExitCodes(
1008
+ {
1009
+ cwd: spec.cwd,
1010
+ command: "docker",
1011
+ args: buildDockerArgs({ ...spec, volume: { ...spec.volume, hostPath } })
1012
+ },
1013
+ okExitCodes,
1014
+ onFailure
1015
+ )
1016
+ );
1017
+ });
1018
+ const runDockerAuthCapture = (spec, okExitCodes, onFailure) => Effect.gen(function* (_) {
1019
+ const hostPath = yield* _(resolveDockerVolumeHostPath(spec.cwd, spec.volume.hostPath));
1020
+ return yield* _(
1021
+ runCommandCapture(
1022
+ {
1023
+ cwd: spec.cwd,
1024
+ command: "docker",
1025
+ args: buildDockerArgs({ ...spec, volume: { ...spec.volume, hostPath } })
1026
+ },
1027
+ okExitCodes,
1028
+ onFailure
1029
+ )
1030
+ );
1031
+ });
1032
+ const runDockerAuthExitCode = (spec) => Effect.gen(function* (_) {
1033
+ const hostPath = yield* _(resolveDockerVolumeHostPath(spec.cwd, spec.volume.hostPath));
1034
+ return yield* _(
1035
+ runCommandExitCode({
1036
+ cwd: spec.cwd,
1037
+ command: "docker",
1038
+ args: buildDockerArgs({ ...spec, volume: { ...spec.volume, hostPath } })
1039
+ })
1040
+ );
1041
+ });
907
1042
  const normalizeAccountLabel = (value, fallback) => {
908
1043
  const trimmed = value?.trim() ?? "";
909
1044
  if (trimmed.length === 0) {
@@ -2119,6 +2254,8 @@ GITHUB_TOKEN="\${GITHUB_TOKEN:-\${GH_TOKEN:-}}"
2119
2254
  GIT_USER_NAME="\${GIT_USER_NAME:-}"
2120
2255
  GIT_USER_EMAIL="\${GIT_USER_EMAIL:-}"
2121
2256
  CODEX_AUTO_UPDATE="\${CODEX_AUTO_UPDATE:-1}"
2257
+ AGENT_MODE="\${AGENT_MODE:-}"
2258
+ AGENT_AUTO="\${AGENT_AUTO:-}"
2122
2259
  MCP_PLAYWRIGHT_ENABLE="\${MCP_PLAYWRIGHT_ENABLE:-${config.enableMcpPlaywright ? "1" : "0"}}"
2123
2260
  MCP_PLAYWRIGHT_CDP_ENDPOINT="\${MCP_PLAYWRIGHT_CDP_ENDPOINT:-}"
2124
2261
  MCP_PLAYWRIGHT_ISOLATED="\${MCP_PLAYWRIGHT_ISOLATED:-1}"
@@ -2243,6 +2380,113 @@ EOF
2243
2380
  chmod 0644 "$DOCKER_GIT_SSHD_CONF" || true`;
2244
2381
  const renderEntrypointSshd = () => `# 5) Run sshd in foreground
2245
2382
  exec /usr/sbin/sshd -D`;
2383
+ const entrypointClaudeGlobalPromptTemplate = String.raw`# Claude Code: managed global memory (CLAUDE.md is auto-loaded by Claude Code)
2384
+ CLAUDE_GLOBAL_PROMPT_FILE="/home/__SSH_USER__/.claude/CLAUDE.md"
2385
+ CLAUDE_AUTO_SYSTEM_PROMPT="${"$"}{CLAUDE_AUTO_SYSTEM_PROMPT:-1}"
2386
+ CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: repository"
2387
+ REPO_REF_VALUE="${"$"}{REPO_REF:-__REPO_REF_DEFAULT__}"
2388
+ REPO_URL_VALUE="${"$"}{REPO_URL:-__REPO_URL_DEFAULT__}"
2389
+
2390
+ if [[ "$REPO_REF_VALUE" == issue-* ]]; then
2391
+ ISSUE_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -E 's#^issue-##')"
2392
+ ISSUE_URL_VALUE=""
2393
+ if [[ "$REPO_URL_VALUE" == https://github.com/* ]]; then
2394
+ ISSUE_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')"
2395
+ if [[ -n "$ISSUE_REPO_VALUE" ]]; then
2396
+ ISSUE_URL_VALUE="https://github.com/$ISSUE_REPO_VALUE/issues/$ISSUE_ID_VALUE"
2397
+ fi
2398
+ fi
2399
+ if [[ -n "$ISSUE_URL_VALUE" ]]; then
2400
+ CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID_VALUE ($ISSUE_URL_VALUE)"
2401
+ else
2402
+ CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID_VALUE"
2403
+ fi
2404
+ elif [[ "$REPO_REF_VALUE" == refs/pull/*/head ]]; then
2405
+ PR_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -nE 's#^refs/pull/([0-9]+)/head$#\1#p')"
2406
+ PR_URL_VALUE=""
2407
+ if [[ "$REPO_URL_VALUE" == https://github.com/* && -n "$PR_ID_VALUE" ]]; then
2408
+ PR_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')"
2409
+ if [[ -n "$PR_REPO_VALUE" ]]; then
2410
+ PR_URL_VALUE="https://github.com/$PR_REPO_VALUE/pull/$PR_ID_VALUE"
2411
+ fi
2412
+ fi
2413
+ if [[ -n "$PR_ID_VALUE" && -n "$PR_URL_VALUE" ]]; then
2414
+ CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID_VALUE ($PR_URL_VALUE)"
2415
+ elif [[ -n "$PR_ID_VALUE" ]]; then
2416
+ CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID_VALUE"
2417
+ else
2418
+ CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: pull request ($REPO_REF_VALUE)"
2419
+ fi
2420
+ fi
2421
+
2422
+ if [[ "$CLAUDE_AUTO_SYSTEM_PROMPT" == "1" ]]; then
2423
+ mkdir -p "$(dirname "$CLAUDE_GLOBAL_PROMPT_FILE")"
2424
+ chown 1000:1000 "$(dirname "$CLAUDE_GLOBAL_PROMPT_FILE")" 2>/dev/null || true
2425
+ if [[ ! -f "$CLAUDE_GLOBAL_PROMPT_FILE" ]] || grep -q "^<!-- docker-git-managed:claude-md -->$" "$CLAUDE_GLOBAL_PROMPT_FILE"; then
2426
+ cat <<EOF > "$CLAUDE_GLOBAL_PROMPT_FILE"
2427
+ <!-- docker-git-managed:claude-md -->
2428
+ Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, codex, opencode, oh-my-opencode, claude, git, node, pnpm и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~
2429
+ Рабочая папка проекта (git clone): __TARGET_DIR__
2430
+ Доступные workspace пути: __TARGET_DIR__
2431
+ $CLAUDE_WORKSPACE_CONTEXT
2432
+ Фокус задачи: работай только в workspace, который запрашивает пользователь. Текущий workspace: __TARGET_DIR__
2433
+ Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе.
2434
+ Если ты видишь файлы AGENTS.md или CLAUDE.md внутри проекта, ты обязан их читать и соблюдать инструкции.
2435
+ <!-- /docker-git-managed:claude-md -->
2436
+ EOF
2437
+ chmod 0644 "$CLAUDE_GLOBAL_PROMPT_FILE" || true
2438
+ chown 1000:1000 "$CLAUDE_GLOBAL_PROMPT_FILE" || true
2439
+ fi
2440
+ fi
2441
+
2442
+ export CLAUDE_AUTO_SYSTEM_PROMPT`;
2443
+ const escapeForDoubleQuotes$1 = (value) => {
2444
+ const backslash = String.fromCodePoint(92);
2445
+ const quote = String.fromCodePoint(34);
2446
+ const escapedBackslash = `${backslash}${backslash}`;
2447
+ const escapedQuote = `${backslash}${quote}`;
2448
+ return value.replaceAll(backslash, escapedBackslash).replaceAll(quote, escapedQuote);
2449
+ };
2450
+ const renderClaudeGlobalPromptSetup = (config) => entrypointClaudeGlobalPromptTemplate.replaceAll("__TARGET_DIR__", config.targetDir).replaceAll("__SSH_USER__", config.sshUser).replaceAll("__REPO_REF_DEFAULT__", escapeForDoubleQuotes$1(config.repoRef)).replaceAll("__REPO_URL_DEFAULT__", escapeForDoubleQuotes$1(config.repoUrl));
2451
+ const renderClaudeWrapperSetup = () => String.raw`CLAUDE_WRAPPER_BIN="/usr/local/bin/claude"
2452
+ if command -v claude >/dev/null 2>&1; then
2453
+ CURRENT_CLAUDE_BIN="$(command -v claude)"
2454
+ CLAUDE_REAL_DIR="$(dirname "$CURRENT_CLAUDE_BIN")"
2455
+ CLAUDE_REAL_BIN="$CLAUDE_REAL_DIR/.docker-git-claude-real"
2456
+
2457
+ # If a wrapper already exists but points to a missing real binary, recover from /usr/bin.
2458
+ if [[ "$CURRENT_CLAUDE_BIN" == "$CLAUDE_WRAPPER_BIN" && ! -e "$CLAUDE_REAL_BIN" && -x "/usr/bin/claude" ]]; then
2459
+ CURRENT_CLAUDE_BIN="/usr/bin/claude"
2460
+ CLAUDE_REAL_DIR="/usr/bin"
2461
+ CLAUDE_REAL_BIN="$CLAUDE_REAL_DIR/.docker-git-claude-real"
2462
+ fi
2463
+
2464
+ # Keep the "real" binary in the same directory as the original command to preserve relative symlinks.
2465
+ if [[ "$CURRENT_CLAUDE_BIN" != "$CLAUDE_REAL_BIN" && ! -e "$CLAUDE_REAL_BIN" ]]; then
2466
+ mv "$CURRENT_CLAUDE_BIN" "$CLAUDE_REAL_BIN"
2467
+ fi
2468
+ if [[ -e "$CLAUDE_REAL_BIN" ]]; then
2469
+ cat <<'EOF' > "$CLAUDE_WRAPPER_BIN"
2470
+ #!/usr/bin/env bash
2471
+ set -euo pipefail
2472
+
2473
+ CLAUDE_REAL_BIN="__CLAUDE_REAL_BIN__"
2474
+ CLAUDE_CONFIG_DIR="${"$"}{CLAUDE_CONFIG_DIR:-$HOME/.claude}"
2475
+ CLAUDE_TOKEN_FILE="$CLAUDE_CONFIG_DIR/.oauth-token"
2476
+
2477
+ if [[ -f "$CLAUDE_TOKEN_FILE" ]]; then
2478
+ CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '\r\n' < "$CLAUDE_TOKEN_FILE")"
2479
+ export CLAUDE_CODE_OAUTH_TOKEN
2480
+ else
2481
+ unset CLAUDE_CODE_OAUTH_TOKEN || true
2482
+ fi
2483
+
2484
+ exec "$CLAUDE_REAL_BIN" "$@"
2485
+ EOF
2486
+ sed -i "s#__CLAUDE_REAL_BIN__#$CLAUDE_REAL_BIN#g" "$CLAUDE_WRAPPER_BIN" || true
2487
+ chmod 0755 "$CLAUDE_WRAPPER_BIN" || true
2488
+ fi
2489
+ fi`;
2246
2490
  const claudeAuthRootContainerPath = (sshUser) => `/home/${sshUser}/.docker-git/.orch/auth/claude`;
2247
2491
  const claudeAuthConfigTemplate = String.raw`# Claude Code: expose CLAUDE_CONFIG_DIR for SSH sessions (OAuth cache lives under ~/.docker-git/.orch/auth/claude)
2248
2492
  CLAUDE_LABEL_RAW="$CLAUDE_AUTH_LABEL"
@@ -2275,6 +2519,17 @@ mkdir -p "$CLAUDE_CONFIG_DIR" || true
2275
2519
  CLAUDE_HOME_DIR="__CLAUDE_HOME_DIR__"
2276
2520
  CLAUDE_HOME_JSON="__CLAUDE_HOME_JSON__"
2277
2521
  mkdir -p "$CLAUDE_HOME_DIR" || true
2522
+ CLAUDE_TOKEN_FILE="$CLAUDE_CONFIG_DIR/.oauth-token"
2523
+ CLAUDE_CREDENTIALS_FILE="$CLAUDE_CONFIG_DIR/.credentials.json"
2524
+ CLAUDE_NESTED_CREDENTIALS_FILE="$CLAUDE_CONFIG_DIR/.claude/.credentials.json"
2525
+
2526
+ docker_git_prepare_claude_auth_mode() {
2527
+ if [[ -s "$CLAUDE_TOKEN_FILE" ]]; then
2528
+ rm -f "$CLAUDE_CREDENTIALS_FILE" "$CLAUDE_NESTED_CREDENTIALS_FILE" "$CLAUDE_HOME_DIR/.credentials.json" || true
2529
+ fi
2530
+ }
2531
+
2532
+ docker_git_prepare_claude_auth_mode
2278
2533
 
2279
2534
  docker_git_link_claude_file() {
2280
2535
  local source_path="$1"
@@ -2302,17 +2557,13 @@ docker_git_link_claude_home_file() {
2302
2557
  docker_git_link_claude_home_file ".oauth-token"
2303
2558
  docker_git_link_claude_home_file ".config.json"
2304
2559
  docker_git_link_claude_home_file ".claude.json"
2305
- docker_git_link_claude_home_file ".credentials.json"
2560
+ if [[ ! -s "$CLAUDE_TOKEN_FILE" ]]; then
2561
+ docker_git_link_claude_home_file ".credentials.json"
2562
+ fi
2306
2563
  docker_git_link_claude_file "$CLAUDE_CONFIG_DIR/.claude.json" "$CLAUDE_HOME_JSON"
2307
2564
 
2308
- CLAUDE_TOKEN_FILE="$CLAUDE_CONFIG_DIR/.oauth-token"
2309
- CLAUDE_CREDENTIALS_FILE="$CLAUDE_CONFIG_DIR/.credentials.json"
2310
2565
  docker_git_refresh_claude_oauth_token() {
2311
2566
  local token=""
2312
- if [[ -s "$CLAUDE_CREDENTIALS_FILE" ]]; then
2313
- unset CLAUDE_CODE_OAUTH_TOKEN || true
2314
- return 0
2315
- fi
2316
2567
  if [[ -f "$CLAUDE_TOKEN_FILE" ]]; then
2317
2568
  token="$(tr -d '\r\n' < "$CLAUDE_TOKEN_FILE")"
2318
2569
  fi
@@ -2366,6 +2617,51 @@ EOF
2366
2617
  }
2367
2618
 
2368
2619
  docker_git_ensure_claude_cli`;
2620
+ const renderClaudePermissionSettingsConfig = () => String.raw`# Claude Code: keep permission settings in sync with docker-git defaults
2621
+ CLAUDE_PERMISSION_SETTINGS_FILE="$CLAUDE_CONFIG_DIR/settings.json"
2622
+ docker_git_sync_claude_permissions() {
2623
+ CLAUDE_PERMISSION_SETTINGS_FILE="$CLAUDE_PERMISSION_SETTINGS_FILE" node - <<'NODE'
2624
+ const fs = require("node:fs")
2625
+ const path = require("node:path")
2626
+
2627
+ const settingsPath = process.env.CLAUDE_PERMISSION_SETTINGS_FILE
2628
+ if (typeof settingsPath !== "string" || settingsPath.length === 0) {
2629
+ process.exit(0)
2630
+ }
2631
+
2632
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value)
2633
+
2634
+ let settings = {}
2635
+ try {
2636
+ const raw = fs.readFileSync(settingsPath, "utf8")
2637
+ const parsed = JSON.parse(raw)
2638
+ settings = isRecord(parsed) ? parsed : {}
2639
+ } catch {
2640
+ settings = {}
2641
+ }
2642
+
2643
+ const currentPermissions = isRecord(settings.permissions) ? settings.permissions : {}
2644
+ const nextPermissions = {
2645
+ ...currentPermissions,
2646
+ defaultMode: "bypassPermissions"
2647
+ }
2648
+ const nextSettings = {
2649
+ ...settings,
2650
+ permissions: nextPermissions
2651
+ }
2652
+
2653
+ if (JSON.stringify(settings) === JSON.stringify(nextSettings)) {
2654
+ process.exit(0)
2655
+ }
2656
+
2657
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true })
2658
+ fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 })
2659
+ NODE
2660
+ }
2661
+
2662
+ docker_git_sync_claude_permissions
2663
+ chmod 0600 "$CLAUDE_PERMISSION_SETTINGS_FILE" 2>/dev/null || true
2664
+ chown 1000:1000 "$CLAUDE_PERMISSION_SETTINGS_FILE" 2>/dev/null || true`;
2369
2665
  const renderClaudeMcpPlaywrightConfig = () => String.raw`# Claude Code: keep Playwright MCP config in sync with container settings
2370
2666
  CLAUDE_SETTINGS_FILE="${"$"}{CLAUDE_HOME_JSON:-$CLAUDE_CONFIG_DIR/.claude.json}"
2371
2667
  docker_git_sync_claude_playwright_mcp() {
@@ -2417,130 +2713,17 @@ if (JSON.stringify(settings) === JSON.stringify(nextSettings)) {
2417
2713
  fs.mkdirSync(path.dirname(settingsPath), { recursive: true })
2418
2714
  fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 })
2419
2715
  NODE
2420
- }
2421
-
2422
- docker_git_sync_claude_playwright_mcp
2423
- chown 1000:1000 "$CLAUDE_SETTINGS_FILE" 2>/dev/null || true`;
2424
- const entrypointClaudeGlobalPromptTemplate = String.raw`# Claude Code: managed global memory (CLAUDE.md is auto-loaded by Claude Code)
2425
- CLAUDE_GLOBAL_PROMPT_FILE="/home/__SSH_USER__/.claude/CLAUDE.md"
2426
- CLAUDE_AUTO_SYSTEM_PROMPT="${"$"}{CLAUDE_AUTO_SYSTEM_PROMPT:-1}"
2427
- CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: repository"
2428
- REPO_REF_VALUE="${"$"}{REPO_REF:-__REPO_REF_DEFAULT__}"
2429
- REPO_URL_VALUE="${"$"}{REPO_URL:-__REPO_URL_DEFAULT__}"
2430
-
2431
- if [[ "$REPO_REF_VALUE" == issue-* ]]; then
2432
- ISSUE_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -E 's#^issue-##')"
2433
- ISSUE_URL_VALUE=""
2434
- if [[ "$REPO_URL_VALUE" == https://github.com/* ]]; then
2435
- ISSUE_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')"
2436
- if [[ -n "$ISSUE_REPO_VALUE" ]]; then
2437
- ISSUE_URL_VALUE="https://github.com/$ISSUE_REPO_VALUE/issues/$ISSUE_ID_VALUE"
2438
- fi
2439
- fi
2440
- if [[ -n "$ISSUE_URL_VALUE" ]]; then
2441
- CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID_VALUE ($ISSUE_URL_VALUE)"
2442
- else
2443
- CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID_VALUE"
2444
- fi
2445
- elif [[ "$REPO_REF_VALUE" == refs/pull/*/head ]]; then
2446
- PR_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -nE 's#^refs/pull/([0-9]+)/head$#\1#p')"
2447
- PR_URL_VALUE=""
2448
- if [[ "$REPO_URL_VALUE" == https://github.com/* && -n "$PR_ID_VALUE" ]]; then
2449
- PR_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')"
2450
- if [[ -n "$PR_REPO_VALUE" ]]; then
2451
- PR_URL_VALUE="https://github.com/$PR_REPO_VALUE/pull/$PR_ID_VALUE"
2452
- fi
2453
- fi
2454
- if [[ -n "$PR_ID_VALUE" && -n "$PR_URL_VALUE" ]]; then
2455
- CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID_VALUE ($PR_URL_VALUE)"
2456
- elif [[ -n "$PR_ID_VALUE" ]]; then
2457
- CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID_VALUE"
2458
- else
2459
- CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: pull request ($REPO_REF_VALUE)"
2460
- fi
2461
- fi
2462
-
2463
- if [[ "$CLAUDE_AUTO_SYSTEM_PROMPT" == "1" ]]; then
2464
- mkdir -p "$(dirname "$CLAUDE_GLOBAL_PROMPT_FILE")"
2465
- chown 1000:1000 "$(dirname "$CLAUDE_GLOBAL_PROMPT_FILE")" 2>/dev/null || true
2466
- if [[ ! -f "$CLAUDE_GLOBAL_PROMPT_FILE" ]] || grep -q "^<!-- docker-git-managed:claude-md -->$" "$CLAUDE_GLOBAL_PROMPT_FILE"; then
2467
- cat <<EOF > "$CLAUDE_GLOBAL_PROMPT_FILE"
2468
- <!-- docker-git-managed:claude-md -->
2469
- Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, codex, opencode, oh-my-opencode, claude, git, node, pnpm и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~
2470
- Рабочая папка проекта (git clone): __TARGET_DIR__
2471
- Доступные workspace пути: __TARGET_DIR__
2472
- $CLAUDE_WORKSPACE_CONTEXT
2473
- Фокус задачи: работай только в workspace, который запрашивает пользователь. Текущий workspace: __TARGET_DIR__
2474
- Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе.
2475
- Если ты видишь файлы AGENTS.md или CLAUDE.md внутри проекта, ты обязан их читать и соблюдать инструкции.
2476
- <!-- /docker-git-managed:claude-md -->
2477
- EOF
2478
- chmod 0644 "$CLAUDE_GLOBAL_PROMPT_FILE" || true
2479
- chown 1000:1000 "$CLAUDE_GLOBAL_PROMPT_FILE" || true
2480
- fi
2481
- fi
2482
-
2483
- export CLAUDE_AUTO_SYSTEM_PROMPT`;
2484
- const escapeForDoubleQuotes$1 = (value) => {
2485
- const backslash = String.fromCodePoint(92);
2486
- const quote = String.fromCodePoint(34);
2487
- const escapedBackslash = `${backslash}${backslash}`;
2488
- const escapedQuote = `${backslash}${quote}`;
2489
- return value.replaceAll(backslash, escapedBackslash).replaceAll(quote, escapedQuote);
2490
- };
2491
- const renderClaudeGlobalPromptSetup = (config) => entrypointClaudeGlobalPromptTemplate.replaceAll("__TARGET_DIR__", config.targetDir).replaceAll("__SSH_USER__", config.sshUser).replaceAll("__REPO_REF_DEFAULT__", escapeForDoubleQuotes$1(config.repoRef)).replaceAll("__REPO_URL_DEFAULT__", escapeForDoubleQuotes$1(config.repoUrl));
2492
- const renderClaudeWrapperSetup = () => String.raw`CLAUDE_WRAPPER_BIN="/usr/local/bin/claude"
2493
- if command -v claude >/dev/null 2>&1; then
2494
- CURRENT_CLAUDE_BIN="$(command -v claude)"
2495
- CLAUDE_REAL_DIR="$(dirname "$CURRENT_CLAUDE_BIN")"
2496
- CLAUDE_REAL_BIN="$CLAUDE_REAL_DIR/.docker-git-claude-real"
2497
-
2498
- # If a wrapper already exists but points to a missing real binary, recover from /usr/bin.
2499
- if [[ "$CURRENT_CLAUDE_BIN" == "$CLAUDE_WRAPPER_BIN" && ! -e "$CLAUDE_REAL_BIN" && -x "/usr/bin/claude" ]]; then
2500
- CURRENT_CLAUDE_BIN="/usr/bin/claude"
2501
- CLAUDE_REAL_DIR="/usr/bin"
2502
- CLAUDE_REAL_BIN="$CLAUDE_REAL_DIR/.docker-git-claude-real"
2503
- fi
2504
-
2505
- # Keep the "real" binary in the same directory as the original command to preserve relative symlinks.
2506
- if [[ "$CURRENT_CLAUDE_BIN" != "$CLAUDE_REAL_BIN" && ! -e "$CLAUDE_REAL_BIN" ]]; then
2507
- mv "$CURRENT_CLAUDE_BIN" "$CLAUDE_REAL_BIN"
2508
- fi
2509
- if [[ -e "$CLAUDE_REAL_BIN" ]]; then
2510
- cat <<'EOF' > "$CLAUDE_WRAPPER_BIN"
2511
- #!/usr/bin/env bash
2512
- set -euo pipefail
2513
-
2514
- CLAUDE_REAL_BIN="__CLAUDE_REAL_BIN__"
2515
- CLAUDE_CONFIG_DIR="${"$"}{CLAUDE_CONFIG_DIR:-$HOME/.claude}"
2516
- CLAUDE_TOKEN_FILE="$CLAUDE_CONFIG_DIR/.oauth-token"
2517
- CLAUDE_CREDENTIALS_FILE="$CLAUDE_CONFIG_DIR/.credentials.json"
2518
-
2519
- if [[ -s "$CLAUDE_CREDENTIALS_FILE" ]]; then
2520
- unset CLAUDE_CODE_OAUTH_TOKEN || true
2521
- elif [[ -f "$CLAUDE_TOKEN_FILE" ]]; then
2522
- CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '\r\n' < "$CLAUDE_TOKEN_FILE")"
2523
- export CLAUDE_CODE_OAUTH_TOKEN
2524
- else
2525
- unset CLAUDE_CODE_OAUTH_TOKEN || true
2526
- fi
2716
+ }
2527
2717
 
2528
- exec "$CLAUDE_REAL_BIN" "$@"
2529
- EOF
2530
- sed -i "s#__CLAUDE_REAL_BIN__#$CLAUDE_REAL_BIN#g" "$CLAUDE_WRAPPER_BIN" || true
2531
- chmod 0755 "$CLAUDE_WRAPPER_BIN" || true
2532
- fi
2533
- fi`;
2718
+ docker_git_sync_claude_playwright_mcp
2719
+ chown 1000:1000 "$CLAUDE_SETTINGS_FILE" 2>/dev/null || true`;
2534
2720
  const renderClaudeProfileSetup = () => String.raw`CLAUDE_PROFILE="/etc/profile.d/claude-config.sh"
2535
2721
  printf "export CLAUDE_AUTH_LABEL=%q\n" "$CLAUDE_AUTH_LABEL" > "$CLAUDE_PROFILE"
2536
2722
  printf "export CLAUDE_CONFIG_DIR=%q\n" "$CLAUDE_CONFIG_DIR" >> "$CLAUDE_PROFILE"
2537
2723
  printf "export CLAUDE_AUTO_SYSTEM_PROMPT=%q\n" "$CLAUDE_AUTO_SYSTEM_PROMPT" >> "$CLAUDE_PROFILE"
2538
2724
  cat <<'EOF' >> "$CLAUDE_PROFILE"
2539
2725
  CLAUDE_TOKEN_FILE="${"$"}{CLAUDE_CONFIG_DIR:-$HOME/.claude}/.oauth-token"
2540
- CLAUDE_CREDENTIALS_FILE="${"$"}{CLAUDE_CONFIG_DIR:-$HOME/.claude}/.credentials.json"
2541
- if [[ -s "$CLAUDE_CREDENTIALS_FILE" ]]; then
2542
- unset CLAUDE_CODE_OAUTH_TOKEN || true
2543
- elif [[ -f "$CLAUDE_TOKEN_FILE" ]]; then
2726
+ if [[ -f "$CLAUDE_TOKEN_FILE" ]]; then
2544
2727
  export CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '\r\n' < "$CLAUDE_TOKEN_FILE")"
2545
2728
  else
2546
2729
  unset CLAUDE_CODE_OAUTH_TOKEN || true
@@ -2555,6 +2738,7 @@ docker_git_upsert_ssh_env "CLAUDE_AUTO_SYSTEM_PROMPT" "$CLAUDE_AUTO_SYSTEM_PROMP
2555
2738
  const renderEntrypointClaudeConfig = (config) => [
2556
2739
  renderClaudeAuthConfig(config),
2557
2740
  renderClaudeCliInstall(),
2741
+ renderClaudePermissionSettingsConfig(),
2558
2742
  renderClaudeMcpPlaywrightConfig(),
2559
2743
  renderClaudeGlobalPromptSetup(config),
2560
2744
  renderClaudeWrapperSetup(),
@@ -3391,6 +3575,164 @@ EOF
3391
3575
  chown 1000:1000 "$OPENCODE_CONFIG_JSON" || true
3392
3576
  fi`;
3393
3577
  const renderEntrypointOpenCodeConfig = (config) => entrypointOpenCodeTemplate.replaceAll("__SSH_USER__", config.sshUser).replaceAll("__CODEX_HOME__", config.codexHome);
3578
+ const indentBlock = (block, size = 2) => {
3579
+ const prefix = " ".repeat(size);
3580
+ return block.split("\n").map((line) => `${prefix}${line}`).join("\n");
3581
+ };
3582
+ const renderAgentPrompt = () => String.raw`AGENT_PROMPT=""
3583
+ ISSUE_NUM=""
3584
+ if [[ "$REPO_REF" =~ ^issue-([0-9]+)$ ]]; then
3585
+ ISSUE_NUM="${"${"}BASH_REMATCH[1]}"
3586
+ fi
3587
+
3588
+ if [[ "$AGENT_AUTO" == "1" ]]; then
3589
+ if [[ -n "$ISSUE_NUM" ]]; then
3590
+ AGENT_PROMPT="Read GitHub issue #$ISSUE_NUM for this repository (use gh issue view $ISSUE_NUM). Implement the requested changes, commit them, create a PR that closes #$ISSUE_NUM, and push it."
3591
+ else
3592
+ AGENT_PROMPT="Analyze this repository, implement any pending tasks, commit changes, create a PR, and push it."
3593
+ fi
3594
+ fi`;
3595
+ const renderAgentSetup = () => [
3596
+ String.raw`AGENT_DONE_PATH="/run/docker-git/agent.done"
3597
+ AGENT_FAIL_PATH="/run/docker-git/agent.failed"
3598
+ AGENT_PROMPT_FILE="/run/docker-git/agent-prompt.txt"
3599
+ rm -f "$AGENT_DONE_PATH" "$AGENT_FAIL_PATH" "$AGENT_PROMPT_FILE"`,
3600
+ String.raw`# Collect tokens for agent environment (su - dev does not always inherit profile.d)
3601
+ AGENT_ENV_FILE="/run/docker-git/agent-env.sh"
3602
+ {
3603
+ [[ -f /etc/profile.d/gh-token.sh ]] && cat /etc/profile.d/gh-token.sh
3604
+ [[ -f /etc/profile.d/claude-config.sh ]] && cat /etc/profile.d/claude-config.sh
3605
+ } > "$AGENT_ENV_FILE" 2>/dev/null || true
3606
+ chmod 644 "$AGENT_ENV_FILE"`,
3607
+ renderAgentPrompt(),
3608
+ String.raw`AGENT_OK=0
3609
+ if [[ -n "$AGENT_PROMPT" ]]; then
3610
+ printf "%s" "$AGENT_PROMPT" > "$AGENT_PROMPT_FILE"
3611
+ chmod 644 "$AGENT_PROMPT_FILE"
3612
+ fi`
3613
+ ].join("\n\n");
3614
+ const renderAgentPromptCommand = (mode) => mode === "claude" ? String.raw`claude --dangerously-skip-permissions -p \"\$(cat $AGENT_PROMPT_FILE)\"` : String.raw`codex --approval-mode full-auto \"\$(cat $AGENT_PROMPT_FILE)\"`;
3615
+ const renderAgentModeBlock = (config, mode) => {
3616
+ const startMessage = `[agent] starting ${mode}...`;
3617
+ const interactiveMessage = `[agent] ${mode} started in interactive mode (use SSH to connect)`;
3618
+ return String.raw`"${mode}")
3619
+ echo "${startMessage}"
3620
+ if [[ -n "$AGENT_PROMPT" ]]; then
3621
+ if su - ${config.sshUser} \
3622
+ -c ". /run/docker-git/agent-env.sh 2>/dev/null; cd '$TARGET_DIR' && ${renderAgentPromptCommand(mode)}"; then
3623
+ AGENT_OK=1
3624
+ fi
3625
+ else
3626
+ echo "${interactiveMessage}"
3627
+ AGENT_OK=1
3628
+ fi
3629
+ ;;`;
3630
+ };
3631
+ const renderAgentModeCase = (config) => [
3632
+ String.raw`case "$AGENT_MODE" in`,
3633
+ indentBlock(renderAgentModeBlock(config, "claude")),
3634
+ indentBlock(renderAgentModeBlock(config, "codex")),
3635
+ indentBlock(
3636
+ String.raw`*)
3637
+ echo "[agent] unknown agent mode: $AGENT_MODE"
3638
+ ;;`
3639
+ ),
3640
+ "esac"
3641
+ ].join("\n");
3642
+ const renderAgentIssueComment = (config) => String.raw`echo "[agent] posting review comment to issue #$ISSUE_NUM..."
3643
+
3644
+ PR_BODY=""
3645
+ PR_BODY=$(su - ${config.sshUser} -c ". /run/docker-git/agent-env.sh 2>/dev/null; cd '$TARGET_DIR' && gh pr list --head '$REPO_REF' --json body --jq '.[0].body'" 2>/dev/null) || true
3646
+
3647
+ if [[ -z "$PR_BODY" ]]; then
3648
+ PR_BODY=$(su - ${config.sshUser} -c ". /run/docker-git/agent-env.sh 2>/dev/null; cd '$TARGET_DIR' && git log --format='%B' -1" 2>/dev/null) || true
3649
+ fi
3650
+
3651
+ if [[ -n "$PR_BODY" ]]; then
3652
+ COMMENT_FILE="/run/docker-git/agent-comment.txt"
3653
+ printf "%s" "$PR_BODY" > "$COMMENT_FILE"
3654
+ chmod 644 "$COMMENT_FILE"
3655
+ su - ${config.sshUser} -c ". /run/docker-git/agent-env.sh 2>/dev/null; cd '$TARGET_DIR' && gh issue comment '$ISSUE_NUM' --body-file '$COMMENT_FILE'" || echo "[agent] failed to comment on issue #$ISSUE_NUM"
3656
+ else
3657
+ echo "[agent] no PR body or commit message found, skipping comment"
3658
+ fi`;
3659
+ const renderProjectMoveScript = () => String.raw`#!/bin/bash
3660
+ . /run/docker-git/agent-env.sh 2>/dev/null || true
3661
+ cd "$1" || exit 1
3662
+ ISSUE_NUM="$2"
3663
+
3664
+ ISSUE_NODE_ID=$(gh issue view "$ISSUE_NUM" --json id --jq '.id' 2>/dev/null) || true
3665
+ if [[ -z "$ISSUE_NODE_ID" ]]; then
3666
+ echo "[agent] could not get issue node ID, skipping move"
3667
+ exit 0
3668
+ fi
3669
+
3670
+ GQL_QUERY='query($nodeId: ID!) { node(id: $nodeId) { ... on Issue { projectItems(first: 1) { nodes { id project { id field(name: "Status") { ... on ProjectV2SingleSelectField { id options { id name } } } } } } } } }'
3671
+ ALL_IDS=$(gh api graphql -F nodeId="$ISSUE_NODE_ID" -f query="$GQL_QUERY" \
3672
+ --jq '(.data.node.projectItems.nodes // [])[0] // empty | [.id, .project.id, .project.field.id, ([.project.field.options[] | select(.name | test("review"; "i"))][0].id)] | @tsv' 2>/dev/null) || true
3673
+
3674
+ if [[ -z "$ALL_IDS" ]]; then
3675
+ echo "[agent] issue #$ISSUE_NUM is not in a project board, skipping move"
3676
+ exit 0
3677
+ fi
3678
+
3679
+ ITEM_ID=$(printf "%s" "$ALL_IDS" | cut -f1)
3680
+ PROJECT_ID=$(printf "%s" "$ALL_IDS" | cut -f2)
3681
+ STATUS_FIELD_ID=$(printf "%s" "$ALL_IDS" | cut -f3)
3682
+ REVIEW_OPTION_ID=$(printf "%s" "$ALL_IDS" | cut -f4)
3683
+ if [[ -z "$STATUS_FIELD_ID" || -z "$REVIEW_OPTION_ID" || "$STATUS_FIELD_ID" == "null" || "$REVIEW_OPTION_ID" == "null" ]]; then
3684
+ echo "[agent] review status not found in project board, skipping move"
3685
+ exit 0
3686
+ fi
3687
+
3688
+ MUTATION='mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { updateProjectV2ItemFieldValue(input: { projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { singleSelectOptionId: $optionId } }) { projectV2Item { id } } }'
3689
+ MOVE_RESULT=$(gh api graphql \
3690
+ -F projectId="$PROJECT_ID" \
3691
+ -F itemId="$ITEM_ID" \
3692
+ -F fieldId="$STATUS_FIELD_ID" \
3693
+ -F optionId="$REVIEW_OPTION_ID" \
3694
+ -f query="$MUTATION" 2>&1) || true
3695
+
3696
+ if [[ "$MOVE_RESULT" == *"projectV2Item"* ]]; then
3697
+ echo "[agent] issue #$ISSUE_NUM moved to review"
3698
+ else
3699
+ echo "[agent] failed to move issue #$ISSUE_NUM in project board"
3700
+ fi`;
3701
+ const renderAgentIssueMove = (config) => [
3702
+ String.raw`echo "[agent] moving issue #$ISSUE_NUM to review..."
3703
+ MOVE_SCRIPT="/run/docker-git/project-move.sh"`,
3704
+ String.raw`cat > "$MOVE_SCRIPT" << 'EOFMOVE'
3705
+ ${renderProjectMoveScript()}
3706
+ EOFMOVE`,
3707
+ String.raw`chmod +x "$MOVE_SCRIPT"
3708
+ su - ${config.sshUser} -c "$MOVE_SCRIPT '$TARGET_DIR' '$ISSUE_NUM'" || true`
3709
+ ].join("\n");
3710
+ const renderAgentIssueReview = (config) => [
3711
+ String.raw`if [[ "$AGENT_OK" -eq 1 && "$AGENT_AUTO" == "1" && -n "$ISSUE_NUM" ]]; then`,
3712
+ indentBlock(renderAgentIssueComment(config)),
3713
+ "",
3714
+ renderAgentIssueMove(config),
3715
+ "fi"
3716
+ ].join("\n");
3717
+ const renderAgentFinalize = () => String.raw`if [[ "$AGENT_OK" -eq 1 ]]; then
3718
+ echo "[agent] done"
3719
+ touch "$AGENT_DONE_PATH"
3720
+ else
3721
+ echo "[agent] failed"
3722
+ touch "$AGENT_FAIL_PATH"
3723
+ fi`;
3724
+ const renderAgentLaunch = (config) => [
3725
+ String.raw`# 3) Auto-launch agent if AGENT_MODE is set
3726
+ if [[ "$CLONE_OK" -eq 1 && -n "$AGENT_MODE" ]]; then`,
3727
+ indentBlock(renderAgentSetup()),
3728
+ "",
3729
+ indentBlock(renderAgentModeCase(config)),
3730
+ "",
3731
+ renderAgentIssueReview(config),
3732
+ "",
3733
+ indentBlock(renderAgentFinalize()),
3734
+ "fi"
3735
+ ].join("\n");
3394
3736
  const renderEntrypointAutoUpdate = () => `# 1) Keep Codex CLI up to date if requested (bun only)
3395
3737
  if [[ "$CODEX_AUTO_UPDATE" == "1" ]]; then
3396
3738
  if command -v bun >/dev/null 2>&1; then
@@ -3567,6 +3909,8 @@ const renderEntrypointBackgroundTasks = (config) => `# 4) Start background tasks
3567
3909
  ${renderEntrypointAutoUpdate()}
3568
3910
 
3569
3911
  ${renderEntrypointClone(config)}
3912
+
3913
+ ${renderAgentLaunch(config)}
3570
3914
  ) &`;
3571
3915
  const renderEntrypoint = (config) => [
3572
3916
  renderEntrypointHeader(config),
@@ -3602,6 +3946,10 @@ const renderCodexAuthLabelEnv = (codexAuthLabel) => codexAuthLabel.length > 0 ?
3602
3946
  ` : "";
3603
3947
  const renderClaudeAuthLabelEnv = (claudeAuthLabel) => claudeAuthLabel.length > 0 ? ` CLAUDE_AUTH_LABEL: "${claudeAuthLabel}"
3604
3948
  ` : "";
3949
+ const renderAgentModeEnv = (agentMode) => agentMode !== void 0 && agentMode.length > 0 ? ` AGENT_MODE: "${agentMode}"
3950
+ ` : "";
3951
+ const renderAgentAutoEnv = (agentAuto) => agentAuto === true ? ` AGENT_AUTO: "1"
3952
+ ` : "";
3605
3953
  const buildPlaywrightFragments = (config, networkName) => {
3606
3954
  if (!config.enableMcpPlaywright) {
3607
3955
  return {
@@ -3654,6 +4002,8 @@ const buildComposeFragments = (config) => {
3654
4002
  const maybeGitTokenLabelEnv = renderGitTokenLabelEnv(gitTokenLabel);
3655
4003
  const maybeCodexAuthLabelEnv = renderCodexAuthLabelEnv(codexAuthLabel);
3656
4004
  const maybeClaudeAuthLabelEnv = renderClaudeAuthLabelEnv(claudeAuthLabel);
4005
+ const maybeAgentModeEnv = renderAgentModeEnv(config.agentMode);
4006
+ const maybeAgentAutoEnv = renderAgentAutoEnv(config.agentAuto);
3657
4007
  const playwright = buildPlaywrightFragments(config, networkName);
3658
4008
  return {
3659
4009
  networkMode,
@@ -3661,6 +4011,8 @@ const buildComposeFragments = (config) => {
3661
4011
  maybeGitTokenLabelEnv,
3662
4012
  maybeCodexAuthLabelEnv,
3663
4013
  maybeClaudeAuthLabelEnv,
4014
+ maybeAgentModeEnv,
4015
+ maybeAgentAutoEnv,
3664
4016
  maybeDependsOn: playwright.maybeDependsOn,
3665
4017
  maybePlaywrightEnv: playwright.maybePlaywrightEnv,
3666
4018
  maybeBrowserService: playwright.maybeBrowserService,
@@ -3679,7 +4031,7 @@ const renderComposeServices = (config, fragments) => `services:
3679
4031
  FORK_REPO_URL: "${fragments.forkRepoUrl}"
3680
4032
  ${fragments.maybeGitTokenLabelEnv} # Optional token label selector (maps to GITHUB_TOKEN__<LABEL>/GIT_AUTH_TOKEN__<LABEL>)
3681
4033
  ${fragments.maybeCodexAuthLabelEnv} # Optional Codex account label selector (maps to CODEX_AUTH_LABEL)
3682
- ${fragments.maybeClaudeAuthLabelEnv} # Optional Claude account label selector (maps to CLAUDE_AUTH_LABEL)
4034
+ ${fragments.maybeClaudeAuthLabelEnv}${fragments.maybeAgentModeEnv}${fragments.maybeAgentAutoEnv} # Optional Claude account label selector (maps to CLAUDE_AUTH_LABEL)
3683
4035
  TARGET_DIR: "${config.targetDir}"
3684
4036
  CODEX_HOME: "${config.codexHome}"
3685
4037
  ${fragments.maybePlaywrightEnv}${fragments.maybeDependsOn} env_file:
@@ -4668,70 +5020,6 @@ const parseJsonRecord = (text) => Either.match(ParseResult.decodeUnknownEither(J
4668
5020
  const hasClaudeOauthAccount = (record) => record !== null && typeof record["oauthAccount"] === "object" && record["oauthAccount"] !== null;
4669
5021
  const hasClaudeCredentials = (record) => record !== null && typeof record["claudeAiOauth"] === "object" && record["claudeAiOauth"] !== null;
4670
5022
  const isGithubTokenKey = (key) => key === "GITHUB_TOKEN" || key === "GH_TOKEN" || key.startsWith("GITHUB_TOKEN__");
4671
- const syncGithubAuthKeys = (sourceText, targetText) => {
4672
- const sourceTokenEntries = parseEnvEntries(sourceText).filter((entry) => isGithubTokenKey(entry.key));
4673
- if (sourceTokenEntries.length === 0) {
4674
- return targetText;
4675
- }
4676
- const targetTokenKeys = parseEnvEntries(targetText).filter((entry) => isGithubTokenKey(entry.key)).map((entry) => entry.key);
4677
- let next = targetText;
4678
- for (const key of targetTokenKeys) {
4679
- next = removeEnvKey(next, key);
4680
- }
4681
- for (const entry of sourceTokenEntries) {
4682
- next = upsertEnvKey(next, entry.key, entry.value);
4683
- }
4684
- return next;
4685
- };
4686
- const syncGithubTokenKeysInFile = (sourcePath, targetPath) => withFsPathContext(
4687
- ({ fs }) => Effect.gen(function* (_) {
4688
- const sourceExists = yield* _(fs.exists(sourcePath));
4689
- if (!sourceExists) {
4690
- return;
4691
- }
4692
- const targetExists = yield* _(fs.exists(targetPath));
4693
- if (!targetExists) {
4694
- return;
4695
- }
4696
- const sourceInfo = yield* _(fs.stat(sourcePath));
4697
- const targetInfo = yield* _(fs.stat(targetPath));
4698
- if (sourceInfo.type !== "File" || targetInfo.type !== "File") {
4699
- return;
4700
- }
4701
- const sourceText = yield* _(fs.readFileString(sourcePath));
4702
- const targetText = yield* _(fs.readFileString(targetPath));
4703
- const mergedText = syncGithubAuthKeys(sourceText, targetText);
4704
- if (mergedText !== targetText) {
4705
- yield* _(fs.writeFileString(targetPath, mergedText));
4706
- yield* _(Effect.log(`Synced GitHub auth keys from ${sourcePath} to ${targetPath}`));
4707
- }
4708
- })
4709
- );
4710
- const copyFileIfNeeded = (sourcePath, targetPath) => withFsPathContext(
4711
- ({ fs, path }) => Effect.gen(function* (_) {
4712
- const sourceExists = yield* _(fs.exists(sourcePath));
4713
- if (!sourceExists) {
4714
- return;
4715
- }
4716
- const sourceInfo = yield* _(fs.stat(sourcePath));
4717
- if (sourceInfo.type !== "File") {
4718
- return;
4719
- }
4720
- yield* _(fs.makeDirectory(path.dirname(targetPath), { recursive: true }));
4721
- const targetExists = yield* _(fs.exists(targetPath));
4722
- if (!targetExists) {
4723
- yield* _(fs.copyFile(sourcePath, targetPath));
4724
- yield* _(Effect.log(`Copied env file from ${sourcePath} to ${targetPath}`));
4725
- return;
4726
- }
4727
- const sourceText = yield* _(fs.readFileString(sourcePath));
4728
- const targetText = yield* _(fs.readFileString(targetPath));
4729
- if (shouldCopyEnv(sourceText, targetText) === "copy") {
4730
- yield* _(fs.writeFileString(targetPath, sourceText));
4731
- yield* _(Effect.log(`Synced env file from ${sourcePath} to ${targetPath}`));
4732
- }
4733
- })
4734
- );
4735
5023
  const syncClaudeJsonFile = (fs, path, spec) => Effect.gen(function* (_) {
4736
5024
  const sourceExists = yield* _(fs.exists(spec.sourcePath));
4737
5025
  if (!sourceExists) {
@@ -4782,6 +5070,18 @@ const syncClaudeCredentialsJson = (fs, path, sourcePath, targetPath) => syncClau
4782
5070
  seedLabel: "Claude credentials",
4783
5071
  updateLabel: "Claude credentials"
4784
5072
  });
5073
+ const hasNonEmptyFile = (fs, filePath) => Effect.gen(function* (_) {
5074
+ const exists = yield* _(fs.exists(filePath));
5075
+ if (!exists) {
5076
+ return false;
5077
+ }
5078
+ const info = yield* _(fs.stat(filePath));
5079
+ if (info.type !== "File") {
5080
+ return false;
5081
+ }
5082
+ const text = yield* _(fs.readFileString(filePath), Effect.orElseSucceed(() => ""));
5083
+ return text.trim().length > 0;
5084
+ });
4785
5085
  const ensureClaudeAuthSeedFromHome = (baseDir, claudeAuthPath) => withFsPathContext(
4786
5086
  ({ fs, path }) => Effect.gen(function* (_) {
4787
5087
  const homeDir = (process.env["HOME"] ?? "").trim();
@@ -4793,10 +5093,78 @@ const ensureClaudeAuthSeedFromHome = (baseDir, claudeAuthPath) => withFsPathCont
4793
5093
  const claudeRoot = resolvePathFromBase$1(path, baseDir, claudeAuthPath);
4794
5094
  const targetAccountDir = path.join(claudeRoot, "default");
4795
5095
  const targetClaudeJson = path.join(targetAccountDir, ".claude.json");
5096
+ const targetOauthToken = path.join(targetAccountDir, ".oauth-token");
4796
5097
  const targetCredentials = path.join(targetAccountDir, ".credentials.json");
5098
+ const hasTargetOauthToken = yield* _(hasNonEmptyFile(fs, targetOauthToken));
4797
5099
  yield* _(fs.makeDirectory(targetAccountDir, { recursive: true }));
4798
5100
  yield* _(syncClaudeHomeJson(fs, path, sourceClaudeJson, targetClaudeJson));
4799
- yield* _(syncClaudeCredentialsJson(fs, path, sourceCredentials, targetCredentials));
5101
+ if (!hasTargetOauthToken) {
5102
+ yield* _(syncClaudeCredentialsJson(fs, path, sourceCredentials, targetCredentials));
5103
+ }
5104
+ })
5105
+ );
5106
+ const syncGithubAuthKeys = (sourceText, targetText) => {
5107
+ const sourceTokenEntries = parseEnvEntries(sourceText).filter((entry) => isGithubTokenKey(entry.key));
5108
+ if (sourceTokenEntries.length === 0) {
5109
+ return targetText;
5110
+ }
5111
+ const targetTokenKeys = parseEnvEntries(targetText).filter((entry) => isGithubTokenKey(entry.key)).map((entry) => entry.key);
5112
+ let next = targetText;
5113
+ for (const key of targetTokenKeys) {
5114
+ next = removeEnvKey(next, key);
5115
+ }
5116
+ for (const entry of sourceTokenEntries) {
5117
+ next = upsertEnvKey(next, entry.key, entry.value);
5118
+ }
5119
+ return next;
5120
+ };
5121
+ const syncGithubTokenKeysInFile = (sourcePath, targetPath) => withFsPathContext(
5122
+ ({ fs }) => Effect.gen(function* (_) {
5123
+ const sourceExists = yield* _(fs.exists(sourcePath));
5124
+ if (!sourceExists) {
5125
+ return;
5126
+ }
5127
+ const targetExists = yield* _(fs.exists(targetPath));
5128
+ if (!targetExists) {
5129
+ return;
5130
+ }
5131
+ const sourceInfo = yield* _(fs.stat(sourcePath));
5132
+ const targetInfo = yield* _(fs.stat(targetPath));
5133
+ if (sourceInfo.type !== "File" || targetInfo.type !== "File") {
5134
+ return;
5135
+ }
5136
+ const sourceText = yield* _(fs.readFileString(sourcePath));
5137
+ const targetText = yield* _(fs.readFileString(targetPath));
5138
+ const mergedText = syncGithubAuthKeys(sourceText, targetText);
5139
+ if (mergedText !== targetText) {
5140
+ yield* _(fs.writeFileString(targetPath, mergedText));
5141
+ yield* _(Effect.log(`Synced GitHub auth keys from ${sourcePath} to ${targetPath}`));
5142
+ }
5143
+ })
5144
+ );
5145
+ const copyFileIfNeeded = (sourcePath, targetPath) => withFsPathContext(
5146
+ ({ fs, path }) => Effect.gen(function* (_) {
5147
+ const sourceExists = yield* _(fs.exists(sourcePath));
5148
+ if (!sourceExists) {
5149
+ return;
5150
+ }
5151
+ const sourceInfo = yield* _(fs.stat(sourcePath));
5152
+ if (sourceInfo.type !== "File") {
5153
+ return;
5154
+ }
5155
+ yield* _(fs.makeDirectory(path.dirname(targetPath), { recursive: true }));
5156
+ const targetExists = yield* _(fs.exists(targetPath));
5157
+ if (!targetExists) {
5158
+ yield* _(fs.copyFile(sourcePath, targetPath));
5159
+ yield* _(Effect.log(`Copied env file from ${sourcePath} to ${targetPath}`));
5160
+ return;
5161
+ }
5162
+ const sourceText = yield* _(fs.readFileString(sourcePath));
5163
+ const targetText = yield* _(fs.readFileString(targetPath));
5164
+ if (shouldCopyEnv(sourceText, targetText) === "copy") {
5165
+ yield* _(fs.writeFileString(targetPath, sourceText));
5166
+ yield* _(Effect.log(`Synced env file from ${sourcePath} to ${targetPath}`));
5167
+ }
4800
5168
  })
4801
5169
  );
4802
5170
  const ensureCodexConfigFile = (baseDir, codexAuthPath) => withFsPathContext(
@@ -5214,8 +5582,11 @@ const listProjectStatus = Effect.asVoid(
5214
5582
  )
5215
5583
  );
5216
5584
  const clonePollInterval = Duration.seconds(1);
5585
+ const agentPollInterval = Duration.seconds(2);
5217
5586
  const cloneDonePath = "/run/docker-git/clone.done";
5218
5587
  const cloneFailPath = "/run/docker-git/clone.failed";
5588
+ const agentDonePath = "/run/docker-git/agent.done";
5589
+ const agentFailPath = "/run/docker-git/agent.failed";
5219
5590
  const logSshAccess = (baseDir, config) => Effect.gen(function* (_) {
5220
5591
  const fs = yield* _(FileSystem.FileSystem);
5221
5592
  const path = yield* _(Path.Path);
@@ -5274,6 +5645,47 @@ const waitForCloneCompletion = (cwd, config) => Effect.gen(function* (_) {
5274
5645
  );
5275
5646
  }
5276
5647
  });
5648
+ const checkAgentState = (cwd, containerName) => Effect.gen(function* (_) {
5649
+ const failed = yield* _(runDockerExecExitCode(cwd, containerName, ["test", "-f", agentFailPath]));
5650
+ if (failed === 0) {
5651
+ return "failed";
5652
+ }
5653
+ const done = yield* _(runDockerExecExitCode(cwd, containerName, ["test", "-f", agentDonePath]));
5654
+ return done === 0 ? "done" : "pending";
5655
+ });
5656
+ const waitForAgentCompletion = (cwd, config) => Effect.gen(function* (_) {
5657
+ const logsFiber = yield* _(
5658
+ runDockerComposeLogsFollow(cwd).pipe(
5659
+ Effect.tapError(
5660
+ (error) => Effect.logWarning(
5661
+ `docker compose logs --follow failed: ${error instanceof Error ? error.message : String(error)}`
5662
+ )
5663
+ ),
5664
+ Effect.fork
5665
+ )
5666
+ );
5667
+ const result = yield* _(
5668
+ checkAgentState(cwd, config.containerName).pipe(
5669
+ Effect.repeat(
5670
+ Schedule.addDelay(
5671
+ Schedule.recurUntil((state) => state !== "pending"),
5672
+ () => agentPollInterval
5673
+ )
5674
+ )
5675
+ )
5676
+ );
5677
+ yield* _(Fiber$1.interrupt(logsFiber));
5678
+ if (result === "failed") {
5679
+ return yield* _(
5680
+ Effect.fail(
5681
+ new AgentFailedError({
5682
+ agentMode: config.agentMode ?? "unknown",
5683
+ targetDir: config.targetDir
5684
+ })
5685
+ )
5686
+ );
5687
+ }
5688
+ });
5277
5689
  const runDockerComposeUpByMode = (resolvedOutDir, projectConfig, force, forceEnv) => Effect.gen(function* (_) {
5278
5690
  yield* _(ensureComposeNetworkReady(resolvedOutDir, projectConfig));
5279
5691
  if (force) {
@@ -5318,9 +5730,16 @@ const runDockerUpIfNeeded = (resolvedOutDir, projectConfig, options) => Effect.g
5318
5730
  yield* _(Effect.log("Streaming container logs until clone completes..."));
5319
5731
  yield* _(waitForCloneCompletion(resolvedOutDir, projectConfig));
5320
5732
  }
5733
+ if (options.waitForAgent) {
5734
+ yield* _(Effect.log("Waiting for agent to complete..."));
5735
+ yield* _(waitForAgentCompletion(resolvedOutDir, projectConfig));
5736
+ }
5321
5737
  yield* _(Effect.log("Docker environment is up"));
5322
5738
  yield* _(logSshAccess(resolvedOutDir, projectConfig));
5323
5739
  });
5740
+ const runDockerDownCleanup = (resolvedOutDir) => runDockerComposeDownVolumes(resolvedOutDir).pipe(
5741
+ Effect.tap(() => Effect.log("Container and volumes removed."))
5742
+ );
5324
5743
  const resolvePathFromBase = (path, baseDir, targetPath) => path.isAbsolute(targetPath) ? targetPath : path.resolve(baseDir, targetPath);
5325
5744
  const toPosixPath = (value) => value.replaceAll("\\", "/");
5326
5745
  const resolveDockerGitRootRelativePath = (path, projectsRoot, inputPath) => {
@@ -5519,7 +5938,7 @@ const formatStateSyncLabel = (repoUrl) => {
5519
5938
  return repoPath.length > 0 ? repoPath : repoUrl;
5520
5939
  };
5521
5940
  const isInteractiveTty = () => process.stdin.isTTY && process.stdout.isTTY;
5522
- const buildSshArgs = (config, sshKeyPath) => {
5941
+ const buildSshArgs = (config, sshKeyPath, remoteCommand) => {
5523
5942
  const args = [];
5524
5943
  if (sshKeyPath !== null) {
5525
5944
  args.push("-i", sshKeyPath);
@@ -5537,21 +5956,25 @@ const buildSshArgs = (config, sshKeyPath) => {
5537
5956
  String(config.sshPort),
5538
5957
  `${config.sshUser}@localhost`
5539
5958
  );
5959
+ if (remoteCommand !== void 0) {
5960
+ args.push(remoteCommand);
5961
+ }
5540
5962
  return args;
5541
5963
  };
5542
- const openSshBestEffort = (template) => Effect.gen(function* (_) {
5964
+ const openSshBestEffort = (template, remoteCommand) => Effect.gen(function* (_) {
5543
5965
  const fs = yield* _(FileSystem.FileSystem);
5544
5966
  const path = yield* _(Path.Path);
5545
5967
  const sshKey = yield* _(findSshPrivateKey(fs, path, process.cwd()));
5546
5968
  const sshCommand = buildSshCommand(template, sshKey);
5547
- yield* _(Effect.log(`Opening SSH: ${sshCommand}`));
5969
+ const remoteCommandLabel = remoteCommand === void 0 ? "" : ` (${remoteCommand})`;
5970
+ yield* _(Effect.log(`Opening SSH: ${sshCommand}${remoteCommandLabel}`));
5548
5971
  yield* _(ensureTerminalCursorVisible());
5549
5972
  yield* _(
5550
5973
  runCommandWithExitCodes(
5551
5974
  {
5552
5975
  cwd: process.cwd(),
5553
5976
  command: "ssh",
5554
- args: buildSshArgs(template, sshKey)
5977
+ args: buildSshArgs(template, sshKey, remoteCommand)
5555
5978
  },
5556
5979
  [0, 130],
5557
5980
  (exitCode) => new CommandFailedError({ command: "ssh", exitCode })
@@ -5564,6 +5987,23 @@ const openSshBestEffort = (template) => Effect.gen(function* (_) {
5564
5987
  onSuccess: () => Effect.void
5565
5988
  })
5566
5989
  );
5990
+ const resolveInteractiveRemoteCommand = (projectConfig, interactiveAgent) => interactiveAgent && projectConfig.agentMode !== void 0 ? `cd '${projectConfig.targetDir}' && ${projectConfig.agentMode}` : void 0;
5991
+ const maybeOpenSsh = (command, hasAgent, waitForAgent, projectConfig) => Effect.gen(function* (_) {
5992
+ const interactiveAgent = hasAgent && !waitForAgent;
5993
+ if (!command.openSsh || hasAgent && !interactiveAgent) {
5994
+ return;
5995
+ }
5996
+ if (!command.runUp) {
5997
+ yield* _(Effect.logWarning("Skipping SSH auto-open: docker compose up disabled (--no-up)."));
5998
+ return;
5999
+ }
6000
+ if (!isInteractiveTty()) {
6001
+ yield* _(Effect.logWarning("Skipping SSH auto-open: not running in an interactive TTY."));
6002
+ return;
6003
+ }
6004
+ const remoteCommand = resolveInteractiveRemoteCommand(projectConfig, interactiveAgent);
6005
+ yield* _(openSshBestEffort(projectConfig, remoteCommand));
6006
+ }).pipe(Effect.asVoid);
5567
6007
  const runCreateProject = (path, command) => Effect.gen(function* (_) {
5568
6008
  if (command.runUp) {
5569
6009
  yield* _(ensureDockerDaemonAccess(process.cwd()));
@@ -5580,10 +6020,13 @@ const runCreateProject = (path, command) => Effect.gen(function* (_) {
5580
6020
  })
5581
6021
  );
5582
6022
  yield* _(logCreatedProject(resolvedOutDir, createdFiles));
6023
+ const hasAgent = resolvedConfig.agentMode !== void 0;
6024
+ const waitForAgent = hasAgent && (resolvedConfig.agentAuto ?? false);
5583
6025
  yield* _(
5584
6026
  runDockerUpIfNeeded(resolvedOutDir, projectConfig, {
5585
6027
  runUp: command.runUp,
5586
6028
  waitForClone: command.waitForClone,
6029
+ waitForAgent,
5587
6030
  force: command.force,
5588
6031
  forceEnv: command.forceEnv
5589
6032
  })
@@ -5591,16 +6034,12 @@ const runCreateProject = (path, command) => Effect.gen(function* (_) {
5591
6034
  if (command.runUp) {
5592
6035
  yield* _(logDockerAccessInfo(resolvedOutDir, projectConfig));
5593
6036
  }
5594
- yield* _(autoSyncState(`chore(state): update ${formatStateSyncLabel(projectConfig.repoUrl)}`));
5595
- if (command.openSsh) {
5596
- if (!command.runUp) {
5597
- yield* _(Effect.logWarning("Skipping SSH auto-open: docker compose up disabled (--no-up)."));
5598
- } else if (isInteractiveTty()) {
5599
- yield* _(openSshBestEffort(projectConfig));
5600
- } else {
5601
- yield* _(Effect.logWarning("Skipping SSH auto-open: not running in an interactive TTY."));
5602
- }
6037
+ if (waitForAgent) {
6038
+ yield* _(Effect.log("Agent finished. Cleaning up container..."));
6039
+ yield* _(runDockerDownCleanup(resolvedOutDir));
5603
6040
  }
6041
+ yield* _(autoSyncState(`chore(state): update ${formatStateSyncLabel(projectConfig.repoUrl)}`));
6042
+ yield* _(maybeOpenSsh(command, hasAgent, waitForAgent, projectConfig));
5604
6043
  }).pipe(Effect.asVoid);
5605
6044
  const createProject = (command) => Path.Path.pipe(Effect.flatMap((path) => runCreateProject(path, command)));
5606
6045
  const trimEdgeUnderscores = (value) => {
@@ -6057,7 +6496,8 @@ const runClaudeOauthLoginWithPrompt = (cwd, accountPath, options) => {
6057
6496
  return Effect.scoped(
6058
6497
  Effect.gen(function* (_) {
6059
6498
  const executor = yield* _(CommandExecutor.CommandExecutor);
6060
- const spec = buildDockerSetupTokenSpec(cwd, accountPath, options.image, options.containerPath);
6499
+ const hostPath = yield* _(resolveDockerVolumeHostPath(cwd, accountPath));
6500
+ const spec = buildDockerSetupTokenSpec(cwd, hostPath, options.image, options.containerPath);
6061
6501
  const proc = yield* _(startDockerProcess(executor, spec));
6062
6502
  const tokenBox = { value: null };
6063
6503
  const stdoutFiber = yield* _(Effect.forkScoped(pumpDockerOutput(proc.stdout, 1, tokenBox)));
@@ -6106,6 +6546,10 @@ const syncClaudeCredentialsFile = (fs, accountPath) => Effect.gen(function* (_)
6106
6546
  yield* _(fs.chmod(nestedPath, 384), Effect.orElseSucceed(() => void 0));
6107
6547
  }
6108
6548
  });
6549
+ const clearClaudeSessionCredentials = (fs, accountPath) => Effect.gen(function* (_) {
6550
+ yield* _(fs.remove(claudeCredentialsPath(accountPath), { force: true }));
6551
+ yield* _(fs.remove(claudeNestedCredentialsPath(accountPath), { force: true }));
6552
+ });
6109
6553
  const hasNonEmptyOauthToken$1 = (fs, accountPath) => Effect.gen(function* (_) {
6110
6554
  const tokenPath = claudeOauthTokenPath(accountPath);
6111
6555
  const hasToken = yield* _(isRegularFile(fs, tokenPath));
@@ -6115,7 +6559,30 @@ const hasNonEmptyOauthToken$1 = (fs, accountPath) => Effect.gen(function* (_) {
6115
6559
  const tokenText = yield* _(fs.readFileString(tokenPath), Effect.orElseSucceed(() => ""));
6116
6560
  return tokenText.trim().length > 0;
6117
6561
  });
6118
- const buildClaudeAuthEnv = (interactive) => interactive ? [`HOME=${claudeContainerHomeDir}`, `CLAUDE_CONFIG_DIR=${claudeContainerHomeDir}`, "BROWSER=echo"] : [`HOME=${claudeContainerHomeDir}`, `CLAUDE_CONFIG_DIR=${claudeContainerHomeDir}`];
6562
+ const readOauthToken = (fs, accountPath) => Effect.gen(function* (_) {
6563
+ const tokenPath = claudeOauthTokenPath(accountPath);
6564
+ const hasToken = yield* _(isRegularFile(fs, tokenPath));
6565
+ if (!hasToken) {
6566
+ return null;
6567
+ }
6568
+ const tokenText = yield* _(fs.readFileString(tokenPath), Effect.orElseSucceed(() => ""));
6569
+ const token = tokenText.trim();
6570
+ return token.length > 0 ? token : null;
6571
+ });
6572
+ const resolveClaudeAuthMethod = (fs, accountPath) => Effect.gen(function* (_) {
6573
+ const hasOauthToken = yield* _(hasNonEmptyOauthToken$1(fs, accountPath));
6574
+ if (hasOauthToken) {
6575
+ yield* _(clearClaudeSessionCredentials(fs, accountPath));
6576
+ return "oauth-token";
6577
+ }
6578
+ yield* _(syncClaudeCredentialsFile(fs, accountPath));
6579
+ const hasCredentials = yield* _(isRegularFile(fs, claudeCredentialsPath(accountPath)));
6580
+ return hasCredentials ? "claude-ai-session" : "none";
6581
+ });
6582
+ const buildClaudeAuthEnv = (interactive, oauthToken = null) => [
6583
+ ...[`HOME=${claudeContainerHomeDir}`, `CLAUDE_CONFIG_DIR=${claudeContainerHomeDir}`],
6584
+ ...oauthToken === null ? [] : [`CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`]
6585
+ ];
6119
6586
  const ensureClaudeOrchLayout = (cwd) => migrateLegacyOrchLayout(cwd, {
6120
6587
  envGlobalPath: defaultTemplateConfig.envGlobalPath,
6121
6588
  envProjectPath: defaultTemplateConfig.envProjectPath,
@@ -6164,7 +6631,7 @@ const runClaudeAuthCommand = (cwd, accountPath, args, commandLabel, interactive)
6164
6631
  image: claudeImageName,
6165
6632
  hostPath: accountPath,
6166
6633
  containerPath: claudeContainerHomeDir,
6167
- env: buildClaudeAuthEnv(interactive),
6634
+ env: buildClaudeAuthEnv(),
6168
6635
  args,
6169
6636
  interactive
6170
6637
  }),
@@ -6172,13 +6639,13 @@ const runClaudeAuthCommand = (cwd, accountPath, args, commandLabel, interactive)
6172
6639
  (exitCode) => new CommandFailedError({ command: commandLabel, exitCode })
6173
6640
  );
6174
6641
  const runClaudeLogout = (cwd, accountPath) => runClaudeAuthCommand(cwd, accountPath, ["auth", "logout"], "claude auth logout", false);
6175
- const runClaudePingProbeExitCode = (cwd, accountPath) => runDockerAuthExitCode(
6642
+ const runClaudePingProbeExitCode = (cwd, accountPath, oauthToken) => runDockerAuthExitCode(
6176
6643
  buildDockerAuthSpec({
6177
6644
  cwd,
6178
6645
  image: claudeImageName,
6179
6646
  hostPath: accountPath,
6180
6647
  containerPath: claudeContainerHomeDir,
6181
- env: buildClaudeAuthEnv(false),
6648
+ env: buildClaudeAuthEnv(false, oauthToken),
6182
6649
  args: ["-p", "ping"],
6183
6650
  interactive: false
6184
6651
  })
@@ -6195,8 +6662,8 @@ const authClaudeLogin = (command) => {
6195
6662
  yield* _(fs.writeFileString(claudeOauthTokenPath(accountPath), `${token}
6196
6663
  `));
6197
6664
  yield* _(fs.chmod(claudeOauthTokenPath(accountPath), 384), Effect.orElseSucceed(() => void 0));
6198
- yield* _(syncClaudeCredentialsFile(fs, accountPath));
6199
- const probeExitCode = yield* _(runClaudePingProbeExitCode(cwd, accountPath));
6665
+ yield* _(resolveClaudeAuthMethod(fs, accountPath));
6666
+ const probeExitCode = yield* _(runClaudePingProbeExitCode(cwd, accountPath, token));
6200
6667
  if (probeExitCode !== 0) {
6201
6668
  yield* _(
6202
6669
  Effect.fail(
@@ -6212,20 +6679,17 @@ const authClaudeLogin = (command) => {
6212
6679
  );
6213
6680
  };
6214
6681
  const authClaudeStatus = (command) => withClaudeAuth(command, ({ accountLabel, accountPath, cwd, fs }) => Effect.gen(function* (_) {
6215
- yield* _(syncClaudeCredentialsFile(fs, accountPath));
6216
- const hasOauthToken = yield* _(hasNonEmptyOauthToken$1(fs, accountPath));
6217
- const hasCredentials = yield* _(isRegularFile(fs, claudeCredentialsPath(accountPath)));
6218
- if (!hasOauthToken && !hasCredentials) {
6682
+ const method = yield* _(resolveClaudeAuthMethod(fs, accountPath));
6683
+ if (method === "none") {
6219
6684
  yield* _(Effect.log(`Claude not connected (${accountLabel}).`));
6220
6685
  return;
6221
6686
  }
6222
- const probeExitCode = yield* _(runClaudePingProbeExitCode(cwd, accountPath));
6687
+ const oauthToken = method === "oauth-token" ? yield* _(readOauthToken(fs, accountPath)) : null;
6688
+ const probeExitCode = yield* _(runClaudePingProbeExitCode(cwd, accountPath, oauthToken));
6223
6689
  if (probeExitCode === 0) {
6224
- const method2 = hasCredentials ? "claude-ai-session" : "oauth-token";
6225
- yield* _(Effect.log(`Claude connected (${accountLabel}, ${method2}).`));
6690
+ yield* _(Effect.log(`Claude connected (${accountLabel}, ${method}).`));
6226
6691
  return;
6227
6692
  }
6228
- const method = hasCredentials ? "claude-ai-session" : "oauth-token";
6229
6693
  yield* _(
6230
6694
  Effect.logWarning(
6231
6695
  `Claude session exists but API probe failed (${accountLabel}, ${method}, exit=${probeExitCode}). Run 'docker-git auth claude login'.`
@@ -7244,7 +7708,10 @@ const booleanFlagUpdaters = {
7244
7708
  "--wipe": (raw) => ({ ...raw, wipe: true }),
7245
7709
  "--no-wipe": (raw) => ({ ...raw, wipe: false }),
7246
7710
  "--web": (raw) => ({ ...raw, authWeb: true }),
7247
- "--include-default": (raw) => ({ ...raw, includeDefault: true })
7711
+ "--include-default": (raw) => ({ ...raw, includeDefault: true }),
7712
+ "--claude": (raw) => ({ ...raw, agentClaude: true }),
7713
+ "--codex": (raw) => ({ ...raw, agentCodex: true }),
7714
+ "--auto": (raw) => ({ ...raw, agentAuto: true })
7248
7715
  };
7249
7716
  const valueFlagUpdaters = {
7250
7717
  repoUrl: (raw, value) => ({ ...raw, repoUrl: value }),
@@ -7590,7 +8057,14 @@ const resolveCreateBehavior = (raw) => ({
7590
8057
  forceEnv: raw.forceEnv ?? false,
7591
8058
  enableMcpPlaywright: raw.enableMcpPlaywright ?? false
7592
8059
  });
8060
+ const resolveAgentMode = (raw) => {
8061
+ if (raw.agentClaude) return "claude";
8062
+ if (raw.agentCodex) return "codex";
8063
+ return void 0;
8064
+ };
7593
8065
  const buildTemplateConfig = ({
8066
+ agentAuto,
8067
+ agentMode,
7594
8068
  claudeAuthLabel,
7595
8069
  codexAuthLabel,
7596
8070
  dockerNetworkMode,
@@ -7622,7 +8096,9 @@ const buildTemplateConfig = ({
7622
8096
  dockerNetworkMode,
7623
8097
  dockerSharedNetworkName,
7624
8098
  enableMcpPlaywright,
7625
- pnpmVersion: defaultTemplateConfig.pnpmVersion
8099
+ pnpmVersion: defaultTemplateConfig.pnpmVersion,
8100
+ agentMode,
8101
+ agentAuto
7626
8102
  });
7627
8103
  const buildCreateCommand = (raw) => Either.gen(function* (_) {
7628
8104
  const repo = yield* _(resolveRepoBasics(raw));
@@ -7636,6 +8112,8 @@ const buildCreateCommand = (raw) => Either.gen(function* (_) {
7636
8112
  const dockerSharedNetworkName = yield* _(
7637
8113
  nonEmpty("--shared-network", raw.dockerSharedNetworkName, defaultTemplateConfig.dockerSharedNetworkName)
7638
8114
  );
8115
+ const agentMode = resolveAgentMode(raw);
8116
+ const agentAuto = raw.agentAuto ?? false;
7639
8117
  return {
7640
8118
  _tag: "Create",
7641
8119
  outDir: paths.outDir,
@@ -7653,7 +8131,9 @@ const buildCreateCommand = (raw) => Either.gen(function* (_) {
7653
8131
  gitTokenLabel,
7654
8132
  codexAuthLabel,
7655
8133
  claudeAuthLabel,
7656
- enableMcpPlaywright: behavior.enableMcpPlaywright
8134
+ enableMcpPlaywright: behavior.enableMcpPlaywright,
8135
+ agentMode,
8136
+ agentAuto
7657
8137
  })
7658
8138
  };
7659
8139
  });
@@ -7957,6 +8437,9 @@ Options:
7957
8437
  --up | --no-up Run docker compose up after init (default: --up)
7958
8438
  --ssh | --no-ssh Auto-open SSH after create/clone (default: clone=--ssh, create=--no-ssh)
7959
8439
  --mcp-playwright | --no-mcp-playwright Enable Playwright MCP + Chromium sidecar (default: --no-mcp-playwright)
8440
+ --claude Start Claude Code agent inside container after clone
8441
+ --codex Start Codex agent inside container after clone
8442
+ --auto Auto-execute: agent completes the task, creates PR and pushes (requires --claude or --codex)
7960
8443
  --force Overwrite existing files and wipe compose volumes (docker compose down -v)
7961
8444
  --force-env Reset project env defaults only (keep workspace volume/data)
7962
8445
  -h, --help Show this help
@@ -10882,6 +11365,7 @@ const program = pipe(
10882
11365
  Effect.catchTag("DockerAccessError", logWarningAndExit),
10883
11366
  Effect.catchTag("DockerCommandError", logWarningAndExit),
10884
11367
  Effect.catchTag("AuthError", logWarningAndExit),
11368
+ Effect.catchTag("AgentFailedError", logWarningAndExit),
10885
11369
  Effect.catchTag("CommandFailedError", logWarningAndExit),
10886
11370
  Effect.catchTag("ScrapArchiveNotFoundError", logErrorAndExit),
10887
11371
  Effect.catchTag("ScrapTargetDirUnsupportedError", logErrorAndExit),