Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
reports/
.idea
.claude
5 changes: 4 additions & 1 deletion packages/app/src/docker-git/cli/parser-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,10 @@ const booleanFlagUpdaters: Readonly<Record<string, (raw: RawOptions) => 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 } = {
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/docker-git/cli/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/docker-git/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
21 changes: 19 additions & 2 deletions packages/lib/src/core/command-builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand All @@ -306,7 +321,9 @@ export const buildCreateCommand = (
gitTokenLabel,
codexAuthLabel,
claudeAuthLabel,
enableMcpPlaywright: behavior.enableMcpPlaywright
enableMcpPlaywright: behavior.enableMcpPlaywright,
agentMode,
agentAuto
})
}
})
3 changes: 3 additions & 0 deletions packages/lib/src/core/command-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/lib/src/core/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
191 changes: 191 additions & 0 deletions packages/lib/src/core/templates-entrypoint/agent.ts
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 2 additions & 0 deletions packages/lib/src/core/templates-entrypoint/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
3 changes: 3 additions & 0 deletions packages/lib/src/core/templates-entrypoint/tasks.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -203,4 +204,6 @@ export const renderEntrypointBackgroundTasks = (config: TemplateConfig): string
${renderEntrypointAutoUpdate()}

${renderEntrypointClone(config)}

${renderAgentLaunch(config)}
) &`
18 changes: 17 additions & 1 deletion packages/lib/src/core/templates/docker-compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -80,6 +94,8 @@ const buildComposeFragments = (config: TemplateConfig): ComposeFragments => {
maybeGitTokenLabelEnv,
maybeCodexAuthLabelEnv,
maybeClaudeAuthLabelEnv,
maybeAgentModeEnv,
maybeAgentAutoEnv,
maybeDependsOn: playwright.maybeDependsOn,
maybePlaywrightEnv: playwright.maybePlaywrightEnv,
maybeBrowserService: playwright.maybeBrowserService,
Expand All @@ -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__<LABEL>/GIT_AUTH_TOKEN__<LABEL>)
${fragments.maybeCodexAuthLabelEnv} # Optional Codex account label selector (maps to CODEX_AUTH_LABEL)
${fragments.maybeClaudeAuthLabelEnv} # Optional Claude account label selector (maps to CLAUDE_AUTH_LABEL)
${fragments.maybeClaudeAuthLabelEnv}${fragments.maybeAgentModeEnv}${fragments.maybeAgentAutoEnv} # Optional Claude account label selector (maps to CLAUDE_AUTH_LABEL)
TARGET_DIR: "${config.targetDir}"
CODEX_HOME: "${config.codexHome}"
${fragments.maybePlaywrightEnv}${fragments.maybeDependsOn} env_file:
Expand Down
Loading
Loading