Skip to content

Commit 7514bc1

Browse files
authored
Merge pull request #115 from alex-radchenko-github/add-flags-claude-auto
feat: add --claude, --codex, --auto flags for AI agent automation
2 parents 5d0e56e + ada757f commit 7514bc1

File tree

15 files changed

+407
-39
lines changed

15 files changed

+407
-39
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,5 @@ yarn-debug.log*
2222
yarn-error.log*
2323
pnpm-debug.log*
2424
reports/
25+
.idea
26+
.claude

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,10 @@ const booleanFlagUpdaters: Readonly<Record<string, (raw: RawOptions) => RawOptio
8888
"--wipe": (raw) => ({ ...raw, wipe: true }),
8989
"--no-wipe": (raw) => ({ ...raw, wipe: false }),
9090
"--web": (raw) => ({ ...raw, authWeb: true }),
91-
"--include-default": (raw) => ({ ...raw, includeDefault: true })
91+
"--include-default": (raw) => ({ ...raw, includeDefault: true }),
92+
"--claude": (raw) => ({ ...raw, agentClaude: true }),
93+
"--codex": (raw) => ({ ...raw, agentCodex: true }),
94+
"--auto": (raw) => ({ ...raw, agentAuto: true })
9295
}
9396

9497
const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: string) => RawOptions } = {

packages/app/src/docker-git/cli/usage.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ Options:
6565
--up | --no-up Run docker compose up after init (default: --up)
6666
--ssh | --no-ssh Auto-open SSH after create/clone (default: clone=--ssh, create=--no-ssh)
6767
--mcp-playwright | --no-mcp-playwright Enable Playwright MCP + Chromium sidecar (default: --no-mcp-playwright)
68+
--claude Start Claude Code agent inside container after clone
69+
--codex Start Codex agent inside container after clone
70+
--auto Auto-execute: agent completes the task, creates PR and pushes (requires --claude or --codex)
6871
--force Overwrite existing files and wipe compose volumes (docker compose down -v)
6972
--force-env Reset project env defaults only (keep workspace volume/data)
7073
-h, --help Show this help

packages/app/src/docker-git/program.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ export const program = pipe(
136136
Effect.catchTag("DockerAccessError", logWarningAndExit),
137137
Effect.catchTag("DockerCommandError", logWarningAndExit),
138138
Effect.catchTag("AuthError", logWarningAndExit),
139+
Effect.catchTag("AgentFailedError", logWarningAndExit),
139140
Effect.catchTag("CommandFailedError", logWarningAndExit),
140141
Effect.catchTag("ScrapArchiveNotFoundError", logErrorAndExit),
141142
Effect.catchTag("ScrapTargetDirUnsupportedError", logErrorAndExit),

packages/lib/src/core/command-builders.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Either } from "effect"
33
import { expandContainerHome } from "../usecases/scrap-path.js"
44
import { type RawOptions } from "./command-options.js"
55
import {
6+
type AgentMode,
67
type CreateCommand,
78
defaultTemplateConfig,
89
deriveRepoPathParts,
@@ -226,9 +227,19 @@ type BuildTemplateConfigInput = {
226227
readonly codexAuthLabel: string | undefined
227228
readonly claudeAuthLabel: string | undefined
228229
readonly enableMcpPlaywright: boolean
230+
readonly agentMode: AgentMode | undefined
231+
readonly agentAuto: boolean
232+
}
233+
234+
const resolveAgentMode = (raw: RawOptions): AgentMode | undefined => {
235+
if (raw.agentClaude) return "claude"
236+
if (raw.agentCodex) return "codex"
237+
return undefined
229238
}
230239

231240
const buildTemplateConfig = ({
241+
agentAuto,
242+
agentMode,
232243
claudeAuthLabel,
233244
codexAuthLabel,
234245
dockerNetworkMode,
@@ -260,7 +271,9 @@ const buildTemplateConfig = ({
260271
dockerNetworkMode,
261272
dockerSharedNetworkName,
262273
enableMcpPlaywright,
263-
pnpmVersion: defaultTemplateConfig.pnpmVersion
274+
pnpmVersion: defaultTemplateConfig.pnpmVersion,
275+
agentMode,
276+
agentAuto
264277
})
265278

266279
// CHANGE: build a typed create command from raw options (CLI or API)
@@ -288,6 +301,8 @@ export const buildCreateCommand = (
288301
const dockerSharedNetworkName = yield* _(
289302
nonEmpty("--shared-network", raw.dockerSharedNetworkName, defaultTemplateConfig.dockerSharedNetworkName)
290303
)
304+
const agentMode = resolveAgentMode(raw)
305+
const agentAuto = raw.agentAuto ?? false
291306

292307
return {
293308
_tag: "Create",
@@ -306,7 +321,9 @@ export const buildCreateCommand = (
306321
gitTokenLabel,
307322
codexAuthLabel,
308323
claudeAuthLabel,
309-
enableMcpPlaywright: behavior.enableMcpPlaywright
324+
enableMcpPlaywright: behavior.enableMcpPlaywright,
325+
agentMode,
326+
agentAuto
310327
})
311328
}
312329
})

packages/lib/src/core/command-options.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ export interface RawOptions {
4747
readonly openSsh?: boolean
4848
readonly force?: boolean
4949
readonly forceEnv?: boolean
50+
readonly agentClaude?: boolean
51+
readonly agentCodex?: boolean
52+
readonly agentAuto?: boolean
5053
}
5154

5255
// CHANGE: helper type alias for builder signatures that produce parse errors

packages/lib/src/core/domain.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export type { MenuAction, ParseError } from "./menu.js"
22
export { parseMenuSelection } from "./menu.js"
33
export { deriveRepoPathParts, deriveRepoSlug, resolveRepoInput } from "./repo.js"
44

5+
export type AgentMode = "claude" | "codex"
6+
57
export type DockerNetworkMode = "shared" | "project"
68

79
export const defaultDockerNetworkMode: DockerNetworkMode = "shared"
@@ -32,6 +34,8 @@ export interface TemplateConfig {
3234
readonly dockerSharedNetworkName: string
3335
readonly enableMcpPlaywright: boolean
3436
readonly pnpmVersion: string
37+
readonly agentMode?: AgentMode | undefined
38+
readonly agentAuto?: boolean | undefined
3539
}
3640

3741
export interface ProjectConfig {
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import type { TemplateConfig } from "../domain.js"
2+
3+
const indentBlock = (block: string, size = 2): string => {
4+
const prefix = " ".repeat(size)
5+
6+
return block
7+
.split("\n")
8+
.map((line) => `${prefix}${line}`)
9+
.join("\n")
10+
}
11+
12+
const renderAgentPrompt = (): string =>
13+
String.raw`AGENT_PROMPT=""
14+
ISSUE_NUM=""
15+
if [[ "$REPO_REF" =~ ^issue-([0-9]+)$ ]]; then
16+
ISSUE_NUM="${"${"}BASH_REMATCH[1]}"
17+
fi
18+
19+
if [[ "$AGENT_AUTO" == "1" ]]; then
20+
if [[ -n "$ISSUE_NUM" ]]; then
21+
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."
22+
else
23+
AGENT_PROMPT="Analyze this repository, implement any pending tasks, commit changes, create a PR, and push it."
24+
fi
25+
fi`
26+
27+
const renderAgentSetup = (): string =>
28+
[
29+
String.raw`AGENT_DONE_PATH="/run/docker-git/agent.done"
30+
AGENT_FAIL_PATH="/run/docker-git/agent.failed"
31+
AGENT_PROMPT_FILE="/run/docker-git/agent-prompt.txt"
32+
rm -f "$AGENT_DONE_PATH" "$AGENT_FAIL_PATH" "$AGENT_PROMPT_FILE"`,
33+
String.raw`# Collect tokens for agent environment (su - dev does not always inherit profile.d)
34+
AGENT_ENV_FILE="/run/docker-git/agent-env.sh"
35+
{
36+
[[ -f /etc/profile.d/gh-token.sh ]] && cat /etc/profile.d/gh-token.sh
37+
[[ -f /etc/profile.d/claude-config.sh ]] && cat /etc/profile.d/claude-config.sh
38+
} > "$AGENT_ENV_FILE" 2>/dev/null || true
39+
chmod 644 "$AGENT_ENV_FILE"`,
40+
renderAgentPrompt(),
41+
String.raw`AGENT_OK=0
42+
if [[ -n "$AGENT_PROMPT" ]]; then
43+
printf "%s" "$AGENT_PROMPT" > "$AGENT_PROMPT_FILE"
44+
chmod 644 "$AGENT_PROMPT_FILE"
45+
fi`
46+
].join("\n\n")
47+
48+
const renderAgentPromptCommand = (mode: "claude" | "codex"): string =>
49+
mode === "claude"
50+
? String.raw`claude --dangerously-skip-permissions -p \"\$(cat $AGENT_PROMPT_FILE)\"`
51+
: String.raw`codex --approval-mode full-auto \"\$(cat $AGENT_PROMPT_FILE)\"`
52+
53+
const renderAgentModeBlock = (
54+
config: TemplateConfig,
55+
mode: "claude" | "codex"
56+
): string => {
57+
const startMessage = `[agent] starting ${mode}...`
58+
const interactiveMessage = `[agent] ${mode} started in interactive mode (use SSH to connect)`
59+
60+
return String.raw`"${mode}")
61+
echo "${startMessage}"
62+
if [[ -n "$AGENT_PROMPT" ]]; then
63+
if su - ${config.sshUser} \
64+
-c ". /run/docker-git/agent-env.sh 2>/dev/null; cd '$TARGET_DIR' && ${renderAgentPromptCommand(mode)}"; then
65+
AGENT_OK=1
66+
fi
67+
else
68+
echo "${interactiveMessage}"
69+
AGENT_OK=1
70+
fi
71+
;;`
72+
}
73+
74+
const renderAgentModeCase = (config: TemplateConfig): string =>
75+
[
76+
String.raw`case "$AGENT_MODE" in`,
77+
indentBlock(renderAgentModeBlock(config, "claude")),
78+
indentBlock(renderAgentModeBlock(config, "codex")),
79+
indentBlock(
80+
String.raw`*)
81+
echo "[agent] unknown agent mode: $AGENT_MODE"
82+
;;`
83+
),
84+
"esac"
85+
].join("\n")
86+
87+
const renderAgentIssueComment = (config: TemplateConfig): string =>
88+
String.raw`echo "[agent] posting review comment to issue #$ISSUE_NUM..."
89+
90+
PR_BODY=""
91+
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
92+
93+
if [[ -z "$PR_BODY" ]]; then
94+
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
95+
fi
96+
97+
if [[ -n "$PR_BODY" ]]; then
98+
COMMENT_FILE="/run/docker-git/agent-comment.txt"
99+
printf "%s" "$PR_BODY" > "$COMMENT_FILE"
100+
chmod 644 "$COMMENT_FILE"
101+
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"
102+
else
103+
echo "[agent] no PR body or commit message found, skipping comment"
104+
fi`
105+
106+
const renderProjectMoveScript = (): string =>
107+
String.raw`#!/bin/bash
108+
. /run/docker-git/agent-env.sh 2>/dev/null || true
109+
cd "$1" || exit 1
110+
ISSUE_NUM="$2"
111+
112+
ISSUE_NODE_ID=$(gh issue view "$ISSUE_NUM" --json id --jq '.id' 2>/dev/null) || true
113+
if [[ -z "$ISSUE_NODE_ID" ]]; then
114+
echo "[agent] could not get issue node ID, skipping move"
115+
exit 0
116+
fi
117+
118+
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 } } } } } } } } }'
119+
ALL_IDS=$(gh api graphql -F nodeId="$ISSUE_NODE_ID" -f query="$GQL_QUERY" \
120+
--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
121+
122+
if [[ -z "$ALL_IDS" ]]; then
123+
echo "[agent] issue #$ISSUE_NUM is not in a project board, skipping move"
124+
exit 0
125+
fi
126+
127+
ITEM_ID=$(printf "%s" "$ALL_IDS" | cut -f1)
128+
PROJECT_ID=$(printf "%s" "$ALL_IDS" | cut -f2)
129+
STATUS_FIELD_ID=$(printf "%s" "$ALL_IDS" | cut -f3)
130+
REVIEW_OPTION_ID=$(printf "%s" "$ALL_IDS" | cut -f4)
131+
if [[ -z "$STATUS_FIELD_ID" || -z "$REVIEW_OPTION_ID" || "$STATUS_FIELD_ID" == "null" || "$REVIEW_OPTION_ID" == "null" ]]; then
132+
echo "[agent] review status not found in project board, skipping move"
133+
exit 0
134+
fi
135+
136+
MUTATION='mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { updateProjectV2ItemFieldValue(input: { projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { singleSelectOptionId: $optionId } }) { projectV2Item { id } } }'
137+
MOVE_RESULT=$(gh api graphql \
138+
-F projectId="$PROJECT_ID" \
139+
-F itemId="$ITEM_ID" \
140+
-F fieldId="$STATUS_FIELD_ID" \
141+
-F optionId="$REVIEW_OPTION_ID" \
142+
-f query="$MUTATION" 2>&1) || true
143+
144+
if [[ "$MOVE_RESULT" == *"projectV2Item"* ]]; then
145+
echo "[agent] issue #$ISSUE_NUM moved to review"
146+
else
147+
echo "[agent] failed to move issue #$ISSUE_NUM in project board"
148+
fi`
149+
150+
const renderAgentIssueMove = (config: TemplateConfig): string =>
151+
[
152+
String.raw`echo "[agent] moving issue #$ISSUE_NUM to review..."
153+
MOVE_SCRIPT="/run/docker-git/project-move.sh"`,
154+
String.raw`cat > "$MOVE_SCRIPT" << 'EOFMOVE'
155+
${renderProjectMoveScript()}
156+
EOFMOVE`,
157+
String.raw`chmod +x "$MOVE_SCRIPT"
158+
su - ${config.sshUser} -c "$MOVE_SCRIPT '$TARGET_DIR' '$ISSUE_NUM'" || true`
159+
].join("\n")
160+
161+
const renderAgentIssueReview = (config: TemplateConfig): string =>
162+
[
163+
String.raw`if [[ "$AGENT_OK" -eq 1 && "$AGENT_AUTO" == "1" && -n "$ISSUE_NUM" ]]; then`,
164+
indentBlock(renderAgentIssueComment(config)),
165+
"",
166+
indentBlock(renderAgentIssueMove(config)),
167+
"fi"
168+
].join("\n")
169+
170+
const renderAgentFinalize = (): string =>
171+
String.raw`if [[ "$AGENT_OK" -eq 1 ]]; then
172+
echo "[agent] done"
173+
touch "$AGENT_DONE_PATH"
174+
else
175+
echo "[agent] failed"
176+
touch "$AGENT_FAIL_PATH"
177+
fi`
178+
179+
export const renderAgentLaunch = (config: TemplateConfig): string =>
180+
[
181+
String.raw`# 3) Auto-launch agent if AGENT_MODE is set
182+
if [[ "$CLONE_OK" -eq 1 && -n "$AGENT_MODE" ]]; then`,
183+
indentBlock(renderAgentSetup()),
184+
"",
185+
indentBlock(renderAgentModeCase(config)),
186+
"",
187+
indentBlock(renderAgentIssueReview(config)),
188+
"",
189+
indentBlock(renderAgentFinalize()),
190+
"fi"
191+
].join("\n")

packages/lib/src/core/templates-entrypoint/base.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ GITHUB_TOKEN="\${GITHUB_TOKEN:-\${GH_TOKEN:-}}"
2323
GIT_USER_NAME="\${GIT_USER_NAME:-}"
2424
GIT_USER_EMAIL="\${GIT_USER_EMAIL:-}"
2525
CODEX_AUTO_UPDATE="\${CODEX_AUTO_UPDATE:-1}"
26+
AGENT_MODE="\${AGENT_MODE:-}"
27+
AGENT_AUTO="\${AGENT_AUTO:-}"
2628
MCP_PLAYWRIGHT_ENABLE="\${MCP_PLAYWRIGHT_ENABLE:-${config.enableMcpPlaywright ? "1" : "0"}}"
2729
MCP_PLAYWRIGHT_CDP_ENDPOINT="\${MCP_PLAYWRIGHT_CDP_ENDPOINT:-}"
2830
MCP_PLAYWRIGHT_ISOLATED="\${MCP_PLAYWRIGHT_ISOLATED:-1}"

packages/lib/src/core/templates-entrypoint/tasks.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { TemplateConfig } from "../domain.js"
2+
import { renderAgentLaunch } from "./agent.js"
23

34
const renderEntrypointAutoUpdate = (): string =>
45
`# 1) Keep Codex CLI up to date if requested (bun only)
@@ -203,4 +204,6 @@ export const renderEntrypointBackgroundTasks = (config: TemplateConfig): string
203204
${renderEntrypointAutoUpdate()}
204205
205206
${renderEntrypointClone(config)}
207+
208+
${renderAgentLaunch(config)}
206209
) &`

0 commit comments

Comments
 (0)