Files
ai-setup/home-configs/claude/hooks/add-account-hook.sh
Виталий Никитенко 532d70f478 fix(add-account): unhang oauth login via fifo stdin
Логин Claude.ai подвисал навсегда: claude auth login ждёт код из браузера в stdin, а хук запускал его в фоне с </dev/null, куда код вставить невозможно. Теперь /add-account поднимает логин со stdin из fifo и печатает ссылку, а /add-account <код> отдаёт код, дожидается обмена и сохраняет новый аккаунт по email из токена. Провал логина больше не маскируется под успешное сохранение текущего аккаунта, а фоновые процессы гасятся строго по своим PID вместо широкого pkill -f. Поведение закреплено пятью тестами в tests/test_fixes.sh.
2026-08-08 19:20:10 +03:00

238 lines
9.6 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# Copyright (c) 2026 Виталий Никитенко. MIT License.
# UserPromptSubmit hook: перехватывает /add-account и /add-account <код>.
# Логин в два шага, целиком в этом же терминале (без второго окна):
# /add-account — сохраняет текущий аккаунт, поднимает фоновый
# claude auth login со stdin из fifo и печатает ссылку;
# /add-account <код> — отдаёт код из браузера в fifo, ждёт обмена, определяет
# email нового аккаунта по токену, сохраняет и делает current.
# CLI отдаёт redirect_uri на platform.claude.com и ждёт код в stdin — при
# </dev/null он висит вечно, поэтому stdin держит fifo, а не закрытый дескриптор.
input=$(cat)
prompt=$(echo "$input" | jq -r '.user_prompt // .prompt // empty' 2>/dev/null)
normalized=$(echo "$prompt" | sed 's|^[[:space:]]*/||; s|[[:space:]]*$||')
# Для /add-account ключ передаётся в том же сообщении, поэтому проверяем префикс.
normalized_cmd=$(echo "$normalized" | awk '{print $1}')
[ "$normalized_cmd" != "add-account" ] && exit 0
LAUNCHER="${AI_LAUNCHER:-claude}"
case "$LAUNCHER" in
claude)
CREDS="$HOME/.claude/.credentials.json"
ACCOUNTS_DIR="$HOME/.claude/accounts"
CURRENT_FILE="$ACCOUNTS_DIR/current"
EMAIL_HELPER="$HOME/.claude/hooks/account-email.sh"
FIFO="$HOME/.claude/add-account.fifo"
LOG="$HOME/.claude/add-account.log"
LOGIN_PID_FILE="$HOME/.claude/add-account.login.pid"
HOLDER_PID_FILE="$HOME/.claude/add-account.holder.pid"
mkdir -p "$ACCOUNTS_DIR"
# Сохраняем текущий активный аккаунт под его реальным email (по токену),
# чтобы логин новым аккаунтом не затёр его креды безвозвратно.
cur_email=""
if [ -f "$CREDS" ]; then
cur_email=$(bash "$EMAIL_HELPER" "$CREDS" 2>/dev/null)
if [ -n "$cur_email" ]; then
cp "$CREDS" "$ACCOUNTS_DIR/${cur_email}.credentials.json"
chmod 600 "$ACCOUNTS_DIR/${cur_email}.credentials.json"
echo "$cur_email" > "$CURRENT_FILE"
fi
fi
# Процессы гасим строго по своим PID: pkill -f "claude auth login" выкосил бы
# любой процесс, в чьей командной строке встретилась эта подстрока.
pid_of() { # $1=pid-файл, $2=ожидаемое имя процесса; печатает живой pid
[ -f "$1" ] || return 1
pid=$(cat "$1" 2>/dev/null)
[ -n "$pid" ] || return 1
[ "$(ps -p "$pid" -o comm= 2>/dev/null)" = "$2" ] || return 1
printf '%s' "$pid"
}
login_alive() { pid_of "$LOGIN_PID_FILE" claude >/dev/null; }
# Останавливаем предыдущую попытку логина, чтобы не копить висящие процессы.
stop_login() {
pid=$(pid_of "$LOGIN_PID_FILE" claude) && kill "$pid" 2>/dev/null
pid=$(pid_of "$HOLDER_PID_FILE" sleep) && kill "$pid" 2>/dev/null
rm -f "$LOGIN_PID_FILE" "$HOLDER_PID_FILE" "$FIFO"
}
code=$(echo "$normalized" | awk '{print $2}')
# ── Шаг 2: код из браузера ───────────────────────────────────────
if [ -n "$code" ]; then
if ! login_alive || [ ! -p "$FIFO" ]; then
echo "Сессия логина не запущена (или уже закрылась). Начни заново: /add-account" >&2
stop_login
exit 2
fi
printf '%s\n' "$code" > "$FIFO"
# Ждём, пока CLI обменяет код на токен и завершится.
for _ in $(seq 1 60); do
login_alive || break
sleep 0.5
done
stop_login
if grep -q "Login failed" "$LOG" 2>/dev/null; then
echo "$(grep -o 'Login failed.*' "$LOG" | tail -1)" >&2
echo "Код одноразовый и живёт недолго — начни заново: /add-account" >&2
exit 2
fi
new_email=$(bash "$EMAIL_HELPER" "$CREDS" 2>/dev/null)
if [ -z "$new_email" ]; then
echo "✗ Логин завершился, но email аккаунта определить не удалось. Аккаунт не сохранён." >&2
exit 2
fi
cp "$CREDS" "$ACCOUNTS_DIR/${new_email}.credentials.json"
chmod 600 "$ACCOUNTS_DIR/${new_email}.credentials.json"
echo "$new_email" > "$CURRENT_FILE"
if [ "$new_email" = "$cur_email" ]; then
echo "⚠ Вошёл тот же аккаунт ($new_email) — новых аккаунтов не добавилось." >&2
else
echo "✓ Аккаунт $new_email добавлен и стал активным. /switch-account — переключение по кругу." >&2
fi
exit 2
fi
# ── Шаг 1: запуск логина и ссылка ────────────────────────────────
stop_login
mkfifo -m 600 "$FIFO" || { echo "Не удалось создать $FIFO" >&2; exit 2; }
: > "$LOG"
# stdin из fifo: CLI ждёт код в stdin, закрытый дескриптор его подвешивает.
# Держатель fifo не даёт CLI получить EOF и задаёт таймаут попытки (15 мин).
claude auth login --claudeai < "$FIFO" > "$LOG" 2>&1 &
echo $! > "$LOGIN_PID_FILE"
disown
sleep 900 > "$FIFO" 2>/dev/null &
echo $! > "$HOLDER_PID_FILE"
disown
url=""
for _ in $(seq 1 40); do
url=$(grep -o 'https://claude\.com/[^ ]*' "$LOG" 2>/dev/null | head -1)
[ -n "$url" ] && break
sleep 0.5
done
if [ -z "$url" ]; then
stop_login
echo "Не дождался ссылки для входа. Лог: $LOG" >&2
exit 2
fi
echo "Авторизуйся в браузере (ссылка ниже), скопируй код и пришли его сюда: /add-account <код>" >&2
echo "" >&2
echo "$url" >&2
exit 2
;;
kimi)
KEYS_DIR="$HOME/.config/ai-setup/kimi_keys"
CURRENT_FILE="$KEYS_DIR/current"
mkdir -p "$KEYS_DIR"
# Убедиться, что текущий ключ сохранён под своим alias.
current=$(cat "$CURRENT_FILE" 2>/dev/null || true)
if [ -n "$current" ] && [ -f "$KEYS_DIR/${current}.key" ]; then
chmod 600 "$KEYS_DIR/${current}.key"
fi
# Ключ передаётся в том же сообщении: /add-account <key>
new_key=$(echo "$prompt" | sed 's|^[[:space:]]*/add-account[[:space:]]*||; s|[[:space:]]*$||')
if [ -z "$new_key" ]; then
echo "" >&2
echo "Укажите Kimi API ключ в том же сообщении: /add-account sk-..." >&2
exit 2
fi
# Проверка ключа.
echo -n "Проверяю Kimi ключ... " >&2
response=$(curl -s -w "\n%{http_code}" --max-time 15 \
"https://api.kimi.com/coding/v1/messages" \
-H "x-api-key: $new_key" \
-H "Content-Type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d '{"model":"kimi-k2.7","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}' \
2>/dev/null || echo "000")
code=$(echo "$response" | tail -1)
case "$code" in
200|400|429)
echo "OK" >&2
;;
401|403)
echo "" >&2
echo "Ошибка: ключ недействителен (HTTP $code)." >&2
exit 2
;;
000)
echo "" >&2
echo "Не удалось проверить ключ (сеть недоступна?). Попробуйте позже." >&2
exit 2
;;
*)
echo "" >&2
echo "Ошибка при проверке ключа (HTTP $code)." >&2
exit 2
;;
esac
# Генерация следующего свободного alias.
i=1
while [ -f "$KEYS_DIR/account${i}.key" ]; do
i=$((i + 1))
done
alias_name="account${i}"
echo "$new_key" > "$KEYS_DIR/${alias_name}.key"
chmod 600 "$KEYS_DIR/${alias_name}.key"
# Пытаемся получить email/имя аккаунта Kimi для статусной строки.
_kimi_account_info() {
local api_key="$1"
local resp email name
for url in "https://api.kimi.com/coding/v1/account" "https://api.kimi.com/coding/v1/users/me"; do
resp=$(curl -s --max-time 10 "$url" \
-H "x-api-key: $api_key" \
-H "Accept: application/json" \
2>/dev/null || echo "")
[ -z "$resp" ] && continue
email=$(echo "$resp" | jq -r '.email // .data.email // .account.email // empty' 2>/dev/null)
name=$(echo "$resp" | jq -r '.name // .data.name // .account.name // empty' 2>/dev/null)
if [ -n "$email" ]; then echo "$email"; return 0; fi
if [ -n "$name" ]; then echo "$name"; return 0; fi
done
return 1
}
meta=$(_kimi_account_info "$new_key" 2>/dev/null || true)
if [ -n "$meta" ]; then
echo "$meta" > "$KEYS_DIR/${alias_name}.meta"
chmod 600 "$KEYS_DIR/${alias_name}.meta"
fi
echo "$alias_name" > "$CURRENT_FILE"
echo "Новый Kimi ключ сохранён как: $alias_name. ai-kimi перезапустится с ним." >&2
exit 0
;;
*)
exit 0
;;
esac