diff --git a/README.md b/README.md index bead606..f5bac63 100644 --- a/README.md +++ b/README.md @@ -173,12 +173,16 @@ claude-limitline retrieves data from two sources: ### OAuth Token Location +The tool automatically retrieves your OAuth token from Claude Code's credential storage: + | Platform | Location | |----------|----------| +| **macOS** | Keychain (`Claude Code-credentials` service) | | **Windows** | Credential Manager or `~/.claude/.credentials.json` | -| **macOS** | Keychain or `~/.claude/.credentials.json` | | **Linux** | secret-tool (GNOME Keyring) or `~/.claude/.credentials.json` | +> **Note:** On macOS, the token is read directly from the system Keychain where Claude Code stores it. No additional configuration is needed—just make sure you're logged into Claude Code (`claude --login`). + ## Development ```bash @@ -191,7 +195,7 @@ npm run dev # Watch mode ## Testing -The project uses [Vitest](https://vitest.dev/) for testing with 164 tests covering config loading, themes, segments, utilities, and rendering. +The project uses [Vitest](https://vitest.dev/) for testing with 170 tests covering config loading, themes, segments, utilities, and rendering. ```bash npm test # Run tests once @@ -207,7 +211,7 @@ npm run test:coverage # Coverage report | `src/themes/index.test.ts` | 37 | Theme retrieval, color validation | | `src/segments/block.test.ts` | 8 | Block segment, time calculations | | `src/segments/weekly.test.ts` | 13 | Weekly segment, week progress | -| `src/utils/oauth.test.ts` | 13 | API responses, caching, trends | +| `src/utils/oauth.test.ts` | 19 | API responses, caching, trends, macOS keychain | | `src/utils/claude-hook.test.ts` | 21 | Model name formatting | | `src/utils/environment.test.ts` | 20 | Git branch, directory detection | | `src/utils/terminal.test.ts` | 13 | Terminal width, ANSI handling | diff --git a/src/utils/oauth.test.ts b/src/utils/oauth.test.ts index 0bb3954..09686c3 100644 --- a/src/utils/oauth.test.ts +++ b/src/utils/oauth.test.ts @@ -19,7 +19,12 @@ vi.mock("node:fs", () => ({ }, })); +vi.mock("node:child_process", () => ({ + exec: vi.fn(), +})); + import fs from "node:fs"; +import { exec } from "node:child_process"; describe("oauth utilities", () => { beforeEach(() => { @@ -238,5 +243,126 @@ describe("oauth utilities", () => { Object.defineProperty(process, "platform", { value: originalPlatform }); }); + + describe("macOS", () => { + const originalPlatform = process.platform; + + beforeEach(() => { + Object.defineProperty(process, "platform", { value: "darwin" }); + }); + + afterEach(() => { + Object.defineProperty(process, "platform", { value: originalPlatform }); + }); + + it("parses JSON credentials from keychain with claudeAiOauth structure", async () => { + const mockCredentials = JSON.stringify({ + claudeAiOauth: { + accessToken: "sk-ant-oat-test-token-12345", + }, + }); + + vi.mocked(exec).mockImplementation(((cmd: string, opts: unknown, callback?: (error: Error | null, result: { stdout: string; stderr: string }) => void) => { + // Handle both (cmd, callback) and (cmd, opts, callback) signatures + const cb = typeof opts === "function" ? opts : callback; + if (cb) { + cb(null, { stdout: mockCredentials, stderr: "" }); + } + return {} as ReturnType; + }) as typeof exec); + + const token = await getOAuthToken(); + + expect(token).toBe("sk-ant-oat-test-token-12345"); + }); + + it("falls back to raw token if keychain returns non-JSON", async () => { + const rawToken = "sk-ant-oat-raw-token-67890"; + + vi.mocked(exec).mockImplementation(((cmd: string, opts: unknown, callback?: (error: Error | null, result: { stdout: string; stderr: string }) => void) => { + const cb = typeof opts === "function" ? opts : callback; + if (cb) { + cb(null, { stdout: rawToken, stderr: "" }); + } + return {} as ReturnType; + }) as typeof exec); + + const token = await getOAuthToken(); + + expect(token).toBe("sk-ant-oat-raw-token-67890"); + }); + + it("returns null when keychain retrieval fails", async () => { + vi.mocked(exec).mockImplementation(((cmd: string, opts: unknown, callback?: (error: Error | null, result: { stdout: string; stderr: string }) => void) => { + const cb = typeof opts === "function" ? opts : callback; + if (cb) { + cb(new Error("keychain error"), { stdout: "", stderr: "" }); + } + return {} as ReturnType; + }) as typeof exec); + + const token = await getOAuthToken(); + + expect(token).toBeNull(); + }); + + it("returns null when JSON is valid but missing accessToken", async () => { + const mockCredentials = JSON.stringify({ + claudeAiOauth: { + refreshToken: "some-refresh-token", + }, + }); + + vi.mocked(exec).mockImplementation(((cmd: string, opts: unknown, callback?: (error: Error | null, result: { stdout: string; stderr: string }) => void) => { + const cb = typeof opts === "function" ? opts : callback; + if (cb) { + cb(null, { stdout: mockCredentials, stderr: "" }); + } + return {} as ReturnType; + }) as typeof exec); + + const token = await getOAuthToken(); + + expect(token).toBeNull(); + }); + + it("returns null when token doesn't start with sk-ant-oat", async () => { + const mockCredentials = JSON.stringify({ + claudeAiOauth: { + accessToken: "invalid-token-format", + }, + }); + + vi.mocked(exec).mockImplementation(((cmd: string, opts: unknown, callback?: (error: Error | null, result: { stdout: string; stderr: string }) => void) => { + const cb = typeof opts === "function" ? opts : callback; + if (cb) { + cb(null, { stdout: mockCredentials, stderr: "" }); + } + return {} as ReturnType; + }) as typeof exec); + + const token = await getOAuthToken(); + + expect(token).toBeNull(); + }); + + it("uses correct keychain service name", async () => { + vi.mocked(exec).mockImplementation(((cmd: string, opts: unknown, callback?: (error: Error | null, result: { stdout: string; stderr: string }) => void) => { + const cb = typeof opts === "function" ? opts : callback; + if (cb) { + cb(new Error("not found"), { stdout: "", stderr: "" }); + } + return {} as ReturnType; + }) as typeof exec); + + await getOAuthToken(); + + expect(exec).toHaveBeenCalledWith( + expect.stringContaining("Claude Code-credentials"), + expect.anything(), + expect.anything() + ); + }); + }); }); }); diff --git a/src/utils/oauth.ts b/src/utils/oauth.ts index f96323e..0d228f1 100644 --- a/src/utils/oauth.ts +++ b/src/utils/oauth.ts @@ -113,12 +113,30 @@ async function getOAuthTokenWindows(): Promise { async function getOAuthTokenMacOS(): Promise { try { const { stdout } = await execAsync( - `security find-generic-password -s "Claude Code" -w`, + `security find-generic-password -s "Claude Code-credentials" -w`, { timeout: 5000 } ); - const token = stdout.trim(); - if (token && token.startsWith("sk-ant-oat")) { - return token; + const content = stdout.trim(); + + // The keychain stores JSON with structure: {"claudeAiOauth":{"accessToken":"..."}} + if (content.startsWith("{")) { + try { + const parsed = JSON.parse(content); + if (parsed.claudeAiOauth && typeof parsed.claudeAiOauth === "object") { + const token = parsed.claudeAiOauth.accessToken; + if (token && typeof token === "string" && token.startsWith("sk-ant-oat")) { + debug("Found OAuth token in macOS Keychain under claudeAiOauth.accessToken"); + return token; + } + } + } catch (parseError) { + debug("Failed to parse keychain JSON:", parseError); + } + } + + // Fallback: check if it's a raw token + if (content.startsWith("sk-ant-oat")) { + return content; } } catch (error) { debug("macOS Keychain retrieval failed:", error);