diff --git a/.gitignore b/.gitignore index f5b65d74..5cfe6580 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* reports/ +.idea +.claude diff --git a/packages/app/src/docker-git/cli/parser-options.ts b/packages/app/src/docker-git/cli/parser-options.ts index 440614dc..bd0cb523 100644 --- a/packages/app/src/docker-git/cli/parser-options.ts +++ b/packages/app/src/docker-git/cli/parser-options.ts @@ -88,7 +88,10 @@ const booleanFlagUpdaters: Readonly RawOptio "--wipe": (raw) => ({ ...raw, wipe: true }), "--no-wipe": (raw) => ({ ...raw, wipe: false }), "--web": (raw) => ({ ...raw, authWeb: true }), - "--include-default": (raw) => ({ ...raw, includeDefault: true }) + "--include-default": (raw) => ({ ...raw, includeDefault: true }), + "--claude": (raw) => ({ ...raw, agentClaude: true }), + "--codex": (raw) => ({ ...raw, agentCodex: true }), + "--auto": (raw) => ({ ...raw, agentAuto: true }) } const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: string) => RawOptions } = { diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index 97d4a307..5f0bb18a 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -65,6 +65,9 @@ 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) --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/src/docker-git/program.ts b/packages/app/src/docker-git/program.ts index 6adea278..dd97d1bc 100644 --- a/packages/app/src/docker-git/program.ts +++ b/packages/app/src/docker-git/program.ts @@ -136,6 +136,7 @@ export const program = pipe( Effect.catchTag("DockerAccessError", logWarningAndExit), Effect.catchTag("DockerCommandError", logWarningAndExit), Effect.catchTag("AuthError", logWarningAndExit), + Effect.catchTag("AgentFailedError", logWarningAndExit), Effect.catchTag("CommandFailedError", logWarningAndExit), Effect.catchTag("ScrapArchiveNotFoundError", logErrorAndExit), Effect.catchTag("ScrapTargetDirUnsupportedError", logErrorAndExit), diff --git a/packages/lib/src/core/command-builders.ts b/packages/lib/src/core/command-builders.ts index d6058fd4..54de3ae0 100644 --- a/packages/lib/src/core/command-builders.ts +++ b/packages/lib/src/core/command-builders.ts @@ -3,6 +3,7 @@ import { Either } from "effect" import { expandContainerHome } from "../usecases/scrap-path.js" import { type RawOptions } from "./command-options.js" import { + type AgentMode, type CreateCommand, defaultTemplateConfig, deriveRepoPathParts, @@ -226,9 +227,19 @@ type BuildTemplateConfigInput = { readonly codexAuthLabel: string | undefined readonly claudeAuthLabel: string | undefined readonly enableMcpPlaywright: boolean + readonly agentMode: AgentMode | undefined + 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, claudeAuthLabel, codexAuthLabel, dockerNetworkMode, @@ -260,7 +271,9 @@ const buildTemplateConfig = ({ dockerNetworkMode, dockerSharedNetworkName, enableMcpPlaywright, - pnpmVersion: defaultTemplateConfig.pnpmVersion + pnpmVersion: defaultTemplateConfig.pnpmVersion, + agentMode, + agentAuto }) // CHANGE: build a typed create command from raw options (CLI or API) @@ -288,6 +301,8 @@ export const buildCreateCommand = ( const dockerSharedNetworkName = yield* _( nonEmpty("--shared-network", raw.dockerSharedNetworkName, defaultTemplateConfig.dockerSharedNetworkName) ) + const agentMode = resolveAgentMode(raw) + const agentAuto = raw.agentAuto ?? false return { _tag: "Create", @@ -306,7 +321,9 @@ export const buildCreateCommand = ( gitTokenLabel, codexAuthLabel, claudeAuthLabel, - enableMcpPlaywright: behavior.enableMcpPlaywright + enableMcpPlaywright: behavior.enableMcpPlaywright, + agentMode, + agentAuto }) } }) diff --git a/packages/lib/src/core/command-options.ts b/packages/lib/src/core/command-options.ts index da0fab9c..fe130578 100644 --- a/packages/lib/src/core/command-options.ts +++ b/packages/lib/src/core/command-options.ts @@ -47,6 +47,9 @@ export interface RawOptions { readonly openSsh?: boolean readonly force?: boolean readonly forceEnv?: boolean + readonly agentClaude?: boolean + readonly agentCodex?: boolean + readonly agentAuto?: boolean } // CHANGE: helper type alias for builder signatures that produce parse errors diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 602ead86..7944f0cd 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -2,6 +2,8 @@ export type { MenuAction, ParseError } from "./menu.js" export { parseMenuSelection } from "./menu.js" export { deriveRepoPathParts, deriveRepoSlug, resolveRepoInput } from "./repo.js" +export type AgentMode = "claude" | "codex" + export type DockerNetworkMode = "shared" | "project" export const defaultDockerNetworkMode: DockerNetworkMode = "shared" @@ -32,6 +34,8 @@ export interface TemplateConfig { readonly dockerSharedNetworkName: string readonly enableMcpPlaywright: boolean readonly pnpmVersion: string + readonly agentMode?: AgentMode | undefined + readonly agentAuto?: boolean | undefined } export interface ProjectConfig { diff --git a/packages/lib/src/core/templates-entrypoint/agent.ts b/packages/lib/src/core/templates-entrypoint/agent.ts new file mode 100644 index 00000000..92fdf1c0 --- /dev/null +++ b/packages/lib/src/core/templates-entrypoint/agent.ts @@ -0,0 +1,191 @@ +import type { TemplateConfig } from "../domain.js" + +const indentBlock = (block: string, size = 2): string => { + const prefix = " ".repeat(size) + + return block + .split("\n") + .map((line) => `${prefix}${line}`) + .join("\n") +} + +const renderAgentPrompt = (): string => + String.raw`AGENT_PROMPT="" +ISSUE_NUM="" +if [[ "$REPO_REF" =~ ^issue-([0-9]+)$ ]]; then + ISSUE_NUM="${"${"}BASH_REMATCH[1]}" +fi + +if [[ "$AGENT_AUTO" == "1" ]]; then + if [[ -n "$ISSUE_NUM" ]]; then + 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." + else + AGENT_PROMPT="Analyze this repository, implement any pending tasks, commit changes, create a PR, and push it." + fi +fi` + +const renderAgentSetup = (): string => + [ + String.raw`AGENT_DONE_PATH="/run/docker-git/agent.done" +AGENT_FAIL_PATH="/run/docker-git/agent.failed" +AGENT_PROMPT_FILE="/run/docker-git/agent-prompt.txt" +rm -f "$AGENT_DONE_PATH" "$AGENT_FAIL_PATH" "$AGENT_PROMPT_FILE"`, + String.raw`# Collect tokens for agent environment (su - dev does not always inherit profile.d) +AGENT_ENV_FILE="/run/docker-git/agent-env.sh" +{ + [[ -f /etc/profile.d/gh-token.sh ]] && cat /etc/profile.d/gh-token.sh + [[ -f /etc/profile.d/claude-config.sh ]] && cat /etc/profile.d/claude-config.sh +} > "$AGENT_ENV_FILE" 2>/dev/null || true +chmod 644 "$AGENT_ENV_FILE"`, + renderAgentPrompt(), + String.raw`AGENT_OK=0 +if [[ -n "$AGENT_PROMPT" ]]; then + printf "%s" "$AGENT_PROMPT" > "$AGENT_PROMPT_FILE" + chmod 644 "$AGENT_PROMPT_FILE" +fi` + ].join("\n\n") + +const renderAgentPromptCommand = (mode: "claude" | "codex"): string => + mode === "claude" + ? String.raw`claude --dangerously-skip-permissions -p \"\$(cat $AGENT_PROMPT_FILE)\"` + : String.raw`codex --approval-mode full-auto \"\$(cat $AGENT_PROMPT_FILE)\"` + +const renderAgentModeBlock = ( + config: TemplateConfig, + mode: "claude" | "codex" +): string => { + const startMessage = `[agent] starting ${mode}...` + const interactiveMessage = `[agent] ${mode} started in interactive mode (use SSH to connect)` + + return String.raw`"${mode}") + echo "${startMessage}" + if [[ -n "$AGENT_PROMPT" ]]; then + if su - ${config.sshUser} \ + -c ". /run/docker-git/agent-env.sh 2>/dev/null; cd '$TARGET_DIR' && ${renderAgentPromptCommand(mode)}"; then + AGENT_OK=1 + fi + else + echo "${interactiveMessage}" + AGENT_OK=1 + fi + ;;` +} + +const renderAgentModeCase = (config: TemplateConfig): string => + [ + String.raw`case "$AGENT_MODE" in`, + indentBlock(renderAgentModeBlock(config, "claude")), + indentBlock(renderAgentModeBlock(config, "codex")), + indentBlock( + String.raw`*) + echo "[agent] unknown agent mode: $AGENT_MODE" + ;;` + ), + "esac" + ].join("\n") + +const renderAgentIssueComment = (config: TemplateConfig): string => + String.raw`echo "[agent] posting review comment to issue #$ISSUE_NUM..." + +PR_BODY="" +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 + +if [[ -z "$PR_BODY" ]]; then + 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 +fi + +if [[ -n "$PR_BODY" ]]; then + COMMENT_FILE="/run/docker-git/agent-comment.txt" + printf "%s" "$PR_BODY" > "$COMMENT_FILE" + chmod 644 "$COMMENT_FILE" + 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" +else + echo "[agent] no PR body or commit message found, skipping comment" +fi` + +const renderProjectMoveScript = (): string => + String.raw`#!/bin/bash +. /run/docker-git/agent-env.sh 2>/dev/null || true +cd "$1" || exit 1 +ISSUE_NUM="$2" + +ISSUE_NODE_ID=$(gh issue view "$ISSUE_NUM" --json id --jq '.id' 2>/dev/null) || true +if [[ -z "$ISSUE_NODE_ID" ]]; then + echo "[agent] could not get issue node ID, skipping move" + exit 0 +fi + +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 } } } } } } } } }' +ALL_IDS=$(gh api graphql -F nodeId="$ISSUE_NODE_ID" -f query="$GQL_QUERY" \ + --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 + +if [[ -z "$ALL_IDS" ]]; then + echo "[agent] issue #$ISSUE_NUM is not in a project board, skipping move" + exit 0 +fi + +ITEM_ID=$(printf "%s" "$ALL_IDS" | cut -f1) +PROJECT_ID=$(printf "%s" "$ALL_IDS" | cut -f2) +STATUS_FIELD_ID=$(printf "%s" "$ALL_IDS" | cut -f3) +REVIEW_OPTION_ID=$(printf "%s" "$ALL_IDS" | cut -f4) +if [[ -z "$STATUS_FIELD_ID" || -z "$REVIEW_OPTION_ID" || "$STATUS_FIELD_ID" == "null" || "$REVIEW_OPTION_ID" == "null" ]]; then + echo "[agent] review status not found in project board, skipping move" + exit 0 +fi + +MUTATION='mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { updateProjectV2ItemFieldValue(input: { projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { singleSelectOptionId: $optionId } }) { projectV2Item { id } } }' +MOVE_RESULT=$(gh api graphql \ + -F projectId="$PROJECT_ID" \ + -F itemId="$ITEM_ID" \ + -F fieldId="$STATUS_FIELD_ID" \ + -F optionId="$REVIEW_OPTION_ID" \ + -f query="$MUTATION" 2>&1) || true + +if [[ "$MOVE_RESULT" == *"projectV2Item"* ]]; then + echo "[agent] issue #$ISSUE_NUM moved to review" +else + echo "[agent] failed to move issue #$ISSUE_NUM in project board" +fi` + +const renderAgentIssueMove = (config: TemplateConfig): string => + [ + String.raw`echo "[agent] moving issue #$ISSUE_NUM to review..." +MOVE_SCRIPT="/run/docker-git/project-move.sh"`, + String.raw`cat > "$MOVE_SCRIPT" << 'EOFMOVE' +${renderProjectMoveScript()} +EOFMOVE`, + String.raw`chmod +x "$MOVE_SCRIPT" +su - ${config.sshUser} -c "$MOVE_SCRIPT '$TARGET_DIR' '$ISSUE_NUM'" || true` + ].join("\n") + +const renderAgentIssueReview = (config: TemplateConfig): string => + [ + String.raw`if [[ "$AGENT_OK" -eq 1 && "$AGENT_AUTO" == "1" && -n "$ISSUE_NUM" ]]; then`, + indentBlock(renderAgentIssueComment(config)), + "", + indentBlock(renderAgentIssueMove(config)), + "fi" + ].join("\n") + +const renderAgentFinalize = (): string => + String.raw`if [[ "$AGENT_OK" -eq 1 ]]; then + echo "[agent] done" + touch "$AGENT_DONE_PATH" +else + echo "[agent] failed" + touch "$AGENT_FAIL_PATH" +fi` + +export const renderAgentLaunch = (config: TemplateConfig): string => + [ + String.raw`# 3) Auto-launch agent if AGENT_MODE is set +if [[ "$CLONE_OK" -eq 1 && -n "$AGENT_MODE" ]]; then`, + indentBlock(renderAgentSetup()), + "", + indentBlock(renderAgentModeCase(config)), + "", + indentBlock(renderAgentIssueReview(config)), + "", + indentBlock(renderAgentFinalize()), + "fi" + ].join("\n") diff --git a/packages/lib/src/core/templates-entrypoint/base.ts b/packages/lib/src/core/templates-entrypoint/base.ts index bfe31b07..e96fb283 100644 --- a/packages/lib/src/core/templates-entrypoint/base.ts +++ b/packages/lib/src/core/templates-entrypoint/base.ts @@ -23,6 +23,8 @@ GITHUB_TOKEN="\${GITHUB_TOKEN:-\${GH_TOKEN:-}}" GIT_USER_NAME="\${GIT_USER_NAME:-}" GIT_USER_EMAIL="\${GIT_USER_EMAIL:-}" CODEX_AUTO_UPDATE="\${CODEX_AUTO_UPDATE:-1}" +AGENT_MODE="\${AGENT_MODE:-}" +AGENT_AUTO="\${AGENT_AUTO:-}" MCP_PLAYWRIGHT_ENABLE="\${MCP_PLAYWRIGHT_ENABLE:-${config.enableMcpPlaywright ? "1" : "0"}}" MCP_PLAYWRIGHT_CDP_ENDPOINT="\${MCP_PLAYWRIGHT_CDP_ENDPOINT:-}" MCP_PLAYWRIGHT_ISOLATED="\${MCP_PLAYWRIGHT_ISOLATED:-1}" diff --git a/packages/lib/src/core/templates-entrypoint/tasks.ts b/packages/lib/src/core/templates-entrypoint/tasks.ts index b32014ba..101e1b6d 100644 --- a/packages/lib/src/core/templates-entrypoint/tasks.ts +++ b/packages/lib/src/core/templates-entrypoint/tasks.ts @@ -1,4 +1,5 @@ import type { TemplateConfig } from "../domain.js" +import { renderAgentLaunch } from "./agent.js" const renderEntrypointAutoUpdate = (): string => `# 1) Keep Codex CLI up to date if requested (bun only) @@ -203,4 +204,6 @@ export const renderEntrypointBackgroundTasks = (config: TemplateConfig): string ${renderEntrypointAutoUpdate()} ${renderEntrypointClone(config)} + +${renderAgentLaunch(config)} ) &` diff --git a/packages/lib/src/core/templates/docker-compose.ts b/packages/lib/src/core/templates/docker-compose.ts index 88f5b443..000cbd02 100644 --- a/packages/lib/src/core/templates/docker-compose.ts +++ b/packages/lib/src/core/templates/docker-compose.ts @@ -6,6 +6,8 @@ type ComposeFragments = { readonly maybeGitTokenLabelEnv: string readonly maybeCodexAuthLabelEnv: string readonly maybeClaudeAuthLabelEnv: string + readonly maybeAgentModeEnv: string + readonly maybeAgentAutoEnv: string readonly maybeDependsOn: string readonly maybePlaywrightEnv: string readonly maybeBrowserService: string @@ -33,6 +35,16 @@ const renderClaudeAuthLabelEnv = (claudeAuthLabel: string): string => ? ` CLAUDE_AUTH_LABEL: "${claudeAuthLabel}"\n` : "" +const renderAgentModeEnv = (agentMode: string | undefined): string => + agentMode !== undefined && agentMode.length > 0 + ? ` AGENT_MODE: "${agentMode}"\n` + : "" + +const renderAgentAutoEnv = (agentAuto: boolean | undefined): string => + agentAuto === true + ? ` AGENT_AUTO: "1"\n` + : "" + const buildPlaywrightFragments = ( config: TemplateConfig, networkName: string @@ -72,6 +84,8 @@ const buildComposeFragments = (config: TemplateConfig): ComposeFragments => { const maybeGitTokenLabelEnv = renderGitTokenLabelEnv(gitTokenLabel) const maybeCodexAuthLabelEnv = renderCodexAuthLabelEnv(codexAuthLabel) const maybeClaudeAuthLabelEnv = renderClaudeAuthLabelEnv(claudeAuthLabel) + const maybeAgentModeEnv = renderAgentModeEnv(config.agentMode) + const maybeAgentAutoEnv = renderAgentAutoEnv(config.agentAuto) const playwright = buildPlaywrightFragments(config, networkName) return { @@ -80,6 +94,8 @@ const buildComposeFragments = (config: TemplateConfig): ComposeFragments => { maybeGitTokenLabelEnv, maybeCodexAuthLabelEnv, maybeClaudeAuthLabelEnv, + maybeAgentModeEnv, + maybeAgentAutoEnv, maybeDependsOn: playwright.maybeDependsOn, maybePlaywrightEnv: playwright.maybePlaywrightEnv, maybeBrowserService: playwright.maybeBrowserService, @@ -100,7 +116,7 @@ const renderComposeServices = (config: TemplateConfig, fragments: ComposeFragmen FORK_REPO_URL: "${fragments.forkRepoUrl}" ${fragments.maybeGitTokenLabelEnv} # Optional token label selector (maps to GITHUB_TOKEN__