fix(claude-code): look up the keychain item by account

Claude Code >= 2.1 writes its credential item under the current $USER and
leaves any pre-2.1 item (account "unknown") in place on the *same* service
name, `Claude Code-credentials`. `security find-generic-password -s <svc> -w`
with no `-a` matches an arbitrary one of them, so CCR read the stale legacy
item: the read exits 0, the JSON parses, and it carries only `mcpOAuth`.
`findOauthTokenSet()` then correctly returns undefined and the candidate is
silently dropped by the `status !== "missing"` filter in service.ts.

Resolve the item through tiered candidates, evaluated lazily so the normal
case stays at one `security` call:

1. the expected service name, derived the way Claude Code derives it
   (`Claude Code${oauthSuffix}-credentials${configSuffix}`, where
   configSuffix is `-${sha256(NFC(configDir)).slice(0, 8)}` once
   CLAUDE_CONFIG_DIR / CLAUDE_SECURESTORAGE_CONFIG_DIR is set), read under
   the current account;
2. `security dump-keychain` enumeration, newest-modified first, for items
   under another account or a config dir this process cannot reconstruct.
   Without `-d` that prints item metadata only: it never decrypts a
   password and never prompts;
3. the previous accountless read, as a last resort.

Prefer an explicit top-level `claudeAiOauth` across every candidate and fall
back to the recursive search only if none has one. `mcpOAuth` holds
per-plugin OAuth records that also carry `accessToken`, and key order puts
it first, so a recursive match could import an MCP plugin token as an
Anthropic provider that then 401s on every request.

Surface diagnostics instead of swallowing them: capture `security` stderr
rather than discarding it via `stdio: ["ignore","pipe","ignore"]`, and
return a `locked` candidate naming the reason when login state exists but
yields no token, so it survives the `status !== "missing"` filter. Treat
errSecItemNotFound (exit 44) as absence rather than an error, so a machine
with no Claude Code login still reports `missing`.

Also fix the file fallback: continue past a credentials file that holds no
token instead of returning it, and check the secure-storage config dir,
which the env vars above can relocate away from ~/.claude.

Refs #1601, #1567
This commit is contained in:
Matteo Antoci
2026-07-29 11:53:30 +02:00
parent 3b99fa239b
commit 519b92d406
2 changed files with 495 additions and 36 deletions
@@ -1,4 +1,5 @@
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import os from "node:os";
import path from "node:path";
import type {
@@ -21,7 +22,23 @@ import {
} from "@ccr/core/agents/local-providers/shared";
const claudeDefaultModels = ["claude-sonnet-5"];
const claudeCodeKeychainService = "Claude Code-credentials";
const claudeCodeKeychainServiceBase = "Claude Code-credentials";
const claudeCodeKeychainAccountPattern = /^[a-zA-Z0-9._-]+$/;
const claudeCodeKeychainServicePattern = /^Claude Code(?:-[a-z]+-oauth)?-credentials(?:-[0-9a-f]{8})?$/;
// `security` exit code for errSecItemNotFound.
const keychainItemNotFoundStatus = 44;
type ClaudeCodeKeychainCandidate = { account?: string; service: string };
export type ClaudeCodeLoginScan = {
/** Reads that failed, with the `security` stderr that explains why. */
errors: string[];
/** Locations that parsed successfully. */
inspected: string[];
oauth?: OAuthTokenSet;
/** Locations that parsed but carry no OAuth token. */
tokenless: string[];
};
const percentLimitMapping = (id: string, label: string, path: string, window: string) => ({
id,
@@ -65,7 +82,8 @@ const claudeCodeAccountMapping: ProviderAccountMappingConfig = {
};
export function claudeCodeCandidate(): LocalAgentProviderCandidate {
const oauth = readClaudeCodeOauth();
const scan = scanClaudeCodeLogin();
const oauth = scan.oauth;
if (oauth?.accessToken) {
return {
detail: "Claude Code login detected. Click Import to add it as a gateway provider.",
@@ -92,9 +110,36 @@ export function claudeCodeCandidate(): LocalAgentProviderCandidate {
status: "locked"
};
}
const detail = claudeCodeScanDiagnostic(scan);
if (detail) {
return {
detail,
id: "claude-code-api",
importable: false,
kind: "claude-code",
models: claudeDefaultModels,
name: "Claude Code API",
protocol: "anthropic_messages",
sourceFile: scan.inspected[0],
status: "locked"
};
}
return missingCandidate("claude-code", "claude-code-api", "Claude Code API", "anthropic_messages", claudeDefaultModels);
}
// Distinguishes "found login state but no token" and "could not read the store"
// from "no login at all", so the Add Provider list can say why an import is
// unavailable instead of silently omitting the candidate.
function claudeCodeScanDiagnostic(scan: ClaudeCodeLoginScan): string | undefined {
if (scan.tokenless.length > 0) {
return `Claude Code login state was found but contains no OAuth token (${scan.tokenless.join("; ")}). Run \`claude /login\`, then retry.`;
}
if (scan.errors.length > 0) {
return `Claude Code login state could not be read: ${scan.errors.join("; ")}`;
}
return undefined;
}
export function importClaudeCodeProvider(candidate: LocalAgentProviderCandidate, providerNames: string[]): LocalAgentProviderImportResult {
const oauth = readClaudeCodeOauth();
const token = oauth?.accessToken;
@@ -139,9 +184,15 @@ function claudeCodeProviderAccountConfig(): ProviderAccountConfig {
}
export function readClaudeCodeOauth(): OAuthTokenSet | undefined {
const keychainOauth = readClaudeCodeKeychainOauth();
return scanClaudeCodeLogin().oauth;
}
export function scanClaudeCodeLogin(): ClaudeCodeLoginScan {
const scan: ClaudeCodeLoginScan = { errors: [], inspected: [], tokenless: [] };
const keychainOauth = scanClaudeCodeKeychain(scan);
if (keychainOauth) {
return keychainOauth;
scan.oauth = keychainOauth;
return scan;
}
for (const sourceFile of claudeCredentialFiles()) {
@@ -149,58 +200,232 @@ export function readClaudeCodeOauth(): OAuthTokenSet | undefined {
if (!record) {
continue;
}
const credential = findOauthTokenSet(record);
return {
accessToken: credential?.accessToken,
refreshToken: credential?.refreshToken,
scan.inspected.push(sourceFile);
const credential = findOauthTokenSet(record.claudeAiOauth) ?? findOauthTokenSet(record);
if (!credential?.accessToken && !credential?.refreshToken) {
scan.tokenless.push(`${sourceFile} (keys: ${Object.keys(record).join(", ")})`);
continue;
}
scan.oauth = {
accessToken: credential.accessToken,
refreshToken: credential.refreshToken,
sourceFile
};
return scan;
}
return undefined;
return scan;
}
function claudeCredentialFiles(): string[] {
return uniqueStrings([
path.join(claudeCodeStorageDir(), ".credentials.json"),
path.join(os.homedir(), ".claude", ".credentials.json"),
path.join(os.homedir(), ".claude", "credentials.json"),
path.join(os.homedir(), ".config", "claude", "credentials.json")
]);
}
// Newer macOS builds of the Claude Code CLI store credentials in the
// Keychain instead of ~/.claude/.credentials.json. Reading it triggers the
// standard macOS keychain access prompt (Allow / Always Allow); the user
// declining or the item not existing both surface as a non-zero exit here.
function readClaudeCodeKeychainOauth(): OAuthTokenSet | undefined {
const keychainRecord = readClaudeCodeKeychainRecord();
if (!keychainRecord) {
return undefined;
}
const credential = findOauthTokenSet(keychainRecord);
if (!credential) {
return undefined;
}
return {
accessToken: credential.accessToken,
refreshToken: credential.refreshToken,
sourceFile: `keychain:${claudeCodeKeychainService}`
};
// Claude Code's plaintext fallback lives in its secure-storage config dir,
// which CLAUDE_SECURESTORAGE_CONFIG_DIR / CLAUDE_CONFIG_DIR can relocate.
function claudeCodeStorageDir(): string {
const secureStorageDir = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
const dir = secureStorageDir !== undefined
? secureStorageDir || path.join(os.homedir(), ".claude")
: process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
return dir.normalize("NFC");
}
function readClaudeCodeKeychainRecord(): Record<string, unknown> | undefined {
// Claude Code >= 2.1 derives the keychain service name from its config dir:
// `Claude Code${oauthSuffix}-credentials${configSuffix}`
// `configSuffix` is empty for a default config dir and
// `-${sha256(NFC(configDir)).slice(0, 8)}` once CLAUDE_CONFIG_DIR or
// CLAUDE_SECURESTORAGE_CONFIG_DIR is set. Verified against 2.1.220.
function claudeCodeExpectedKeychainServices(): string[] {
const secureStorageDir = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
const usesDefaultDir = secureStorageDir !== undefined ? !secureStorageDir : !process.env.CLAUDE_CONFIG_DIR;
const configSuffix = usesDefaultDir
? ""
: `-${createHash("sha256").update(claudeCodeStorageDir()).digest("hex").slice(0, 8)}`;
const oauthSuffix = process.env.CLAUDE_CODE_CUSTOM_OAUTH_URL ? "-custom-oauth" : "";
return uniqueStrings([`Claude Code${oauthSuffix}-credentials${configSuffix}`, claudeCodeKeychainServiceBase]);
}
// Claude Code writes the item under the current `$USER`; a login from an older
// build (or one that could not resolve a username) sits under a different
// account on the *same* service name, so the account has to be explicit.
function claudeCodeKeychainAccount(): string {
let user: string | undefined;
try {
user = process.env.USER || os.userInfo().username;
} catch {
user = undefined;
}
return user && claudeCodeKeychainAccountPattern.test(user) ? user : "claude-code-user";
}
// Newer macOS builds of the Claude Code CLI store credentials in the Keychain
// instead of ~/.claude/.credentials.json. Reading one triggers the standard
// macOS keychain access prompt (Allow / Always Allow); the user declining or
// the item not existing both surface as a non-zero exit here.
function scanClaudeCodeKeychain(scan: ClaudeCodeLoginScan): OAuthTokenSet | undefined {
if (process.platform !== "darwin") {
return undefined;
}
const expectedServices = claudeCodeExpectedKeychainServices();
const account = claudeCodeKeychainAccount();
// Ordered by cost: the enumeration tier shells out to `security dump-keychain`,
// so it is only built once the expected item fails to produce a token.
const tiers: Array<() => ClaudeCodeKeychainCandidate[]> = [
() => expectedServices.map(service => ({ account, service })),
// An item written under a different account, or under a config dir this
// process cannot reconstruct, only turns up by enumeration.
discoverClaudeCodeKeychainItems,
// Pre-2.1 lookup: no `-a`, so the Keychain picks an arbitrary account when
// several items share the service name.
() => expectedServices.map(service => ({ service }))
];
const attempted = new Set<string>();
const servicesRead = new Set<string>();
let nestedMatch: OAuthTokenSet | undefined;
for (const buildTier of tiers) {
for (const candidate of buildTier()) {
const key = `${candidate.service}\u0000${candidate.account ?? ""}`;
// An accountless read of a service already read by account returns one of
// those same items, so it would only duplicate the diagnostics.
if (attempted.has(key) || (candidate.account === undefined && servicesRead.has(candidate.service))) {
continue;
}
attempted.add(key);
const label = candidate.account === undefined ? candidate.service : `${candidate.service} (${candidate.account})`;
const record = readClaudeCodeKeychainRecord(candidate, scan, label);
if (!record) {
continue;
}
servicesRead.add(candidate.service);
scan.inspected.push(`keychain:${label}`);
// Only `claudeAiOauth` is an Anthropic API credential. A recursive search
// over the whole record also matches the per-plugin tokens under
// `mcpOAuth`, which import cleanly and then 401 on every request.
const claudeAiOauth = findOauthTokenSet(record.claudeAiOauth);
if (claudeAiOauth?.accessToken || claudeAiOauth?.refreshToken) {
return {
accessToken: claudeAiOauth.accessToken,
refreshToken: claudeAiOauth.refreshToken,
sourceFile: `keychain:${label}`
};
}
const nested = findOauthTokenSet(record);
if (nested?.accessToken || nested?.refreshToken) {
nestedMatch ??= {
accessToken: nested.accessToken,
refreshToken: nested.refreshToken,
sourceFile: `keychain:${label}`
};
continue;
}
scan.tokenless.push(`keychain:${label} (keys: ${Object.keys(record).join(", ")})`);
}
}
// Every tier is exhausted before falling back to a nested match, so an
// explicit `claudeAiOauth` on any item always wins.
return nestedMatch;
}
// `security dump-keychain` without `-d` prints item *metadata* only: it never
// decrypts a password and never prompts. Finds login items this environment
// cannot name, newest-modified first.
function discoverClaudeCodeKeychainItems(): ClaudeCodeKeychainCandidate[] {
let dump: string;
try {
const output = execFileSync(
"security",
["find-generic-password", "-s", claudeCodeKeychainService, "-w"],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
);
const parsed = JSON.parse(output.trim()) as unknown;
return isRecord(parsed) ? parsed : undefined;
dump = execFileSync("security", ["dump-keychain"], {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
stdio: ["ignore", "pipe", "ignore"]
});
} catch {
return [];
}
const found: Array<ClaudeCodeKeychainCandidate & { modified: string }> = [];
let account: string | undefined;
let modified = "";
let service: string | undefined;
const flush = () => {
if (service && claudeCodeKeychainServicePattern.test(service)) {
found.push({ account, modified, service });
}
account = undefined;
modified = "";
service = undefined;
};
for (const line of dump.split("\n")) {
if (line.startsWith("keychain:")) {
flush();
continue;
}
const serviceMatch = /^\s*"svce"<blob>="(.*)"$/.exec(line);
if (serviceMatch) {
service = serviceMatch[1];
continue;
}
const accountMatch = /^\s*"acct"<blob>="(.*)"$/.exec(line);
if (accountMatch) {
account = accountMatch[1];
continue;
}
const modifiedMatch = /^\s*"mdat"<timedate>=.*"(\d{14})Z/.exec(line);
if (modifiedMatch) {
modified = modifiedMatch[1];
}
}
flush();
return found
.sort((left, right) => right.modified.localeCompare(left.modified))
.map(({ account: itemAccount, service: itemService }) => ({ account: itemAccount, service: itemService }));
}
function readClaudeCodeKeychainRecord(
candidate: ClaudeCodeKeychainCandidate,
scan: ClaudeCodeLoginScan,
label: string
): Record<string, unknown> | undefined {
const args = ["find-generic-password", "-s", candidate.service, "-w"];
if (candidate.account !== undefined) {
args.splice(1, 0, "-a", candidate.account);
}
try {
const output = execFileSync("security", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
const parsed = JSON.parse(output.trim()) as unknown;
if (isRecord(parsed)) {
return parsed;
}
scan.errors.push(`keychain:${label}: item is not a JSON object`);
return undefined;
} catch (error) {
const message = keychainErrorMessage(error);
// errSecItemNotFound is the ordinary "no such login" answer, not a fault:
// recording it would turn a logged-out machine into a `locked` candidate.
if (message !== undefined) {
scan.errors.push(`keychain:${label}: ${message}`);
}
return undefined;
}
}
// Returns undefined when the item simply does not exist.
function keychainErrorMessage(error: unknown): string | undefined {
if (error instanceof SyntaxError) {
return `item is not valid JSON (${error.message})`;
}
const failure = error as { status?: number; stderr?: Buffer | string } | undefined;
if (failure?.status === keychainItemNotFoundStatus) {
return undefined;
}
const stderr = typeof failure?.stderr === "string" ? failure.stderr : failure?.stderr?.toString("utf8");
const message = stderr?.trim().replace(/\s*\n\s*/g, "; ");
if (message) {
return /could not be found/i.test(message) ? undefined : message;
}
return failure?.status === undefined ? String(error) : `security exited ${failure.status}`;
}
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -24,7 +25,7 @@ test("Claude Code local provider prefers macOS Keychain credentials over stale f
const candidate = claudeCodeCandidate();
assert.equal(candidate.status, "available");
assert.equal(candidate.importable, true);
assert.equal(candidate.sourceFile, "keychain:Claude Code-credentials");
assert.equal(candidate.sourceFile, `keychain:Claude Code-credentials (${keychainAccount})`);
const result = importClaudeCodeProvider(candidate, []);
assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer keychain-access-token");
@@ -100,6 +101,151 @@ test("Core gateway config replaces imported Claude Code OAuth token with live ma
});
});
// Claude Code >= 2.1 writes the credential item under the current $USER and
// leaves any pre-2.1 item (account "unknown") in place on the same service
// name. A lookup without `-a` matches the stale one, which only carries MCP
// plugin OAuth. See musistudio/claude-code-router#1601.
test("Claude Code local provider reads the credential item stored under the current account", { skip: process.platform === "win32" }, async () => {
await withClaudeCodeHome(async () => {
await withPlatform("darwin", async () => {
await withFakeSecurityKeychain([
{
account: "unknown",
modified: "20260604091803",
record: { mcpOAuth: { "plugin:github|1": { expiresAt: 1 } } },
service: "Claude Code-credentials"
},
{
account: keychainAccount,
modified: "20260729042837",
record: { claudeAiOauth: { accessToken: "live-access-token", refreshToken: "live-refresh-token" } },
service: "Claude Code-credentials"
}
], async () => {
const candidate = claudeCodeCandidate();
assert.equal(candidate.status, "available");
assert.equal(candidate.importable, true);
assert.equal(candidate.sourceFile, `keychain:Claude Code-credentials (${keychainAccount})`);
const result = importClaudeCodeProvider(candidate, []);
assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer live-access-token");
});
});
});
});
// The only path to an item written under a different account (a login made as
// another user, or a $USER that has since changed) is `security dump-keychain`
// enumeration; the expected-name lookup cannot reach it.
test("Claude Code local provider enumerates the keychain to find an item under another account", { skip: process.platform === "win32" }, async () => {
await withClaudeCodeHome(async () => {
await withPlatform("darwin", async () => {
await withFakeSecurityKeychain([
{
account: "other-user",
modified: "20260729042837",
record: { claudeAiOauth: { accessToken: "enumerated-access-token" } },
service: "Claude Code-credentials",
accountlessLookup: false
}
], async () => {
const candidate = claudeCodeCandidate();
assert.equal(candidate.status, "available");
assert.equal(candidate.importable, true);
assert.equal(candidate.sourceFile, "keychain:Claude Code-credentials (other-user)");
const result = importClaudeCodeProvider(candidate, []);
assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer enumerated-access-token");
});
});
});
});
// mcpOAuth holds per-plugin tokens that are not Anthropic API credentials;
// importing one yields a provider that 401s on every request.
test("Claude Code local provider ignores mcpOAuth plugin tokens in favour of claudeAiOauth", { skip: process.platform === "win32" }, async () => {
await withClaudeCodeHome(async () => {
await withPlatform("darwin", async () => {
await withFakeSecurityKeychain([
{
account: keychainAccount,
modified: "20260729042837",
record: {
mcpOAuth: { "plugin:github|1": { accessToken: "mcp-plugin-token", refreshToken: "mcp-plugin-refresh" } },
claudeAiOauth: { accessToken: "live-access-token", refreshToken: "live-refresh-token" }
},
service: "Claude Code-credentials"
}
], async () => {
const result = importClaudeCodeProvider(claudeCodeCandidate(), []);
assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer live-access-token");
});
});
});
});
// With CLAUDE_CONFIG_DIR set, Claude Code appends the first 8 hex of
// sha256(NFC(configDir)) to the service name.
test("Claude Code local provider reads the config-dir-suffixed keychain service", { skip: process.platform === "win32" }, async () => {
await withClaudeCodeHome(async (home) => {
await withPlatform("darwin", async () => {
const configDir = path.join(home, "alt-claude");
const suffix = createHash("sha256").update(configDir.normalize("NFC")).digest("hex").slice(0, 8);
const service = `Claude Code-credentials-${suffix}`;
await withEnv("CLAUDE_CONFIG_DIR", configDir, async () => {
await withFakeSecurityKeychain([
{
account: keychainAccount,
modified: "20260729042837",
record: { claudeAiOauth: { accessToken: "suffixed-access-token" } },
service
}
], async () => {
const candidate = claudeCodeCandidate();
assert.equal(candidate.status, "available");
assert.equal(candidate.sourceFile, `keychain:${service} (${keychainAccount})`);
});
});
});
});
});
test("Claude Code local provider explains login state that carries no OAuth token", { skip: process.platform === "win32" }, async () => {
await withClaudeCodeHome(async () => {
await withPlatform("darwin", async () => {
await withFakeSecurityKeychain([
{
account: keychainAccount,
modified: "20260729042837",
record: { mcpOAuth: { "plugin:github|1": { expiresAt: 1 } } },
service: "Claude Code-credentials"
}
], async () => {
const candidate = claudeCodeCandidate();
assert.equal(candidate.status, "locked");
assert.equal(candidate.importable, false);
assert.match(candidate.detail, /no OAuth token/);
assert.match(candidate.detail, /mcpOAuth/);
});
});
});
});
// errSecItemNotFound is the ordinary logged-out answer: it must not be reported
// as an unreadable store.
test("Claude Code local provider reports a missing candidate when no keychain item exists", { skip: process.platform === "win32" }, async () => {
await withClaudeCodeHome(async () => {
await withPlatform("darwin", async () => {
await withFakeSecurityKeychain([], async () => {
const candidate = claudeCodeCandidate();
assert.equal(candidate.status, "missing");
assert.equal(candidate.importable, false);
assert.equal(candidate.detail, "No local login state was found for this agent.");
});
});
});
});
async function withClaudeCodeHome(run) {
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-claude-code-provider-"));
const previousHome = process.env.HOME;
@@ -133,21 +279,109 @@ async function withFakeSecurityFailure(run) {
await withFakeSecurityScript("exit 44\n", run);
}
// Pins $USER as well as PATH: the provider looks the keychain item up under the
// current account, so the expected item name has to be deterministic.
async function withFakeSecurityScript(body, run) {
const binDir = mkdtempSync(path.join(os.tmpdir(), "ccr-security-bin-"));
const securityPath = path.join(binDir, "security");
const previousPath = process.env.PATH;
const previousUser = process.env.USER;
writeFileSync(securityPath, `#!/bin/sh\n${body}`);
chmodSync(securityPath, 0o755);
process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ""}`;
process.env.USER = keychainAccount;
try {
await run();
} finally {
restoreEnv("PATH", previousPath);
restoreEnv("USER", previousUser);
rmSync(binDir, { force: true, recursive: true });
}
}
const keychainAccount = "ccr-test-user";
// Stands in for the macOS keychain: `find-generic-password` matches on
// service+account, an omitted `-a` matches the first item registered for the
// service (as the real Keychain picks an arbitrary one), and `dump-keychain`
// prints the metadata-only listing the provider parses.
async function withFakeSecurityKeychain(items, run) {
await withFakeSecurityScript(fakeSecurityBody(items), run);
}
function fakeSecurityBody(items) {
const dump = [
...items.map(item => [
`keychain: "/tmp/login.keychain-db"`,
"version: 512",
"class: \"genp\"",
"attributes:",
` "acct"<blob>="${item.account}"`,
` "mdat"<timedate>=0x00 "${item.modified}Z\\000"`,
` "svce"<blob>="${item.service}"`
].join("\n")),
[
`keychain: "/tmp/login.keychain-db"`,
"attributes:",
` "acct"<blob>="unrelated"`,
` "svce"<blob>="Bitwarden Safe Storage"`
].join("\n")
].join("\n");
// `accountlessLookup: false` models an item the Keychain will not return for
// a `-s`-only query, so only enumeration can reach it.
const firstForService = new Map();
for (const item of items) {
if (item.accountlessLookup !== false && !firstForService.has(item.service)) {
firstForService.set(item.service, item);
}
}
const branches = [
...items.map((item, index) => ({ key: `${item.service}|${item.account}`, record: item.record, tag: `CCR_J${index}` })),
...[...firstForService.values()].map((item, index) => ({ key: `${item.service}|`, record: item.record, tag: `CCR_A${index}` }))
];
return [
`if [ "$1" = "dump-keychain" ]; then`,
`cat <<'CCR_DUMP'`,
dump,
"CCR_DUMP",
"exit 0",
"fi",
"shift",
"acct=''",
"svc=''",
"while [ $# -gt 0 ]; do",
` case "$1" in`,
` -a) acct="$2"; shift 2 ;;`,
` -s) svc="$2"; shift 2 ;;`,
" *) shift ;;",
" esac",
"done",
`case "$svc|$acct" in`,
...branches.map(branch => [
`"${branch.key}")`,
`cat <<'${branch.tag}'`,
JSON.stringify(branch.record),
branch.tag,
"exit 0 ;;"
].join("\n")),
"esac",
`echo 'security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.' >&2`,
"exit 44"
].join("\n");
}
async function withEnv(name, value, run) {
const previous = process.env[name];
process.env[name] = value;
try {
await run();
} finally {
restoreEnv(name, previous);
}
}
function writeClaudeCredentials(home, credentials) {
const directory = path.join(home, ".claude");
const credentialFile = path.join(directory, ".credentials.json");