Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 |
Expand Down
126 changes: 126 additions & 0 deletions src/utils/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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<typeof exec>;
}) 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<typeof exec>;
}) 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<typeof exec>;
}) 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<typeof exec>;
}) 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<typeof exec>;
}) 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<typeof exec>;
}) as typeof exec);

await getOAuthToken();

expect(exec).toHaveBeenCalledWith(
expect.stringContaining("Claude Code-credentials"),
expect.anything(),
expect.anything()
);
});
});
});
});
26 changes: 22 additions & 4 deletions src/utils/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,30 @@ async function getOAuthTokenWindows(): Promise<string | null> {
async function getOAuthTokenMacOS(): Promise<string | null> {
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);
Expand Down