liutaio 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +279 -0
- package/agents-template.md +152 -0
- package/bin/liutaio +9 -0
- package/docker/Dockerfile +27 -0
- package/docker/entrypoint.sh +567 -0
- package/docker/run.sh +366 -0
- package/package.json +32 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
AGENTS_FILE="${1:-}"
|
|
5
|
+
ITERATIONS="${2:-}"
|
|
6
|
+
BASE_BRANCH="${3:-}"
|
|
7
|
+
LOGDIR="${4:-/tmp/liutaio}"
|
|
8
|
+
|
|
9
|
+
if [ -z "$AGENTS_FILE" ] || [ -z "$ITERATIONS" ] || [ -z "$BASE_BRANCH" ]; then
|
|
10
|
+
echo "Usage: liutaio <agents-file> <iterations> <base-branch> [log-dir]"
|
|
11
|
+
echo ""
|
|
12
|
+
echo " agents-file Path to agents.md relative to repo root"
|
|
13
|
+
echo " iterations Number of ralph loop iterations"
|
|
14
|
+
echo " base-branch Name of the base branch to create from main"
|
|
15
|
+
echo " log-dir Log directory (default: /tmp/liutaio)"
|
|
16
|
+
exit 1
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
# ─── Authenticate ───────────────────────────────────────────────────
|
|
20
|
+
# Priority: 1) mounted/cached credentials 2) API key 3) interactive OAuth
|
|
21
|
+
CLAUDE_DIR="$HOME/.claude"
|
|
22
|
+
mkdir -p "$CLAUDE_DIR"
|
|
23
|
+
|
|
24
|
+
NEED_LOGIN=true
|
|
25
|
+
|
|
26
|
+
if [ -f /credentials/credentials.json ]; then
|
|
27
|
+
cp /credentials/credentials.json "$CLAUDE_DIR/.credentials.json"
|
|
28
|
+
chmod 600 "$CLAUDE_DIR/.credentials.json"
|
|
29
|
+
echo "Auth: credentials loaded from mount/cache"
|
|
30
|
+
NEED_LOGIN=false
|
|
31
|
+
elif [ -f "$CLAUDE_DIR/.credentials.json" ]; then
|
|
32
|
+
if jq -e '.claudeAiOauth.accessToken' "$CLAUDE_DIR/.credentials.json" >/dev/null 2>&1; then
|
|
33
|
+
echo "Auth: existing credentials found"
|
|
34
|
+
NEED_LOGIN=false
|
|
35
|
+
fi
|
|
36
|
+
elif [ -n "${ANTHROPIC_API_KEY:-}" ]; then
|
|
37
|
+
echo "Auth: using ANTHROPIC_API_KEY"
|
|
38
|
+
NEED_LOGIN=false
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
# ─── Interactive OAuth PKCE flow (fallback) ─────────────────────────
|
|
42
|
+
if $NEED_LOGIN; then
|
|
43
|
+
echo ""
|
|
44
|
+
echo "============================================"
|
|
45
|
+
echo " Claude OAuth Login"
|
|
46
|
+
echo "============================================"
|
|
47
|
+
echo ""
|
|
48
|
+
|
|
49
|
+
OAUTH_CLIENT_ID="${LIUTAIO_OAUTH_CLIENT_ID:-9d1c250a-e61b-44d9-88ed-5944d1962f5e}"
|
|
50
|
+
OAUTH_AUTHORIZE="https://claude.com/cai/oauth/authorize"
|
|
51
|
+
OAUTH_TOKEN="https://platform.claude.com/v1/oauth/token"
|
|
52
|
+
OAUTH_REDIRECT="https://platform.claude.com/oauth/code/callback"
|
|
53
|
+
OAUTH_SCOPE="org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"
|
|
54
|
+
|
|
55
|
+
# Generate PKCE code_verifier (43-128 chars from [A-Za-z0-9])
|
|
56
|
+
# tr -d '\n' removes newlines that base64 inserts every 76 chars
|
|
57
|
+
CODE_VERIFIER=$(head -c 96 /dev/urandom | base64 | tr -d '\n=+/' | head -c 128)
|
|
58
|
+
|
|
59
|
+
# code_challenge = base64url(sha256(code_verifier))
|
|
60
|
+
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" \
|
|
61
|
+
| openssl dgst -sha256 -binary \
|
|
62
|
+
| base64 \
|
|
63
|
+
| tr -d '\n' \
|
|
64
|
+
| tr '+/' '-_' \
|
|
65
|
+
| tr -d '=')
|
|
66
|
+
|
|
67
|
+
STATE=$(head -c 32 /dev/urandom | base64 | tr -d '\n=+/' | head -c 43)
|
|
68
|
+
|
|
69
|
+
ENCODED_SCOPE=$(printf '%s' "$OAUTH_SCOPE" | sed 's/ /%20/g')
|
|
70
|
+
ENCODED_REDIRECT=$(printf '%s' "$OAUTH_REDIRECT" | jq -sRr @uri)
|
|
71
|
+
AUTH_URL="${OAUTH_AUTHORIZE}?code=true&client_id=${OAUTH_CLIENT_ID}&response_type=code&redirect_uri=${ENCODED_REDIRECT}&scope=${ENCODED_SCOPE}&code_challenge=${CODE_CHALLENGE}&code_challenge_method=S256&state=${STATE}"
|
|
72
|
+
|
|
73
|
+
echo " Open this URL in your browser and authorise:"
|
|
74
|
+
echo ""
|
|
75
|
+
echo " $AUTH_URL"
|
|
76
|
+
echo ""
|
|
77
|
+
echo " After authorising, you'll see a page with a code."
|
|
78
|
+
echo " Copy the ENTIRE code (including any # in the middle)"
|
|
79
|
+
echo " and paste it below."
|
|
80
|
+
echo ""
|
|
81
|
+
printf " Code: "
|
|
82
|
+
read -r AUTH_CODE </dev/tty
|
|
83
|
+
|
|
84
|
+
if [ -z "$AUTH_CODE" ]; then
|
|
85
|
+
echo "Error: No code entered."
|
|
86
|
+
exit 1
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
# Pasted code format: "{authorization_code}#{state}"
|
|
90
|
+
OAUTH_CODE="${AUTH_CODE%%#*}"
|
|
91
|
+
OAUTH_STATE="${AUTH_CODE#*#}"
|
|
92
|
+
|
|
93
|
+
if [ -z "$OAUTH_CODE" ] || [ "$OAUTH_CODE" = "$AUTH_CODE" ]; then
|
|
94
|
+
echo "Error: Invalid code format. Expected {code}#{state}."
|
|
95
|
+
exit 1
|
|
96
|
+
fi
|
|
97
|
+
|
|
98
|
+
echo ""
|
|
99
|
+
echo " Exchanging code for tokens..."
|
|
100
|
+
|
|
101
|
+
TOKEN_BODY=$(jq -n \
|
|
102
|
+
--arg grant_type "authorization_code" \
|
|
103
|
+
--arg code "$OAUTH_CODE" \
|
|
104
|
+
--arg redirect_uri "$OAUTH_REDIRECT" \
|
|
105
|
+
--arg client_id "$OAUTH_CLIENT_ID" \
|
|
106
|
+
--arg code_verifier "$CODE_VERIFIER" \
|
|
107
|
+
--arg state "$OAUTH_STATE" \
|
|
108
|
+
'{grant_type: $grant_type, code: $code, redirect_uri: $redirect_uri, client_id: $client_id, code_verifier: $code_verifier, state: $state}')
|
|
109
|
+
|
|
110
|
+
TOKEN_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$OAUTH_TOKEN" \
|
|
111
|
+
-H "Content-Type: application/json" \
|
|
112
|
+
-H "User-Agent: claude-code" \
|
|
113
|
+
-d "$TOKEN_BODY" \
|
|
114
|
+
2>&1)
|
|
115
|
+
|
|
116
|
+
HTTP_CODE=$(echo "$TOKEN_RESPONSE" | tail -1)
|
|
117
|
+
RESPONSE_BODY=$(echo "$TOKEN_RESPONSE" | sed '$d')
|
|
118
|
+
|
|
119
|
+
ACCESS_TOKEN=$(echo "$RESPONSE_BODY" | jq -r '.access_token // empty')
|
|
120
|
+
REFRESH_TOKEN=$(echo "$RESPONSE_BODY" | jq -r '.refresh_token // empty')
|
|
121
|
+
EXPIRES_IN=$(echo "$RESPONSE_BODY" | jq -r '.expires_in // empty')
|
|
122
|
+
|
|
123
|
+
if [ -z "$ACCESS_TOKEN" ]; then
|
|
124
|
+
echo " Error: Token exchange failed (HTTP $HTTP_CODE):"
|
|
125
|
+
echo " $RESPONSE_BODY"
|
|
126
|
+
exit 1
|
|
127
|
+
fi
|
|
128
|
+
|
|
129
|
+
# expiresAt must be Unix epoch in MILLISECONDS
|
|
130
|
+
EXPIRES_AT_MS=0
|
|
131
|
+
if [ -n "$EXPIRES_IN" ] && [ "$EXPIRES_IN" != "null" ]; then
|
|
132
|
+
EXPIRES_AT_MS=$(( $(date +%s) * 1000 + EXPIRES_IN * 1000 ))
|
|
133
|
+
fi
|
|
134
|
+
|
|
135
|
+
# Build credentials.json in the exact format Claude Code expects
|
|
136
|
+
CREDS_JSON=$(jq -n \
|
|
137
|
+
--arg at "$ACCESS_TOKEN" \
|
|
138
|
+
--arg rt "${REFRESH_TOKEN:-}" \
|
|
139
|
+
--argjson ea "$EXPIRES_AT_MS" \
|
|
140
|
+
'{
|
|
141
|
+
claudeAiOauth: {
|
|
142
|
+
accessToken: $at,
|
|
143
|
+
refreshToken: $rt,
|
|
144
|
+
expiresAt: $ea,
|
|
145
|
+
scopes: [
|
|
146
|
+
"org:create_api_key",
|
|
147
|
+
"user:file_upload",
|
|
148
|
+
"user:inference",
|
|
149
|
+
"user:mcp_servers",
|
|
150
|
+
"user:profile",
|
|
151
|
+
"user:sessions:claude_code"
|
|
152
|
+
]
|
|
153
|
+
}
|
|
154
|
+
}')
|
|
155
|
+
|
|
156
|
+
echo "$CREDS_JSON" > "$CLAUDE_DIR/.credentials.json"
|
|
157
|
+
chmod 600 "$CLAUDE_DIR/.credentials.json"
|
|
158
|
+
|
|
159
|
+
echo " Login successful."
|
|
160
|
+
|
|
161
|
+
# Persist credentials to mounted volume so they survive container restarts
|
|
162
|
+
if [ -d /credentials ] && [ -w /credentials ]; then
|
|
163
|
+
cp "$CLAUDE_DIR/.credentials.json" /credentials/credentials.json
|
|
164
|
+
chmod 600 /credentials/credentials.json
|
|
165
|
+
echo " Credentials cached (will persist across restarts)."
|
|
166
|
+
fi
|
|
167
|
+
|
|
168
|
+
echo ""
|
|
169
|
+
fi
|
|
170
|
+
|
|
171
|
+
AGENTS_DIR=$(dirname "$AGENTS_FILE")
|
|
172
|
+
|
|
173
|
+
echo ""
|
|
174
|
+
echo "============================================"
|
|
175
|
+
echo " Liutaio"
|
|
176
|
+
echo "============================================"
|
|
177
|
+
echo " Agents file : $AGENTS_FILE"
|
|
178
|
+
echo " Iterations : $ITERATIONS"
|
|
179
|
+
echo " Base branch : $BASE_BRANCH"
|
|
180
|
+
echo " Log dir : $LOGDIR"
|
|
181
|
+
echo "============================================"
|
|
182
|
+
|
|
183
|
+
# ─── Clone from mounted source repo ──────────────────────────────────
|
|
184
|
+
echo ""
|
|
185
|
+
echo "[1/7] Cloning repository from /repo..."
|
|
186
|
+
git clone /repo /workspace 2>&1 | tail -1
|
|
187
|
+
cd /workspace
|
|
188
|
+
|
|
189
|
+
# Repoint origin from local /repo mount to the real remote
|
|
190
|
+
REAL_REMOTE=$(git -C /repo remote get-url origin 2>/dev/null || echo "")
|
|
191
|
+
if [ -n "$REAL_REMOTE" ]; then
|
|
192
|
+
git remote set-url origin "$REAL_REMOTE"
|
|
193
|
+
echo " Remote set to: $REAL_REMOTE"
|
|
194
|
+
fi
|
|
195
|
+
|
|
196
|
+
# Copy agents file (and its directory) from host if not on main
|
|
197
|
+
if [ ! -f "/workspace/$AGENTS_FILE" ] && [ -f "/repo/$AGENTS_FILE" ]; then
|
|
198
|
+
echo " Agents file not on main — copying from host repo..."
|
|
199
|
+
mkdir -p "/workspace/$AGENTS_DIR"
|
|
200
|
+
cp -r "/repo/$AGENTS_DIR/." "/workspace/$AGENTS_DIR/"
|
|
201
|
+
echo " Copied: $AGENTS_DIR/"
|
|
202
|
+
fi
|
|
203
|
+
|
|
204
|
+
# Copy liutaio.setup.sh from host if not in git
|
|
205
|
+
if [ ! -f "/workspace/liutaio.setup.sh" ] && [ -f "/repo/liutaio.setup.sh" ]; then
|
|
206
|
+
cp "/repo/liutaio.setup.sh" "/workspace/liutaio.setup.sh"
|
|
207
|
+
chmod +x "/workspace/liutaio.setup.sh"
|
|
208
|
+
echo " Copied: liutaio.setup.sh"
|
|
209
|
+
fi
|
|
210
|
+
|
|
211
|
+
# ─── Checkout main and create base branch ────────────────────────────
|
|
212
|
+
echo "[2/7] Creating base branch '$BASE_BRANCH' from main..."
|
|
213
|
+
git checkout main --quiet
|
|
214
|
+
git checkout -b "$BASE_BRANCH" --quiet
|
|
215
|
+
|
|
216
|
+
# ─── Git config ──────────────────────────────────────────────────────
|
|
217
|
+
git config user.name "${GIT_USER_NAME:-Liutaio Agent}"
|
|
218
|
+
git config user.email "${GIT_USER_EMAIL:-liutaio@users.noreply.github.com}"
|
|
219
|
+
|
|
220
|
+
# ─── SSH setup ───────────────────────────────────────────────────────
|
|
221
|
+
echo "[3/7] Setting up SSH..."
|
|
222
|
+
SSH_DIR="$HOME/.ssh"
|
|
223
|
+
mkdir -p "$SSH_DIR"
|
|
224
|
+
chmod 700 "$SSH_DIR"
|
|
225
|
+
|
|
226
|
+
if [ -d /ssh-keys ]; then
|
|
227
|
+
cp /ssh-keys/id_* "$SSH_DIR/" 2>/dev/null || true
|
|
228
|
+
chmod 600 "$SSH_DIR"/id_* 2>/dev/null || true
|
|
229
|
+
if [ -f /ssh-keys/config ]; then
|
|
230
|
+
cp /ssh-keys/config "$SSH_DIR/config"
|
|
231
|
+
chmod 600 "$SSH_DIR/config"
|
|
232
|
+
fi
|
|
233
|
+
fi
|
|
234
|
+
|
|
235
|
+
if [ -n "${LIUTAIO_SSH_HOSTS:-}" ]; then
|
|
236
|
+
IFS=',' read -ra HOSTS <<< "$LIUTAIO_SSH_HOSTS"
|
|
237
|
+
for host in "${HOSTS[@]}"; do
|
|
238
|
+
timeout 5 ssh-keyscan "$(echo "$host" | xargs)" >> "$SSH_DIR/known_hosts" 2>/dev/null || true
|
|
239
|
+
done
|
|
240
|
+
elif [ -n "$REAL_REMOTE" ]; then
|
|
241
|
+
SSH_HOST=$(echo "$REAL_REMOTE" | sed -n 's|.*@\([^:]*\):.*|\1|p; s|.*://\([^/]*\)/.*|\1|p' | head -1)
|
|
242
|
+
if [ -n "$SSH_HOST" ]; then
|
|
243
|
+
if [ -f "$SSH_DIR/config" ]; then
|
|
244
|
+
REAL_HOST=$(awk -v host="$SSH_HOST" '
|
|
245
|
+
tolower($1) == "host" && $2 == host { found=1; next }
|
|
246
|
+
tolower($1) == "host" { found=0 }
|
|
247
|
+
found && tolower($1) == "hostname" { print $2; exit }
|
|
248
|
+
' "$SSH_DIR/config")
|
|
249
|
+
if [ -n "$REAL_HOST" ]; then
|
|
250
|
+
echo " Resolved SSH alias '$SSH_HOST' -> '$REAL_HOST'"
|
|
251
|
+
SSH_HOST="$REAL_HOST"
|
|
252
|
+
fi
|
|
253
|
+
fi
|
|
254
|
+
timeout 5 ssh-keyscan "$SSH_HOST" >> "$SSH_DIR/known_hosts" 2>/dev/null || true
|
|
255
|
+
echo " SSH host: $SSH_HOST"
|
|
256
|
+
fi
|
|
257
|
+
else
|
|
258
|
+
timeout 5 ssh-keyscan github.com >> "$SSH_DIR/known_hosts" 2>/dev/null || true
|
|
259
|
+
echo " SSH host: github.com (default)"
|
|
260
|
+
fi
|
|
261
|
+
|
|
262
|
+
# ─── Install dependencies ────────────────────────────────────────────
|
|
263
|
+
echo "[4/7] Installing dependencies..."
|
|
264
|
+
|
|
265
|
+
install_deps() {
|
|
266
|
+
if [ -x "./liutaio.setup.sh" ]; then
|
|
267
|
+
echo " Running liutaio.setup.sh..."
|
|
268
|
+
bash ./liutaio.setup.sh
|
|
269
|
+
return
|
|
270
|
+
fi
|
|
271
|
+
|
|
272
|
+
if [ -f "pnpm-lock.yaml" ]; then
|
|
273
|
+
echo " Detected pnpm project"
|
|
274
|
+
npm install -g pnpm 2>&1 | tail -1
|
|
275
|
+
pnpm install 2>&1 | tail -3
|
|
276
|
+
elif [ -f "bun.lock" ] || [ -f "bun.lockb" ]; then
|
|
277
|
+
echo " Detected Bun project"
|
|
278
|
+
npm install -g bun 2>&1 | tail -1
|
|
279
|
+
bun install 2>&1 | tail -3
|
|
280
|
+
elif [ -f "yarn.lock" ]; then
|
|
281
|
+
echo " Detected Yarn project"
|
|
282
|
+
yarn install 2>&1 | tail -3
|
|
283
|
+
elif [ -f "package-lock.json" ] || [ -f "package.json" ]; then
|
|
284
|
+
echo " Detected npm project"
|
|
285
|
+
npm install 2>&1 | tail -3
|
|
286
|
+
elif [ -f "requirements.txt" ]; then
|
|
287
|
+
echo " Detected Python project (requirements.txt)"
|
|
288
|
+
pip install -r requirements.txt 2>&1 | tail -3
|
|
289
|
+
elif [ -f "pyproject.toml" ]; then
|
|
290
|
+
echo " Detected Python project (pyproject.toml)"
|
|
291
|
+
pip install -e . 2>&1 | tail -3
|
|
292
|
+
elif [ -f "Pipfile" ]; then
|
|
293
|
+
echo " Detected Pipenv project"
|
|
294
|
+
pip install pipenv 2>&1 | tail -1
|
|
295
|
+
pipenv install 2>&1 | tail -3
|
|
296
|
+
elif [ -f "go.sum" ]; then
|
|
297
|
+
echo " Detected Go project"
|
|
298
|
+
go mod download 2>&1 | tail -3
|
|
299
|
+
elif [ -f "Cargo.lock" ] || [ -f "Cargo.toml" ]; then
|
|
300
|
+
echo " Detected Rust project"
|
|
301
|
+
cargo fetch 2>&1 | tail -3
|
|
302
|
+
elif [ -f "Gemfile.lock" ]; then
|
|
303
|
+
echo " Detected Ruby project"
|
|
304
|
+
bundle install 2>&1 | tail -3
|
|
305
|
+
elif [ -f "composer.json" ]; then
|
|
306
|
+
echo " Detected PHP project"
|
|
307
|
+
composer install 2>&1 | tail -3
|
|
308
|
+
else
|
|
309
|
+
echo " No recognised project type — skipping dependency install"
|
|
310
|
+
echo " Create a liutaio.setup.sh for custom setup"
|
|
311
|
+
fi
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
install_deps
|
|
315
|
+
|
|
316
|
+
# ─── Post-checkout hook (auto reinstall on dependency file changes) ───
|
|
317
|
+
echo "[5/7] Setting up git hooks..."
|
|
318
|
+
mkdir -p .git/hooks
|
|
319
|
+
cat > .git/hooks/post-checkout << 'HOOK'
|
|
320
|
+
#!/bin/bash
|
|
321
|
+
OLD_HEAD="$1"
|
|
322
|
+
NEW_HEAD="$2"
|
|
323
|
+
IS_BRANCH_CHECKOUT="$3"
|
|
324
|
+
|
|
325
|
+
[ "$IS_BRANCH_CHECKOUT" = "1" ] || exit 0
|
|
326
|
+
|
|
327
|
+
CHANGED=$(git diff --name-only "$OLD_HEAD" "$NEW_HEAD" -- \
|
|
328
|
+
'**/package.json' '**/package-lock.json' \
|
|
329
|
+
'**/yarn.lock' '**/pnpm-lock.yaml' '**/bun.lock' \
|
|
330
|
+
'**/requirements.txt' '**/Pipfile.lock' '**/pyproject.toml' \
|
|
331
|
+
'**/go.sum' '**/Cargo.lock' \
|
|
332
|
+
'**/Gemfile.lock' '**/composer.lock' \
|
|
333
|
+
2>/dev/null | head -1)
|
|
334
|
+
|
|
335
|
+
if [ -n "$CHANGED" ]; then
|
|
336
|
+
echo "post-checkout: dependency files changed, reinstalling..."
|
|
337
|
+
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
338
|
+
cd "$REPO_ROOT"
|
|
339
|
+
if [ -x "./liutaio.setup.sh" ]; then
|
|
340
|
+
bash ./liutaio.setup.sh
|
|
341
|
+
elif [ -f "pnpm-lock.yaml" ]; then pnpm install
|
|
342
|
+
elif [ -f "bun.lock" ] || [ -f "bun.lockb" ]; then bun install
|
|
343
|
+
elif [ -f "yarn.lock" ]; then yarn install
|
|
344
|
+
elif [ -f "package-lock.json" ] || [ -f "package.json" ]; then npm install
|
|
345
|
+
elif [ -f "requirements.txt" ]; then pip install -r requirements.txt
|
|
346
|
+
elif [ -f "pyproject.toml" ]; then pip install -e .
|
|
347
|
+
elif [ -f "go.sum" ]; then go mod download
|
|
348
|
+
elif [ -f "Cargo.lock" ]; then cargo fetch
|
|
349
|
+
elif [ -f "Gemfile.lock" ]; then bundle install
|
|
350
|
+
elif [ -f "composer.json" ]; then composer install
|
|
351
|
+
fi
|
|
352
|
+
fi
|
|
353
|
+
HOOK
|
|
354
|
+
chmod +x .git/hooks/post-checkout
|
|
355
|
+
|
|
356
|
+
git config core.hooksPath .git/hooks
|
|
357
|
+
|
|
358
|
+
# ─── Output directory ─────────────────────────────────────────────────
|
|
359
|
+
echo "[6/7] Checking output directory..."
|
|
360
|
+
if [ -d /output ]; then
|
|
361
|
+
echo " /output mounted — progress.md will be copied after each iteration"
|
|
362
|
+
else
|
|
363
|
+
echo " Warning: /output not mounted — progress.md will only exist inside container"
|
|
364
|
+
fi
|
|
365
|
+
|
|
366
|
+
echo "[7/7] Ready."
|
|
367
|
+
|
|
368
|
+
echo ""
|
|
369
|
+
echo "============================================"
|
|
370
|
+
echo " Liutaio ready. Starting loop..."
|
|
371
|
+
echo "============================================"
|
|
372
|
+
echo ""
|
|
373
|
+
|
|
374
|
+
# ─── Token refresh helper ─────────────────────────────────────────────
|
|
375
|
+
OAUTH_CLIENT_ID="${LIUTAIO_OAUTH_CLIENT_ID:-9d1c250a-e61b-44d9-88ed-5944d1962f5e}"
|
|
376
|
+
TOKEN_ENDPOINT="https://platform.claude.com/v1/oauth/token"
|
|
377
|
+
|
|
378
|
+
refresh_oauth_token() {
|
|
379
|
+
# Skip refresh for host credentials — refreshing invalidates the host's token
|
|
380
|
+
local auth_method="${LIUTAIO_AUTH_METHOD:-}"
|
|
381
|
+
if [ "$auth_method" = "keychain" ] || [ "$auth_method" = "credentials-file" ]; then
|
|
382
|
+
return 0
|
|
383
|
+
fi
|
|
384
|
+
|
|
385
|
+
local creds_file="$HOME/.claude/.credentials.json"
|
|
386
|
+
[ -f "$creds_file" ] || return 0
|
|
387
|
+
|
|
388
|
+
local refresh_token
|
|
389
|
+
refresh_token=$(jq -r '.claudeAiOauth.refreshToken // empty' "$creds_file" 2>/dev/null)
|
|
390
|
+
[ -n "$refresh_token" ] || return 0
|
|
391
|
+
|
|
392
|
+
echo " Refreshing OAuth token..."
|
|
393
|
+
local response
|
|
394
|
+
local refresh_scope="user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"
|
|
395
|
+
local refresh_body
|
|
396
|
+
refresh_body=$(jq -n \
|
|
397
|
+
--arg grant_type "refresh_token" \
|
|
398
|
+
--arg refresh_token "$refresh_token" \
|
|
399
|
+
--arg client_id "$OAUTH_CLIENT_ID" \
|
|
400
|
+
--arg scope "$refresh_scope" \
|
|
401
|
+
'{grant_type: $grant_type, refresh_token: $refresh_token, client_id: $client_id, scope: $scope}')
|
|
402
|
+
response=$(curl -s -X POST "$TOKEN_ENDPOINT" \
|
|
403
|
+
-H "Content-Type: application/json" \
|
|
404
|
+
-H "User-Agent: claude-code" \
|
|
405
|
+
-d "$refresh_body" \
|
|
406
|
+
2>/dev/null)
|
|
407
|
+
|
|
408
|
+
local new_access_token new_refresh_token
|
|
409
|
+
new_access_token=$(echo "$response" | jq -r '.access_token // empty' 2>/dev/null)
|
|
410
|
+
new_refresh_token=$(echo "$response" | jq -r '.refresh_token // empty' 2>/dev/null)
|
|
411
|
+
|
|
412
|
+
if [ -n "$new_access_token" ]; then
|
|
413
|
+
local new_expires_in
|
|
414
|
+
new_expires_in=$(echo "$response" | jq -r '.expires_in // empty' 2>/dev/null)
|
|
415
|
+
local new_expires_at=0
|
|
416
|
+
if [ -n "$new_expires_in" ] && [ "$new_expires_in" != "null" ]; then
|
|
417
|
+
new_expires_at=$(( $(date +%s) * 1000 + new_expires_in * 1000 ))
|
|
418
|
+
fi
|
|
419
|
+
|
|
420
|
+
local updated
|
|
421
|
+
updated=$(jq \
|
|
422
|
+
--arg at "$new_access_token" \
|
|
423
|
+
--arg rt "${new_refresh_token:-$refresh_token}" \
|
|
424
|
+
--argjson ea "$new_expires_at" \
|
|
425
|
+
'.claudeAiOauth.accessToken = $at | .claudeAiOauth.refreshToken = $rt | .claudeAiOauth.expiresAt = $ea' "$creds_file")
|
|
426
|
+
echo "$updated" > "$creds_file"
|
|
427
|
+
chmod 600 "$creds_file"
|
|
428
|
+
echo " Token refreshed successfully."
|
|
429
|
+
|
|
430
|
+
# Persist refreshed credentials to mounted volume
|
|
431
|
+
if [ -d /credentials ] && [ -w /credentials ]; then
|
|
432
|
+
cp "$creds_file" /credentials/credentials.json
|
|
433
|
+
chmod 600 /credentials/credentials.json
|
|
434
|
+
fi
|
|
435
|
+
else
|
|
436
|
+
echo " Warning: token refresh failed (will retry next iteration)"
|
|
437
|
+
echo " Response: $(echo "$response" | head -c 200)"
|
|
438
|
+
fi
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
# ─── Push helper ─────────────────────────────────────────────────────
|
|
442
|
+
push_base_branch() {
|
|
443
|
+
local current_branch
|
|
444
|
+
current_branch=$(git branch --show-current)
|
|
445
|
+
|
|
446
|
+
if [ "$current_branch" != "$BASE_BRANCH" ]; then
|
|
447
|
+
git stash --quiet 2>/dev/null || true
|
|
448
|
+
git checkout "$BASE_BRANCH" --quiet 2>/dev/null || true
|
|
449
|
+
fi
|
|
450
|
+
|
|
451
|
+
local commit_count
|
|
452
|
+
commit_count=$(git rev-list --count main.."$BASE_BRANCH" 2>/dev/null || echo "0")
|
|
453
|
+
if [ "$commit_count" -gt 0 ]; then
|
|
454
|
+
echo "Pushing $commit_count commit(s) on '$BASE_BRANCH' to origin..."
|
|
455
|
+
git push origin "$BASE_BRANCH" 2>&1 || echo "Warning: push failed"
|
|
456
|
+
fi
|
|
457
|
+
|
|
458
|
+
if [ "$current_branch" != "$BASE_BRANCH" ]; then
|
|
459
|
+
git checkout "$current_branch" --quiet 2>/dev/null || true
|
|
460
|
+
git stash pop --quiet 2>/dev/null || true
|
|
461
|
+
fi
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
# ─── Trap: push on exit (safety net for crashes/stops) ───────────────
|
|
465
|
+
trap 'echo ""; echo "Container stopping — pushing base branch..."; push_base_branch' EXIT
|
|
466
|
+
|
|
467
|
+
# ─── Run the loop ────────────────────────────────────────────────────
|
|
468
|
+
mkdir -p "$LOGDIR"
|
|
469
|
+
|
|
470
|
+
START_TIME=$SECONDS
|
|
471
|
+
|
|
472
|
+
elapsed() {
|
|
473
|
+
local total=$(( SECONDS - START_TIME ))
|
|
474
|
+
local h=$(( total / 3600 ))
|
|
475
|
+
local m=$(( (total % 3600) / 60 ))
|
|
476
|
+
local s=$(( total % 60 ))
|
|
477
|
+
printf "%dh %dm %ds" "$h" "$m" "$s"
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
for ((i=1; i<=$ITERATIONS; i++)); do
|
|
481
|
+
LOGFILE="$LOGDIR/iteration-${i}.log"
|
|
482
|
+
|
|
483
|
+
# Copy progress.md to host before each iteration (safety net for crashes)
|
|
484
|
+
if [ -d /output ] && [ -f "/workspace/$AGENTS_DIR/progress.md" ]; then
|
|
485
|
+
cp "/workspace/$AGENTS_DIR/progress.md" /output/progress.md 2>/dev/null || true
|
|
486
|
+
fi
|
|
487
|
+
|
|
488
|
+
# Refresh OAuth token before each iteration
|
|
489
|
+
refresh_oauth_token
|
|
490
|
+
|
|
491
|
+
echo "============================================"
|
|
492
|
+
echo "Iteration $i / $ITERATIONS — $(date)"
|
|
493
|
+
echo "Logging to: $LOGFILE"
|
|
494
|
+
echo "============================================"
|
|
495
|
+
|
|
496
|
+
# Show which ticket is next
|
|
497
|
+
NEXT_TICKET=""
|
|
498
|
+
PROGRESS_FILE="/workspace/$AGENTS_DIR/progress.md"
|
|
499
|
+
TICKETS_DIR="/workspace/$AGENTS_DIR/tickets"
|
|
500
|
+
|
|
501
|
+
if [ -f "$PROGRESS_FILE" ]; then
|
|
502
|
+
# Try "## Next Steps" section first
|
|
503
|
+
NEXT_TICKET=$(awk '/^## Next Steps/{found=1; next} /^## /{found=0} found && /[^ \t]/{print; exit}' "$PROGRESS_FILE" | sed 's/^[[:space:]-]*//' || true)
|
|
504
|
+
|
|
505
|
+
# Fallback: first ticket in the table NOT marked DONE
|
|
506
|
+
if [ -z "$NEXT_TICKET" ]; then
|
|
507
|
+
NEXT_ID=$(awk -F'|' '/\|.*\|.*\|/ && !/DONE/ && !/Status/' '{gsub(/[ \t]/, "", $2); if ($2 != "" && $2 != "---") print $2; }' "$PROGRESS_FILE" | head -1 || true)
|
|
508
|
+
if [ -n "$NEXT_ID" ] && [ -d "$TICKETS_DIR" ]; then
|
|
509
|
+
# Read the title (first line) from the ticket file
|
|
510
|
+
TICKET_FILE=$(ls "$TICKETS_DIR"/${NEXT_ID}* 2>/dev/null | head -1)
|
|
511
|
+
if [ -n "$TICKET_FILE" ]; then
|
|
512
|
+
NEXT_TICKET=$(head -1 "$TICKET_FILE" | sed 's/^#* *//')
|
|
513
|
+
else
|
|
514
|
+
NEXT_TICKET="$NEXT_ID"
|
|
515
|
+
fi
|
|
516
|
+
elif [ -n "$NEXT_ID" ]; then
|
|
517
|
+
NEXT_TICKET="$NEXT_ID"
|
|
518
|
+
fi
|
|
519
|
+
fi
|
|
520
|
+
fi
|
|
521
|
+
|
|
522
|
+
if [ -n "$NEXT_TICKET" ]; then
|
|
523
|
+
echo "Current Ticket: $NEXT_TICKET"
|
|
524
|
+
echo "============================================"
|
|
525
|
+
fi
|
|
526
|
+
|
|
527
|
+
claude --dangerously-skip-permissions --output-format stream-json --verbose \
|
|
528
|
+
-p "You are running in AFK (unattended) mode inside a Docker container (Liutaio). Follow the instructions in @${AGENTS_FILE}. IMPORTANT: Since no human is present, whenever a step says to ask the human for confirmation (e.g. merge confirmation, manual steps), auto-approve and proceed automatically. Answer 'yes' to your own merge prompts. For human-assisted tickets that require manual operations, skip them, note it in progress.md, and continue to the next ticket. IMPORTANT: Do NOT commit progress.md — it is tracked outside of git. IMPORTANT: Work on exactly ONE ticket per session. After completing one ticket (code committed, progress.md updated, branch merged and verified), STOP. Do not start the next ticket — the next iteration will handle it." \
|
|
529
|
+
2>"$LOGFILE.stderr" \
|
|
530
|
+
| grep --line-buffered '^{' \
|
|
531
|
+
| tee "$LOGFILE" \
|
|
532
|
+
| jq --unbuffered -rj '
|
|
533
|
+
if .type == "assistant" then
|
|
534
|
+
.message.content[]? |
|
|
535
|
+
if .type == "tool_use" then
|
|
536
|
+
" " + .name + ": " + (.input | tostring | .[0:200]) + "\n"
|
|
537
|
+
elif .type == "text" then
|
|
538
|
+
.text // empty
|
|
539
|
+
else empty end
|
|
540
|
+
elif .type == "result" then
|
|
541
|
+
"\nSession complete (" + (.total_cost_usd // 0 | tostring) + " USD)\n"
|
|
542
|
+
else empty end'
|
|
543
|
+
|
|
544
|
+
echo ""
|
|
545
|
+
|
|
546
|
+
# Push base branch after each iteration
|
|
547
|
+
echo "Pushing after iteration $i..."
|
|
548
|
+
push_base_branch
|
|
549
|
+
|
|
550
|
+
# Copy progress.md back to host
|
|
551
|
+
if [ -d /output ]; then
|
|
552
|
+
cp "/workspace/$AGENTS_DIR/progress.md" /output/progress.md 2>/dev/null || true
|
|
553
|
+
fi
|
|
554
|
+
|
|
555
|
+
# Check for completion token
|
|
556
|
+
if grep -q '<promise>.*COMPLETE.*</promise>' "$LOGFILE"; then
|
|
557
|
+
echo ""
|
|
558
|
+
echo "All tickets complete after $i iterations. Total time: $(elapsed)"
|
|
559
|
+
exit 0
|
|
560
|
+
fi
|
|
561
|
+
|
|
562
|
+
echo "Iteration $i finished. Completion token not found, continuing..."
|
|
563
|
+
echo ""
|
|
564
|
+
done
|
|
565
|
+
|
|
566
|
+
echo "Reached max iterations ($ITERATIONS) without completion token. Total time: $(elapsed)"
|
|
567
|
+
exit 1
|