feat(auth): sync OAuth from Claude/Codex CLIs
Add source profiles anthropic:claude-cli and openai-codex:codex-cli; surface them in onboarding/configure. Co-authored-by: pepicrft <pepicrft@users.noreply.github.com>
This commit is contained in:
@@ -6,6 +6,8 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
type AuthProfileStore,
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
CODEX_CLI_PROFILE_ID,
|
||||
calculateAuthProfileCooldownMs,
|
||||
ensureAuthProfileStore,
|
||||
resolveAuthProfileOrder,
|
||||
@@ -339,3 +341,263 @@ describe("auth profile cooldowns", () => {
|
||||
expect(calculateAuthProfileCooldownMs(5)).toBe(60 * 60_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("external CLI credential sync", () => {
|
||||
it("syncs Claude CLI credentials into anthropic:claude-cli", () => {
|
||||
const agentDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "clawdbot-cli-sync-"),
|
||||
);
|
||||
const originalHome = process.env.HOME;
|
||||
|
||||
try {
|
||||
// Create a temp home with Claude CLI credentials
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
|
||||
// Create Claude CLI credentials
|
||||
const claudeDir = path.join(tempHome, ".claude");
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
const claudeCreds = {
|
||||
claudeAiOauth: {
|
||||
accessToken: "fresh-access-token",
|
||||
refreshToken: "fresh-refresh-token",
|
||||
expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour from now
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(claudeDir, ".credentials.json"),
|
||||
JSON.stringify(claudeCreds),
|
||||
);
|
||||
|
||||
// Create empty auth-profiles.json
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
fs.writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:default": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-default",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Load the store - should sync from CLI
|
||||
const store = ensureAuthProfileStore(agentDir);
|
||||
|
||||
expect(store.profiles["anthropic:default"]).toBeDefined();
|
||||
expect((store.profiles["anthropic:default"] as { key: string }).key).toBe(
|
||||
"sk-default",
|
||||
);
|
||||
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
|
||||
expect(
|
||||
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { access: string }).access,
|
||||
).toBe("fresh-access-token");
|
||||
expect(
|
||||
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { expires: number }).expires,
|
||||
).toBeGreaterThan(Date.now());
|
||||
} finally {
|
||||
process.env.HOME = originalHome;
|
||||
fs.rmSync(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("syncs Codex CLI credentials into openai-codex:codex-cli", () => {
|
||||
const agentDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "clawdbot-codex-sync-"),
|
||||
);
|
||||
const originalHome = process.env.HOME;
|
||||
|
||||
try {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
|
||||
// Create Codex CLI credentials
|
||||
const codexDir = path.join(tempHome, ".codex");
|
||||
fs.mkdirSync(codexDir, { recursive: true });
|
||||
const codexCreds = {
|
||||
tokens: {
|
||||
access_token: "codex-access-token",
|
||||
refresh_token: "codex-refresh-token",
|
||||
},
|
||||
};
|
||||
const codexAuthPath = path.join(codexDir, "auth.json");
|
||||
fs.writeFileSync(codexAuthPath, JSON.stringify(codexCreds));
|
||||
|
||||
// Create empty auth-profiles.json
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
fs.writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
profiles: {},
|
||||
}),
|
||||
);
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir);
|
||||
|
||||
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeDefined();
|
||||
expect(
|
||||
(store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access,
|
||||
).toBe("codex-access-token");
|
||||
} finally {
|
||||
process.env.HOME = originalHome;
|
||||
fs.rmSync(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not overwrite API keys when syncing external CLI creds", () => {
|
||||
const agentDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "clawdbot-no-overwrite-"),
|
||||
);
|
||||
const originalHome = process.env.HOME;
|
||||
|
||||
try {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
|
||||
// Create Claude CLI credentials
|
||||
const claudeDir = path.join(tempHome, ".claude");
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
const claudeCreds = {
|
||||
claudeAiOauth: {
|
||||
accessToken: "cli-access",
|
||||
refreshToken: "cli-refresh",
|
||||
expiresAt: Date.now() + 30 * 60 * 1000,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(claudeDir, ".credentials.json"),
|
||||
JSON.stringify(claudeCreds),
|
||||
);
|
||||
|
||||
// Create auth-profiles.json with an API key
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
fs.writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:default": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-store",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir);
|
||||
|
||||
// Should keep the store's API key and still add the CLI profile.
|
||||
expect((store.profiles["anthropic:default"] as { key: string }).key).toBe(
|
||||
"sk-store",
|
||||
);
|
||||
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
|
||||
} finally {
|
||||
process.env.HOME = originalHome;
|
||||
fs.rmSync(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not overwrite fresher store OAuth with older Claude CLI credentials", () => {
|
||||
const agentDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-"),
|
||||
);
|
||||
const originalHome = process.env.HOME;
|
||||
|
||||
try {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
|
||||
const claudeDir = path.join(tempHome, ".claude");
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(claudeDir, ".credentials.json"),
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: "cli-access",
|
||||
refreshToken: "cli-refresh",
|
||||
expiresAt: Date.now() + 30 * 60 * 1000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
fs.writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
profiles: {
|
||||
[CLAUDE_CLI_PROFILE_ID]: {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "store-access",
|
||||
refresh: "store-refresh",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir);
|
||||
expect(
|
||||
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { access: string }).access,
|
||||
).toBe("store-access");
|
||||
} finally {
|
||||
process.env.HOME = originalHome;
|
||||
fs.rmSync(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("updates codex-cli profile when Codex CLI refresh token changes", () => {
|
||||
const agentDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "clawdbot-codex-refresh-sync-"),
|
||||
);
|
||||
const originalHome = process.env.HOME;
|
||||
|
||||
try {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
|
||||
const codexDir = path.join(tempHome, ".codex");
|
||||
fs.mkdirSync(codexDir, { recursive: true });
|
||||
const codexAuthPath = path.join(codexDir, "auth.json");
|
||||
fs.writeFileSync(
|
||||
codexAuthPath,
|
||||
JSON.stringify({
|
||||
tokens: { access_token: "same-access", refresh_token: "new-refresh" },
|
||||
}),
|
||||
);
|
||||
fs.utimesSync(codexAuthPath, new Date(), new Date());
|
||||
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
fs.writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
profiles: {
|
||||
[CODEX_CLI_PROFILE_ID]: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "same-access",
|
||||
refresh: "old-refresh",
|
||||
expires: Date.now() - 1000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir);
|
||||
expect(
|
||||
(store.profiles[CODEX_CLI_PROFILE_ID] as { refresh: string }).refresh,
|
||||
).toBe("new-refresh");
|
||||
} finally {
|
||||
process.env.HOME = originalHome;
|
||||
fs.rmSync(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,14 @@ import { normalizeProviderId } from "./model-selection.js";
|
||||
const AUTH_STORE_VERSION = 1;
|
||||
const AUTH_PROFILE_FILENAME = "auth-profiles.json";
|
||||
const LEGACY_AUTH_FILENAME = "auth.json";
|
||||
|
||||
// External CLI credential file locations
|
||||
const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json";
|
||||
const CODEX_CLI_AUTH_RELATIVE_PATH = ".codex/auth.json";
|
||||
|
||||
export const CLAUDE_CLI_PROFILE_ID = "anthropic:claude-cli";
|
||||
export const CODEX_CLI_PROFILE_ID = "openai-codex:codex-cli";
|
||||
|
||||
const AUTH_STORE_LOCK_OPTIONS = {
|
||||
retries: {
|
||||
retries: 10,
|
||||
@@ -267,11 +275,177 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
||||
return mutated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Anthropic OAuth credentials from Claude CLI's credential file.
|
||||
* Claude CLI stores credentials at ~/.claude/.credentials.json
|
||||
*/
|
||||
function readClaudeCliCredentials(): OAuthCredential | null {
|
||||
const credPath = path.join(
|
||||
resolveUserPath("~"),
|
||||
CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH,
|
||||
);
|
||||
const raw = loadJsonFile(credPath);
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
|
||||
const data = raw as Record<string, unknown>;
|
||||
const claudeOauth = data.claudeAiOauth as Record<string, unknown> | undefined;
|
||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||
|
||||
const accessToken = claudeOauth.accessToken;
|
||||
const refreshToken = claudeOauth.refreshToken;
|
||||
const expiresAt = claudeOauth.expiresAt;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof refreshToken !== "string" || !refreshToken) return null;
|
||||
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read OpenAI Codex OAuth credentials from Codex CLI's auth file.
|
||||
* Codex CLI stores credentials at ~/.codex/auth.json
|
||||
*/
|
||||
function readCodexCliCredentials(): OAuthCredential | null {
|
||||
const authPath = path.join(
|
||||
resolveUserPath("~"),
|
||||
CODEX_CLI_AUTH_RELATIVE_PATH,
|
||||
);
|
||||
const raw = loadJsonFile(authPath);
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
|
||||
const data = raw as Record<string, unknown>;
|
||||
const tokens = data.tokens as Record<string, unknown> | undefined;
|
||||
if (!tokens || typeof tokens !== "object") return null;
|
||||
|
||||
const accessToken = tokens.access_token;
|
||||
const refreshToken = tokens.refresh_token;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof refreshToken !== "string" || !refreshToken) return null;
|
||||
|
||||
// Codex CLI doesn't store expiry, estimate 1 hour from file mtime or now
|
||||
let expires: number;
|
||||
try {
|
||||
const stat = fs.statSync(authPath);
|
||||
// Assume token is valid for ~1 hour from when the file was last modified
|
||||
expires = stat.mtimeMs + 60 * 60 * 1000;
|
||||
} catch {
|
||||
expires = Date.now() + 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
provider: "openai-codex" as unknown as OAuthProvider,
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires,
|
||||
};
|
||||
}
|
||||
|
||||
function shallowEqualOAuthCredentials(
|
||||
a: OAuthCredential | undefined,
|
||||
b: OAuthCredential,
|
||||
): boolean {
|
||||
if (!a) return false;
|
||||
if (a.type !== "oauth") return false;
|
||||
return (
|
||||
a.provider === b.provider &&
|
||||
a.access === b.access &&
|
||||
a.refresh === b.refresh &&
|
||||
a.expires === b.expires &&
|
||||
a.email === b.email &&
|
||||
a.enterpriseUrl === b.enterpriseUrl &&
|
||||
a.projectId === b.projectId &&
|
||||
a.accountId === b.accountId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync OAuth credentials from external CLI tools (Claude CLI, Codex CLI) into the store.
|
||||
* This allows clawdbot to use the same credentials as these tools without requiring
|
||||
* separate authentication, and keeps credentials in sync when CLI tools refresh tokens.
|
||||
*
|
||||
* Returns true if any credentials were updated.
|
||||
*/
|
||||
function syncExternalCliCredentials(store: AuthProfileStore): boolean {
|
||||
let mutated = false;
|
||||
const now = Date.now();
|
||||
|
||||
// Sync from Claude CLI
|
||||
const claudeCreds = readClaudeCliCredentials();
|
||||
if (claudeCreds) {
|
||||
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
|
||||
|
||||
// Update if: no existing profile, existing is not oauth, or CLI has newer/valid token
|
||||
const shouldUpdate =
|
||||
!existingOAuth ||
|
||||
existingOAuth.provider !== "anthropic" ||
|
||||
existingOAuth.expires <= now ||
|
||||
(claudeCreds.expires > now &&
|
||||
claudeCreds.expires > existingOAuth.expires);
|
||||
|
||||
if (
|
||||
shouldUpdate &&
|
||||
!shallowEqualOAuthCredentials(existingOAuth, claudeCreds)
|
||||
) {
|
||||
store.profiles[CLAUDE_CLI_PROFILE_ID] = claudeCreds;
|
||||
mutated = true;
|
||||
log.info("synced anthropic credentials from claude cli", {
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
expires: new Date(claudeCreds.expires).toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sync from Codex CLI
|
||||
const codexCreds = readCodexCliCredentials();
|
||||
if (codexCreds) {
|
||||
const existing = store.profiles[CODEX_CLI_PROFILE_ID];
|
||||
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
|
||||
|
||||
// Codex creds don't carry expiry; use file mtime heuristic for freshness.
|
||||
const shouldUpdate =
|
||||
!existingOAuth ||
|
||||
existingOAuth.provider !== ("openai-codex" as unknown as OAuthProvider) ||
|
||||
existingOAuth.expires <= now ||
|
||||
codexCreds.expires > existingOAuth.expires;
|
||||
|
||||
if (
|
||||
shouldUpdate &&
|
||||
!shallowEqualOAuthCredentials(existingOAuth, codexCreds)
|
||||
) {
|
||||
store.profiles[CODEX_CLI_PROFILE_ID] = codexCreds;
|
||||
mutated = true;
|
||||
log.info("synced openai-codex credentials from codex cli", {
|
||||
profileId: CODEX_CLI_PROFILE_ID,
|
||||
expires: new Date(codexCreds.expires).toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return mutated;
|
||||
}
|
||||
|
||||
export function loadAuthProfileStore(): AuthProfileStore {
|
||||
const authPath = resolveAuthStorePath();
|
||||
const raw = loadJsonFile(authPath);
|
||||
const asStore = coerceAuthStore(raw);
|
||||
if (asStore) return asStore;
|
||||
if (asStore) {
|
||||
// Sync from external CLI tools on every load
|
||||
const synced = syncExternalCliCredentials(asStore);
|
||||
if (synced) {
|
||||
saveJsonFile(authPath, asStore);
|
||||
}
|
||||
return asStore;
|
||||
}
|
||||
|
||||
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath());
|
||||
const legacy = coerceLegacyStore(legacyRaw);
|
||||
@@ -303,17 +477,27 @@ export function loadAuthProfileStore(): AuthProfileStore {
|
||||
};
|
||||
}
|
||||
}
|
||||
syncExternalCliCredentials(store);
|
||||
return store;
|
||||
}
|
||||
|
||||
return { version: AUTH_STORE_VERSION, profiles: {} };
|
||||
const store: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: {} };
|
||||
syncExternalCliCredentials(store);
|
||||
return store;
|
||||
}
|
||||
|
||||
export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore {
|
||||
const authPath = resolveAuthStorePath(agentDir);
|
||||
const raw = loadJsonFile(authPath);
|
||||
const asStore = coerceAuthStore(raw);
|
||||
if (asStore) return asStore;
|
||||
if (asStore) {
|
||||
// Sync from external CLI tools on every load
|
||||
const synced = syncExternalCliCredentials(asStore);
|
||||
if (synced) {
|
||||
saveJsonFile(authPath, asStore);
|
||||
}
|
||||
return asStore;
|
||||
}
|
||||
|
||||
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir));
|
||||
const legacy = coerceLegacyStore(legacyRaw);
|
||||
@@ -348,7 +532,8 @@ export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore {
|
||||
}
|
||||
|
||||
const mergedOAuth = mergeOAuthFileIntoStore(store);
|
||||
const shouldWrite = legacy !== null || mergedOAuth;
|
||||
const syncedCli = syncExternalCliCredentials(store);
|
||||
const shouldWrite = legacy !== null || mergedOAuth || syncedCli;
|
||||
if (shouldWrite) {
|
||||
saveJsonFile(authPath, store);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
resolveAgentDir,
|
||||
resolveAgentWorkspaceDir,
|
||||
} from "../agents/agent-scope.js";
|
||||
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
CONFIG_PATH_CLAWDBOT,
|
||||
@@ -21,6 +22,7 @@ import { resolveDefaultWhatsAppAccountId } from "../web/accounts.js";
|
||||
import { createClackPrompter } from "../wizard/clack-prompter.js";
|
||||
import { WizardCancelledError } from "../wizard/prompts.js";
|
||||
import { applyAuthChoice, warnIfModelConfigLooksOff } from "./auth-choice.js";
|
||||
import { buildAuthChoiceOptions } from "./auth-choice-options.js";
|
||||
import { ensureWorkspaceAndSessions, moveToTrash } from "./onboard-helpers.js";
|
||||
import { setupProviders } from "./onboard-providers.js";
|
||||
import type { AuthChoice, ProviderChoice } from "./onboard-types.js";
|
||||
@@ -458,19 +460,13 @@ export async function agentsAddCommand(
|
||||
initialValue: false,
|
||||
});
|
||||
if (wantsAuth) {
|
||||
const authStore = ensureAuthProfileStore(agentDir);
|
||||
const authChoice = (await prompter.select({
|
||||
message: "Model/auth choice",
|
||||
options: [
|
||||
{ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" },
|
||||
{ value: "openai-codex", label: "OpenAI Codex (ChatGPT OAuth)" },
|
||||
{
|
||||
value: "antigravity",
|
||||
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
|
||||
},
|
||||
{ value: "apiKey", label: "Anthropic API key" },
|
||||
{ value: "minimax", label: "Minimax M2.1 (LM Studio)" },
|
||||
{ value: "skip", label: "Skip for now" },
|
||||
],
|
||||
options: buildAuthChoiceOptions({
|
||||
store: authStore,
|
||||
includeSkip: true,
|
||||
}),
|
||||
})) as AuthChoice;
|
||||
|
||||
const authResult = await applyAuthChoice({
|
||||
|
||||
49
src/commands/auth-choice-options.ts
Normal file
49
src/commands/auth-choice-options.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { AuthProfileStore } from "../agents/auth-profiles.js";
|
||||
import {
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
CODEX_CLI_PROFILE_ID,
|
||||
} from "../agents/auth-profiles.js";
|
||||
import type { AuthChoice } from "./onboard-types.js";
|
||||
|
||||
export type AuthChoiceOption = { value: AuthChoice; label: string };
|
||||
|
||||
export function buildAuthChoiceOptions(params: {
|
||||
store: AuthProfileStore;
|
||||
includeSkip: boolean;
|
||||
}): AuthChoiceOption[] {
|
||||
const options: AuthChoiceOption[] = [];
|
||||
|
||||
const claudeCli = params.store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||
if (claudeCli?.type === "oauth") {
|
||||
options.push({
|
||||
value: "claude-cli",
|
||||
label: "Anthropic OAuth (Claude CLI)",
|
||||
});
|
||||
}
|
||||
|
||||
options.push({ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" });
|
||||
|
||||
const codexCli = params.store.profiles[CODEX_CLI_PROFILE_ID];
|
||||
if (codexCli?.type === "oauth") {
|
||||
options.push({
|
||||
value: "codex-cli",
|
||||
label: "OpenAI Codex OAuth (Codex CLI)",
|
||||
});
|
||||
}
|
||||
|
||||
options.push({
|
||||
value: "openai-codex",
|
||||
label: "OpenAI Codex (ChatGPT OAuth)",
|
||||
});
|
||||
options.push({
|
||||
value: "antigravity",
|
||||
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
|
||||
});
|
||||
options.push({ value: "apiKey", label: "Anthropic API key" });
|
||||
options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" });
|
||||
if (params.includeSkip) {
|
||||
options.push({ value: "skip", label: "Skip for now" });
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { resolveAgentConfig } from "../agents/agent-scope.js";
|
||||
import {
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
CODEX_CLI_PROFILE_ID,
|
||||
ensureAuthProfileStore,
|
||||
listProfilesForProvider,
|
||||
} from "../agents/auth-profiles.js";
|
||||
@@ -165,6 +167,20 @@ export async function applyAuthChoice(params: {
|
||||
"OAuth help",
|
||||
);
|
||||
}
|
||||
} else if (params.authChoice === "claude-cli") {
|
||||
const store = ensureAuthProfileStore(params.agentDir);
|
||||
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
|
||||
await params.prompter.note(
|
||||
"No Claude CLI credentials found at ~/.claude/.credentials.json.",
|
||||
"Claude CLI OAuth",
|
||||
);
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
provider: "anthropic",
|
||||
mode: "oauth",
|
||||
});
|
||||
} else if (params.authChoice === "openai-codex") {
|
||||
const isRemote = isRemoteEnvironment();
|
||||
await params.prompter.note(
|
||||
@@ -250,6 +266,33 @@ export async function applyAuthChoice(params: {
|
||||
"OAuth help",
|
||||
);
|
||||
}
|
||||
} else if (params.authChoice === "codex-cli") {
|
||||
const store = ensureAuthProfileStore(params.agentDir);
|
||||
if (!store.profiles[CODEX_CLI_PROFILE_ID]) {
|
||||
await params.prompter.note(
|
||||
"No Codex CLI credentials found at ~/.codex/auth.json.",
|
||||
"Codex CLI OAuth",
|
||||
);
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: CODEX_CLI_PROFILE_ID,
|
||||
provider: "openai-codex",
|
||||
mode: "oauth",
|
||||
});
|
||||
if (params.setDefaultModel) {
|
||||
const applied = applyOpenAICodexModelDefault(nextConfig);
|
||||
nextConfig = applied.next;
|
||||
if (applied.changed) {
|
||||
await params.prompter.note(
|
||||
`Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`,
|
||||
"Model configured",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
agentModelOverride = OPENAI_CODEX_DEFAULT_MODEL;
|
||||
await noteAgentModel(OPENAI_CODEX_DEFAULT_MODEL);
|
||||
}
|
||||
} else if (params.authChoice === "antigravity") {
|
||||
const isRemote = isRemoteEnvironment();
|
||||
await params.prompter.note(
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
type OAuthCredentials,
|
||||
type OAuthProvider,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import {
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
CODEX_CLI_PROFILE_ID,
|
||||
ensureAuthProfileStore,
|
||||
} from "../agents/auth-profiles.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
CONFIG_PATH_CLAWDBOT,
|
||||
@@ -35,6 +40,7 @@ import {
|
||||
isRemoteEnvironment,
|
||||
loginAntigravityVpsAware,
|
||||
} from "./antigravity-oauth.js";
|
||||
import { buildAuthChoiceOptions } from "./auth-choice-options.js";
|
||||
import {
|
||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||
@@ -254,20 +260,21 @@ async function promptAuthConfig(
|
||||
const authChoice = guardCancel(
|
||||
await select({
|
||||
message: "Model/auth choice",
|
||||
options: [
|
||||
{ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" },
|
||||
{ value: "openai-codex", label: "OpenAI Codex (ChatGPT OAuth)" },
|
||||
{
|
||||
value: "antigravity",
|
||||
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
|
||||
},
|
||||
{ value: "apiKey", label: "Anthropic API key" },
|
||||
{ value: "minimax", label: "Minimax M2.1 (LM Studio)" },
|
||||
{ value: "skip", label: "Skip for now" },
|
||||
],
|
||||
options: buildAuthChoiceOptions({
|
||||
store: ensureAuthProfileStore(),
|
||||
includeSkip: true,
|
||||
}),
|
||||
}),
|
||||
runtime,
|
||||
) as "oauth" | "openai-codex" | "antigravity" | "apiKey" | "minimax" | "skip";
|
||||
) as
|
||||
| "oauth"
|
||||
| "claude-cli"
|
||||
| "openai-codex"
|
||||
| "codex-cli"
|
||||
| "antigravity"
|
||||
| "apiKey"
|
||||
| "minimax"
|
||||
| "skip";
|
||||
|
||||
let next = cfg;
|
||||
|
||||
@@ -312,6 +319,12 @@ async function promptAuthConfig(
|
||||
runtime.error(String(err));
|
||||
note("Trouble with OAuth? See https://docs.clawd.bot/start/faq", "OAuth");
|
||||
}
|
||||
} else if (authChoice === "claude-cli") {
|
||||
next = applyAuthProfileConfig(next, {
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
provider: "anthropic",
|
||||
mode: "oauth",
|
||||
});
|
||||
} else if (authChoice === "openai-codex") {
|
||||
const isRemote = isRemoteEnvironment();
|
||||
note(
|
||||
@@ -386,6 +399,20 @@ async function promptAuthConfig(
|
||||
runtime.error(String(err));
|
||||
note("Trouble with OAuth? See https://docs.clawd.bot/start/faq", "OAuth");
|
||||
}
|
||||
} else if (authChoice === "codex-cli") {
|
||||
next = applyAuthProfileConfig(next, {
|
||||
profileId: CODEX_CLI_PROFILE_ID,
|
||||
provider: "openai-codex",
|
||||
mode: "oauth",
|
||||
});
|
||||
const applied = applyOpenAICodexModelDefault(next);
|
||||
next = applied.next;
|
||||
if (applied.changed) {
|
||||
note(
|
||||
`Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`,
|
||||
"Model configured",
|
||||
);
|
||||
}
|
||||
} else if (authChoice === "antigravity") {
|
||||
const isRemote = isRemoteEnvironment();
|
||||
note(
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
CODEX_CLI_PROFILE_ID,
|
||||
ensureAuthProfileStore,
|
||||
} from "../agents/auth-profiles.js";
|
||||
import {
|
||||
type ClawdbotConfig,
|
||||
CONFIG_PATH_CLAWDBOT,
|
||||
@@ -30,6 +34,7 @@ import {
|
||||
randomToken,
|
||||
} from "./onboard-helpers.js";
|
||||
import type { AuthChoice, OnboardOptions } from "./onboard-types.js";
|
||||
import { applyOpenAICodexModelDefault } from "./openai-codex-model-default.js";
|
||||
import { ensureSystemdUserLingerNonInteractive } from "./systemd-linger.js";
|
||||
|
||||
export async function runNonInteractiveOnboarding(
|
||||
@@ -112,6 +117,33 @@ export async function runNonInteractiveOnboarding(
|
||||
provider: "anthropic",
|
||||
mode: "api_key",
|
||||
});
|
||||
} else if (authChoice === "claude-cli") {
|
||||
const store = ensureAuthProfileStore();
|
||||
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
|
||||
runtime.error(
|
||||
"No Claude CLI credentials found at ~/.claude/.credentials.json",
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
provider: "anthropic",
|
||||
mode: "oauth",
|
||||
});
|
||||
} else if (authChoice === "codex-cli") {
|
||||
const store = ensureAuthProfileStore();
|
||||
if (!store.profiles[CODEX_CLI_PROFILE_ID]) {
|
||||
runtime.error("No Codex CLI credentials found at ~/.codex/auth.json");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: CODEX_CLI_PROFILE_ID,
|
||||
provider: "openai-codex",
|
||||
mode: "oauth",
|
||||
});
|
||||
nextConfig = applyOpenAICodexModelDefault(nextConfig).next;
|
||||
} else if (authChoice === "minimax") {
|
||||
nextConfig = applyMinimaxConfig(nextConfig);
|
||||
} else if (
|
||||
|
||||
@@ -3,7 +3,9 @@ import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
|
||||
export type OnboardMode = "local" | "remote";
|
||||
export type AuthChoice =
|
||||
| "oauth"
|
||||
| "claude-cli"
|
||||
| "openai-codex"
|
||||
| "codex-cli"
|
||||
| "antigravity"
|
||||
| "apiKey"
|
||||
| "minimax"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
|
||||
import {
|
||||
applyAuthChoice,
|
||||
warnIfModelConfigLooksOff,
|
||||
} from "../commands/auth-choice.js";
|
||||
import { buildAuthChoiceOptions } from "../commands/auth-choice-options.js";
|
||||
import {
|
||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||
@@ -183,19 +184,10 @@ export async function runOnboardingWizard(
|
||||
},
|
||||
};
|
||||
|
||||
const authStore = ensureAuthProfileStore();
|
||||
const authChoice = (await prompter.select({
|
||||
message: "Model/auth choice",
|
||||
options: [
|
||||
{ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" },
|
||||
{ value: "openai-codex", label: "OpenAI Codex (ChatGPT OAuth)" },
|
||||
{
|
||||
value: "antigravity",
|
||||
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
|
||||
},
|
||||
{ value: "apiKey", label: "Anthropic API key" },
|
||||
{ value: "minimax", label: "Minimax M2.1 (LM Studio)" },
|
||||
{ value: "skip", label: "Skip for now" },
|
||||
],
|
||||
options: buildAuthChoiceOptions({ store: authStore, includeSkip: true }),
|
||||
})) as AuthChoice;
|
||||
|
||||
const authResult = await applyAuthChoice({
|
||||
|
||||
Reference in New Issue
Block a user