✨ Add Claude OAuth token discovery chain (closes #102)#103
Conversation
Resolve CLAUDE_CODE_OAUTH_TOKEN via an explicit discovery chain in
ClaudeRunner.run() instead of relying solely on the parent process env.
Stops at the first non-empty source and logs which source won, so a
401 from the spawned `claude` subprocess is replaced with a clear
"no Claude credentials found in any of: ..." error before the
subprocess is invoked.
Sources, in order:
1. CLAUDE_CODE_OAUTH_TOKEN env (no log when used)
2. CLAUDE_OAUTH_TOKEN_FILE env -> file path
3. ~/.tokens/.claude-oauth-token (conventional headless)
4. ${XDG_CONFIG_HOME:-~/.config}/claude/oauth-token
5. ~/.claude/.credentials.json (logs WARNING about staleness)
PermissionError on token files and JSON parse/schema errors on
credentials.json log a WARNING and continue. File contents are
.strip()-ed.
| """Raised when no Claude OAuth token can be resolved from any source.""" | ||
|
|
||
|
|
||
| def _read_token_file(path: Path) -> str | None: |
There was a problem hiding this comment.
move function to Runner method
There was a problem hiding this comment.
Done in 9c638ac — _read_token_file is now ClaudeRunner._read_token_file (instance method).
| return token or None | ||
|
|
||
|
|
||
| def _read_credentials_json(path: Path) -> str | None: |
There was a problem hiding this comment.
move function to Runner method
There was a problem hiding this comment.
Done in 9c638ac — _read_credentials_json is now ClaudeRunner._read_credentials_json (instance method).
| return token or None | ||
|
|
||
|
|
||
| def _xdg_token_path() -> Path: |
There was a problem hiding this comment.
move function to Runner method
There was a problem hiding this comment.
Done in 9c638ac — _xdg_token_path is now ClaudeRunner._xdg_token_path (instance method).
| return base / "claude" / "oauth-token" | ||
|
|
||
|
|
||
| def _resolve_oauth_token() -> tuple[str, str]: |
There was a problem hiding this comment.
move function to Runner method
There was a problem hiding this comment.
Done in 9c638ac — _resolve_oauth_token is now ClaudeRunner._resolve_oauth_token (instance method); call site in run() updated to self._resolve_oauth_token(). All 4 helpers now live on the runner; test patches updated to patch.object / patch("askcc.runners.ClaudeRunner._resolve_oauth_token"). pytest 256 passed, poe check and poe typecheck clean.
Refactor _read_token_file, _read_credentials_json, _xdg_token_path, and _resolve_oauth_token from module-level functions to ClaudeRunner instance methods, per PR review feedback. Tests updated to use a ClaudeRunner fixture and patch.object for method-level isolation.
Summary
Resolves #102 — askcc now resolves
CLAUDE_CODE_OAUTH_TOKENvia an explicit discovery chain inClaudeRunner.run()instead of inheriting whatever the parent process happens to provide. The first non-empty source wins; the chosen source is logged at INFO level so failures are diagnosable from the run log alone.Discovery chain
CLAUDE_CODE_OAUTH_TOKENenv var — current behavior (no log when used; no regression for working installs).CLAUDE_OAUTH_TOKEN_FILEenv var → read the file at that path.~/.tokens/.claude-oauth-token— conventional headless token file.${XDG_CONFIG_HOME:-~/.config}/claude/oauth-token— XDG fallback.~/.claude/.credentials.json— last-resort parse ofclaudeAiOauth.accessToken(logs WARNING about staleness because Claude Code refreshes in RAM and may not write back).If none of the sources produce a non-empty token, askcc raises
OAuthTokenNotFoundError, the CLI logs an error listing every checked location, and exits non-zero before invokingclaude— replacing the previous opaque 401 with a clear actionable error.Edge cases handled
PermissionErroron a token file → WARNING + continue chain.~/.claude/.credentials.json(or missingclaudeAiOauth.accessToken) → WARNING + continue chain..strip()-ed before truthiness check.Files changed
askcc/runners.py—OAuthTokenNotFoundError,_resolve_oauth_token(),_read_token_file(),_read_credentials_json(),_xdg_token_path(); wired intoClaudeRunner.run()immediately after the existingenv.pop("CLAUDECODE", ...).askcc/cli.py— wrapsrunner.run(...)in a try/except forOAuthTokenNotFoundError(preserves existing tempfile-cleanupfinally:).tests/test_askcc.py—TestResolveOAuthToken(14 tests),TestClaudeRunnerOAuthIntegration(4 tests),TestMainOAuthExitPath(1 test). Existing runner test classes get an autouse fixture that setsCLAUDE_CODE_OAUTH_TOKENso they remain isolated from host filesystem.README.md— adds the two new env vars to the table and an Authentication subsection documenting the discovery chain.Out of scope (deferred per the issue)
claude -p "ok"validation probe.ANTHROPIC_API_KEYhandling.Test plan
uv run pytest -v— 256 passeduv run poe check— ruff cleanuv run poe typecheck— pyright cleanSystemExit(1)and error incaplog