Two additions:
1. OAuth subscription auth (lib/oauth.sh + larry-auth.sh)
- PKCE-based out-of-band flow against Claude.ai (no localhost server
needed; works behind any firewall).
- Uses the same client_id Claude Code uses, so calls bill against your
Max/Pro subscription quota instead of pay-as-you-go API metering.
- Tokens stored at $LARRY_HOME/.oauth.json (mode 0600), auto-refresh.
- larry.sh now detects oauth file at startup and uses Bearer auth.
- First-run flow now offers OAuth or API key; /login, /logout, /auth
slash commands in the REPL.
- Transparent fallback to API key if OAuth flow fails.
2. MANUAL.md — offline tool cheat sheet
- Documents every lib/*.sh script with copy-paste examples.
- Bryan's backup plan: when Anthropic is unreachable (no internet, on
a plane, etc.), all the underlying tools work standalone from the
shell. Larry just sequences them; they do not need Larry to run.
- Quick-recipe table at the bottom for the common day-to-day asks.
Files added:
- lib/oauth.sh
- larry-auth.sh
- MANUAL.md
Files modified:
- larry.sh — auth-mode detection, /auth /login /logout commands
- install-larry.sh — fetch new files
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
221 lines
7.6 KiB
Bash
Executable File
221 lines
7.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# oauth.sh — OAuth login flow against Claude.ai for Larry-Anywhere.
|
|
#
|
|
# Uses the same OAuth client/flow Anthropic's Claude Code CLI uses, so calls
|
|
# bill against your Claude Max / Pro subscription quota instead of pay-as-you-go
|
|
# API metering. Public client_id; PKCE; out-of-band code paste (no localhost
|
|
# server required, works behind any firewall).
|
|
#
|
|
# Subcommands:
|
|
# login start the auth flow; print URL; prompt for code
|
|
# refresh refresh the access token using the stored refresh token
|
|
# ensure print a valid access token (auto-refreshes if near-expired)
|
|
# status show current auth state + expiry
|
|
# logout delete the stored tokens
|
|
#
|
|
# Storage: $LARRY_HOME/.oauth.json (mode 0600)
|
|
#
|
|
# This is community/unofficial use of Anthropic's OAuth flow. Anthropic could
|
|
# tighten it at any time. If OAuth stops working, Larry transparently falls
|
|
# back to the API-key path stored in $LARRY_HOME/.env.
|
|
set -u
|
|
set -o pipefail
|
|
|
|
LARRY_HOME="${LARRY_HOME:-$HOME/.larry}"
|
|
OAUTH_FILE="$LARRY_HOME/.oauth.json"
|
|
|
|
# Anthropic Claude Code's publicly-visible OAuth client_id. Used by claude-code
|
|
# and several community CLI tools.
|
|
CLIENT_ID="${LARRY_OAUTH_CLIENT_ID:-9d1c250a-e61b-44d9-88ed-5944d1962f5e}"
|
|
AUTHORIZE_URL="${LARRY_OAUTH_AUTHORIZE_URL:-https://claude.ai/oauth/authorize}"
|
|
TOKEN_URL="${LARRY_OAUTH_TOKEN_URL:-https://console.anthropic.com/v1/oauth/token}"
|
|
REDIRECT_URI="${LARRY_OAUTH_REDIRECT_URI:-https://console.anthropic.com/oauth/code/callback}"
|
|
SCOPE="${LARRY_OAUTH_SCOPE:-org:create_api_key user:profile user:inference}"
|
|
|
|
die() { printf 'oauth: %s\n' "$*" >&2; exit 1; }
|
|
|
|
# Dependency check
|
|
command -v curl >/dev/null 2>&1 || die "curl required"
|
|
command -v jq >/dev/null 2>&1 || die "jq required"
|
|
command -v openssl >/dev/null 2>&1 || die "openssl required (for PKCE sha256)"
|
|
|
|
b64url() { base64 | tr '/+' '_-' | tr -d '=' | tr -d '\n'; }
|
|
|
|
urlenc() {
|
|
# Minimal RFC3986-ish URL encoder for the bits we need (spaces, /, :)
|
|
local s="$1"
|
|
s="${s// /%20}"
|
|
s="${s//:/%3A}"
|
|
s="${s//\//%2F}"
|
|
printf '%s' "$s"
|
|
}
|
|
|
|
gen_pkce() {
|
|
local verifier challenge
|
|
verifier=$(LC_ALL=C tr -dc 'a-zA-Z0-9-._~' </dev/urandom | head -c 64)
|
|
challenge=$(printf '%s' "$verifier" | openssl dgst -sha256 -binary | b64url)
|
|
printf '%s|%s' "$verifier" "$challenge"
|
|
}
|
|
|
|
cmd_login() {
|
|
mkdir -p "$LARRY_HOME"
|
|
local pkce verifier challenge state
|
|
pkce=$(gen_pkce)
|
|
verifier="${pkce%%|*}"
|
|
challenge="${pkce##*|}"
|
|
state=$(LC_ALL=C tr -dc 'a-zA-Z0-9' </dev/urandom | head -c 32)
|
|
|
|
local url
|
|
url="${AUTHORIZE_URL}?code=true"
|
|
url="${url}&client_id=${CLIENT_ID}"
|
|
url="${url}&response_type=code"
|
|
url="${url}&redirect_uri=$(urlenc "$REDIRECT_URI")"
|
|
url="${url}&scope=$(urlenc "$SCOPE")"
|
|
url="${url}&code_challenge=${challenge}"
|
|
url="${url}&code_challenge_method=S256"
|
|
url="${url}&state=${state}"
|
|
|
|
cat <<EOF
|
|
|
|
=== Larry-Anywhere — Claude subscription login ===
|
|
|
|
This binds Larry to your Claude.ai Max/Pro subscription quota (same flow
|
|
Claude Code uses). No API key needed.
|
|
|
|
1. Open this URL in any browser on any device:
|
|
|
|
${url}
|
|
|
|
2. Sign in with your Claude account.
|
|
3. Approve the app. You'll land on a page that displays the authorization
|
|
code in the URL (it'll look like https://console.anthropic.com/oauth/code/
|
|
callback?code=<LONG-STRING>&state=...). Copy ONLY the code value (the
|
|
part between code= and the next &).
|
|
|
|
4. Paste it here:
|
|
|
|
EOF
|
|
printf 'authorization code: '
|
|
read -r code
|
|
[ -z "$code" ] && die "no code entered"
|
|
|
|
local resp
|
|
resp=$(curl -sS -X POST "$TOKEN_URL" \
|
|
-H "Content-Type: application/json" \
|
|
-H "anthropic-beta: oauth-2025-04-20" \
|
|
-d "$(jq -n \
|
|
--arg cid "$CLIENT_ID" \
|
|
--arg code "$code" \
|
|
--arg verifier "$verifier" \
|
|
--arg redirect "$REDIRECT_URI" \
|
|
--arg state "$state" \
|
|
'{client_id:$cid, grant_type:"authorization_code", code:$code, code_verifier:$verifier, redirect_uri:$redirect, state:$state}')")
|
|
|
|
if ! printf '%s' "$resp" | jq -e '.access_token' >/dev/null 2>&1; then
|
|
printf '\nauth failed. server response:\n' >&2
|
|
printf '%s\n' "$resp" | jq . >&2 2>/dev/null || printf '%s\n' "$resp" >&2
|
|
cat >&2 <<EOF
|
|
|
|
Hints:
|
|
- Make sure you pasted ONLY the code= value, not the whole URL.
|
|
- The code is single-use; if you used it already, run 'larry-auth.sh login' again.
|
|
- If the OAuth endpoint has changed, you can fall back to the API key
|
|
by deleting any oauth file and creating $LARRY_HOME/.env with
|
|
ANTHROPIC_API_KEY=sk-ant-...
|
|
EOF
|
|
exit 1
|
|
fi
|
|
|
|
local now; now=$(date +%s)
|
|
umask 077
|
|
printf '%s' "$resp" | jq --arg now "$now" '. + {fetched_at: ($now | tonumber)}' > "$OAUTH_FILE"
|
|
chmod 600 "$OAUTH_FILE"
|
|
printf '\n✓ logged in. Tokens saved to %s (mode 0600).\n' "$OAUTH_FILE"
|
|
cmd_status
|
|
}
|
|
|
|
cmd_refresh() {
|
|
[ -f "$OAUTH_FILE" ] || die "no oauth file at $OAUTH_FILE — run 'larry-auth.sh login' first"
|
|
local refresh_token; refresh_token=$(jq -r '.refresh_token // empty' "$OAUTH_FILE")
|
|
[ -n "$refresh_token" ] || die "no refresh_token in $OAUTH_FILE — please run login again"
|
|
|
|
local resp
|
|
resp=$(curl -sS -X POST "$TOKEN_URL" \
|
|
-H "Content-Type: application/json" \
|
|
-H "anthropic-beta: oauth-2025-04-20" \
|
|
-d "$(jq -n --arg cid "$CLIENT_ID" --arg rt "$refresh_token" \
|
|
'{client_id:$cid, grant_type:"refresh_token", refresh_token:$rt}')")
|
|
|
|
if ! printf '%s' "$resp" | jq -e '.access_token' >/dev/null 2>&1; then
|
|
printf 'refresh failed:\n%s\n' "$resp" >&2
|
|
return 1
|
|
fi
|
|
|
|
local now; now=$(date +%s)
|
|
printf '%s' "$resp" \
|
|
| jq --arg now "$now" --slurpfile prev "$OAUTH_FILE" \
|
|
'. + {fetched_at: ($now|tonumber), refresh_token: (.refresh_token // $prev[0].refresh_token)}' \
|
|
> "$OAUTH_FILE.new"
|
|
mv "$OAUTH_FILE.new" "$OAUTH_FILE"
|
|
chmod 600 "$OAUTH_FILE"
|
|
jq -r '.access_token' "$OAUTH_FILE"
|
|
}
|
|
|
|
cmd_ensure() {
|
|
[ -f "$OAUTH_FILE" ] || return 1
|
|
local fetched_at expires_in
|
|
fetched_at=$(jq -r '.fetched_at // 0' "$OAUTH_FILE")
|
|
expires_in=$(jq -r '.expires_in // 3600' "$OAUTH_FILE")
|
|
local now; now=$(date +%s)
|
|
local expires_at=$((fetched_at + expires_in))
|
|
if [ "$now" -ge $((expires_at - 300)) ]; then
|
|
cmd_refresh >/dev/null 2>&1 || return 1
|
|
jq -r '.access_token' "$OAUTH_FILE"
|
|
else
|
|
jq -r '.access_token' "$OAUTH_FILE"
|
|
fi
|
|
}
|
|
|
|
cmd_status() {
|
|
if [ ! -f "$OAUTH_FILE" ]; then
|
|
echo "OAuth: not authenticated (no $OAUTH_FILE)"
|
|
return 1
|
|
fi
|
|
local fetched_at expires_in scope
|
|
fetched_at=$(jq -r '.fetched_at // 0' "$OAUTH_FILE")
|
|
expires_in=$(jq -r '.expires_in // 3600' "$OAUTH_FILE")
|
|
scope=$(jq -r '.scope // "(unknown)"' "$OAUTH_FILE")
|
|
local now; now=$(date +%s)
|
|
local expires_at=$((fetched_at + expires_in))
|
|
local left=$((expires_at - now))
|
|
printf 'OAuth status:\n'
|
|
printf ' file: %s\n' "$OAUTH_FILE"
|
|
printf ' scope: %s\n' "$scope"
|
|
printf ' fetched_at: %s\n' "$(date -r "$fetched_at" 2>/dev/null || date -d "@$fetched_at" 2>/dev/null)"
|
|
printf ' expires_in: %d s\n' "$expires_in"
|
|
if [ "$left" -le 0 ]; then
|
|
printf ' state: EXPIRED (%ds ago) — will auto-refresh on next call\n' "$((-left))"
|
|
else
|
|
printf ' state: valid for %d more seconds (~%d min)\n' "$left" "$((left/60))"
|
|
fi
|
|
}
|
|
|
|
cmd_logout() {
|
|
if [ -f "$OAUTH_FILE" ]; then
|
|
rm -f "$OAUTH_FILE"
|
|
echo "logged out (removed $OAUTH_FILE)"
|
|
else
|
|
echo "no token file to remove"
|
|
fi
|
|
}
|
|
|
|
case "${1:-status}" in
|
|
login) cmd_login ;;
|
|
refresh) cmd_refresh ;;
|
|
ensure) cmd_ensure ;;
|
|
status) cmd_status ;;
|
|
logout) cmd_logout ;;
|
|
-h|--help|help) sed -n '2,25p' "$0" ;;
|
|
*) die "unknown subcommand: ${1:-} (try 'login|refresh|ensure|status|logout')" ;;
|
|
esac
|