feat: add models auth commands
This commit is contained in:
@@ -4,6 +4,9 @@ import {
|
|||||||
modelsAliasesAddCommand,
|
modelsAliasesAddCommand,
|
||||||
modelsAliasesListCommand,
|
modelsAliasesListCommand,
|
||||||
modelsAliasesRemoveCommand,
|
modelsAliasesRemoveCommand,
|
||||||
|
modelsAuthAddCommand,
|
||||||
|
modelsAuthPasteTokenCommand,
|
||||||
|
modelsAuthSetupTokenCommand,
|
||||||
modelsFallbacksAddCommand,
|
modelsFallbacksAddCommand,
|
||||||
modelsFallbacksClearCommand,
|
modelsFallbacksClearCommand,
|
||||||
modelsFallbacksListCommand,
|
modelsFallbacksListCommand,
|
||||||
@@ -294,4 +297,63 @@ export function registerModelsCli(program: Command) {
|
|||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const auth = models.command("auth").description("Manage model auth profiles");
|
||||||
|
|
||||||
|
auth
|
||||||
|
.command("add")
|
||||||
|
.description("Interactive auth helper (setup-token or paste token)")
|
||||||
|
.action(async () => {
|
||||||
|
try {
|
||||||
|
await modelsAuthAddCommand({}, defaultRuntime);
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(String(err));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
auth
|
||||||
|
.command("setup-token")
|
||||||
|
.description("Run a provider CLI to create/sync a token (TTY required)")
|
||||||
|
.option("--provider <name>", "Provider id (default: anthropic)")
|
||||||
|
.option("--yes", "Skip confirmation", false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
try {
|
||||||
|
await modelsAuthSetupTokenCommand(
|
||||||
|
{
|
||||||
|
provider: opts.provider as string | undefined,
|
||||||
|
yes: Boolean(opts.yes),
|
||||||
|
},
|
||||||
|
defaultRuntime,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(String(err));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
auth
|
||||||
|
.command("paste-token")
|
||||||
|
.description("Paste a token into auth-profiles.json and update config")
|
||||||
|
.requiredOption("--provider <name>", "Provider id (e.g. anthropic)")
|
||||||
|
.option("--profile-id <id>", "Auth profile id (default: <provider>:manual)")
|
||||||
|
.option(
|
||||||
|
"--expires-in <duration>",
|
||||||
|
"Optional expiry duration (e.g. 365d, 12h). Stored as absolute expiresAt.",
|
||||||
|
)
|
||||||
|
.action(async (opts) => {
|
||||||
|
try {
|
||||||
|
await modelsAuthPasteTokenCommand(
|
||||||
|
{
|
||||||
|
provider: opts.provider as string | undefined,
|
||||||
|
profileId: opts.profileId as string | undefined,
|
||||||
|
expiresIn: opts.expiresIn as string | undefined,
|
||||||
|
},
|
||||||
|
defaultRuntime,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(String(err));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ describe("parseDurationMs", () => {
|
|||||||
expect(parseDurationMs("2h")).toBe(7_200_000);
|
expect(parseDurationMs("2h")).toBe(7_200_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses days suffix", () => {
|
||||||
|
expect(parseDurationMs("2d")).toBe(172_800_000);
|
||||||
|
});
|
||||||
|
|
||||||
it("supports decimals", () => {
|
it("supports decimals", () => {
|
||||||
expect(parseDurationMs("0.5s")).toBe(500);
|
expect(parseDurationMs("0.5s")).toBe(500);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type DurationMsParseOptions = {
|
export type DurationMsParseOptions = {
|
||||||
defaultUnit?: "ms" | "s" | "m" | "h";
|
defaultUnit?: "ms" | "s" | "m" | "h" | "d";
|
||||||
};
|
};
|
||||||
|
|
||||||
export function parseDurationMs(
|
export function parseDurationMs(
|
||||||
@@ -11,7 +11,7 @@ export function parseDurationMs(
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
if (!trimmed) throw new Error("invalid duration (empty)");
|
if (!trimmed) throw new Error("invalid duration (empty)");
|
||||||
|
|
||||||
const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(trimmed);
|
const m = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/.exec(trimmed);
|
||||||
if (!m) throw new Error(`invalid duration: ${raw}`);
|
if (!m) throw new Error(`invalid duration: ${raw}`);
|
||||||
|
|
||||||
const value = Number(m[1]);
|
const value = Number(m[1]);
|
||||||
@@ -19,9 +19,22 @@ export function parseDurationMs(
|
|||||||
throw new Error(`invalid duration: ${raw}`);
|
throw new Error(`invalid duration: ${raw}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const unit = (m[2] ?? opts?.defaultUnit ?? "ms") as "ms" | "s" | "m" | "h";
|
const unit = (m[2] ?? opts?.defaultUnit ?? "ms") as
|
||||||
|
| "ms"
|
||||||
|
| "s"
|
||||||
|
| "m"
|
||||||
|
| "h"
|
||||||
|
| "d";
|
||||||
const multiplier =
|
const multiplier =
|
||||||
unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : 3_600_000;
|
unit === "ms"
|
||||||
|
? 1
|
||||||
|
: unit === "s"
|
||||||
|
? 1000
|
||||||
|
: unit === "m"
|
||||||
|
? 60_000
|
||||||
|
: unit === "h"
|
||||||
|
? 3_600_000
|
||||||
|
: 86_400_000;
|
||||||
const ms = Math.round(value * multiplier);
|
const ms = Math.round(value * multiplier);
|
||||||
if (!Number.isFinite(ms)) throw new Error(`invalid duration: ${raw}`);
|
if (!Number.isFinite(ms)) throw new Error(`invalid duration: ${raw}`);
|
||||||
return ms;
|
return ms;
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ export {
|
|||||||
modelsAliasesListCommand,
|
modelsAliasesListCommand,
|
||||||
modelsAliasesRemoveCommand,
|
modelsAliasesRemoveCommand,
|
||||||
} from "./models/aliases.js";
|
} from "./models/aliases.js";
|
||||||
|
export {
|
||||||
|
modelsAuthAddCommand,
|
||||||
|
modelsAuthPasteTokenCommand,
|
||||||
|
modelsAuthSetupTokenCommand,
|
||||||
|
} from "./models/auth.js";
|
||||||
export {
|
export {
|
||||||
modelsFallbacksAddCommand,
|
modelsFallbacksAddCommand,
|
||||||
modelsFallbacksClearCommand,
|
modelsFallbacksClearCommand,
|
||||||
|
|||||||
207
src/commands/models/auth.ts
Normal file
207
src/commands/models/auth.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
|
||||||
|
import { confirm, select, text } from "@clack/prompts";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CLAUDE_CLI_PROFILE_ID,
|
||||||
|
ensureAuthProfileStore,
|
||||||
|
upsertAuthProfile,
|
||||||
|
} from "../../agents/auth-profiles.js";
|
||||||
|
import { normalizeProviderId } from "../../agents/model-selection.js";
|
||||||
|
import { parseDurationMs } from "../../cli/parse-duration.js";
|
||||||
|
import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js";
|
||||||
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
|
import { applyAuthProfileConfig } from "../onboard-auth.js";
|
||||||
|
import { updateConfig } from "./shared.js";
|
||||||
|
|
||||||
|
type TokenProvider = "anthropic";
|
||||||
|
|
||||||
|
function resolveTokenProvider(raw?: string): TokenProvider | "custom" | null {
|
||||||
|
const trimmed = raw?.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const normalized = normalizeProviderId(trimmed);
|
||||||
|
if (normalized === "anthropic") return "anthropic";
|
||||||
|
return "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDefaultTokenProfileId(provider: string): string {
|
||||||
|
return `${normalizeProviderId(provider)}:manual`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function modelsAuthSetupTokenCommand(
|
||||||
|
opts: { provider?: string; yes?: boolean },
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
) {
|
||||||
|
const provider = resolveTokenProvider(opts.provider ?? "anthropic");
|
||||||
|
if (provider !== "anthropic") {
|
||||||
|
throw new Error(
|
||||||
|
"Only --provider anthropic is supported for setup-token (uses `claude setup-token`).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.stdin.isTTY) {
|
||||||
|
throw new Error("setup-token requires an interactive TTY.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opts.yes) {
|
||||||
|
const proceed = await confirm({
|
||||||
|
message: "Run `claude setup-token` now?",
|
||||||
|
initialValue: true,
|
||||||
|
});
|
||||||
|
if (!proceed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = spawnSync("claude", ["setup-token"], { stdio: "inherit" });
|
||||||
|
if (res.error) throw res.error;
|
||||||
|
if (typeof res.status === "number" && res.status !== 0) {
|
||||||
|
throw new Error(`claude setup-token failed (exit ${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = ensureAuthProfileStore(undefined, {
|
||||||
|
allowKeychainPrompt: true,
|
||||||
|
});
|
||||||
|
const synced = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||||
|
if (!synced) {
|
||||||
|
throw new Error(
|
||||||
|
`No Claude CLI credentials found after setup-token. Expected auth profile ${CLAUDE_CLI_PROFILE_ID}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateConfig((cfg) =>
|
||||||
|
applyAuthProfileConfig(cfg, {
|
||||||
|
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||||
|
provider: "anthropic",
|
||||||
|
mode: "token",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
|
runtime.log(`Auth profile: ${CLAUDE_CLI_PROFILE_ID} (anthropic/token)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function modelsAuthPasteTokenCommand(
|
||||||
|
opts: {
|
||||||
|
provider?: string;
|
||||||
|
profileId?: string;
|
||||||
|
expiresIn?: string;
|
||||||
|
},
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
) {
|
||||||
|
const rawProvider = opts.provider?.trim();
|
||||||
|
if (!rawProvider) {
|
||||||
|
throw new Error("Missing --provider.");
|
||||||
|
}
|
||||||
|
const provider = normalizeProviderId(rawProvider);
|
||||||
|
const profileId =
|
||||||
|
opts.profileId?.trim() || resolveDefaultTokenProfileId(provider);
|
||||||
|
|
||||||
|
const tokenInput = await text({
|
||||||
|
message: `Paste token for ${provider}`,
|
||||||
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
|
});
|
||||||
|
const token = String(tokenInput).trim();
|
||||||
|
|
||||||
|
const expires =
|
||||||
|
opts.expiresIn?.trim() && opts.expiresIn.trim().length > 0
|
||||||
|
? Date.now() +
|
||||||
|
parseDurationMs(String(opts.expiresIn).trim(), { defaultUnit: "d" })
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
upsertAuthProfile({
|
||||||
|
profileId,
|
||||||
|
credential: {
|
||||||
|
type: "token",
|
||||||
|
provider,
|
||||||
|
token,
|
||||||
|
...(expires ? { expires } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateConfig((cfg) =>
|
||||||
|
applyAuthProfileConfig(cfg, { profileId, provider, mode: "token" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
|
runtime.log(`Auth profile: ${profileId} (${provider}/token)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function modelsAuthAddCommand(
|
||||||
|
_opts: Record<string, never>,
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
) {
|
||||||
|
const provider = (await select({
|
||||||
|
message: "Token provider",
|
||||||
|
options: [
|
||||||
|
{ value: "anthropic", label: "anthropic" },
|
||||||
|
{ value: "custom", label: "custom (type provider id)" },
|
||||||
|
],
|
||||||
|
})) as TokenProvider | "custom";
|
||||||
|
|
||||||
|
const providerId =
|
||||||
|
provider === "custom"
|
||||||
|
? normalizeProviderId(
|
||||||
|
String(
|
||||||
|
await text({
|
||||||
|
message: "Provider id",
|
||||||
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: provider;
|
||||||
|
|
||||||
|
const method = (await select({
|
||||||
|
message: "Token method",
|
||||||
|
options: [
|
||||||
|
...(providerId === "anthropic"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
value: "setup-token",
|
||||||
|
label: "setup-token (claude)",
|
||||||
|
hint: "Runs `claude setup-token` (recommended)",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{ value: "paste", label: "paste token" },
|
||||||
|
],
|
||||||
|
})) as "setup-token" | "paste";
|
||||||
|
|
||||||
|
if (method === "setup-token") {
|
||||||
|
await modelsAuthSetupTokenCommand({ provider: providerId }, runtime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileIdDefault = resolveDefaultTokenProfileId(providerId);
|
||||||
|
const profileId = String(
|
||||||
|
await text({
|
||||||
|
message: "Profile id",
|
||||||
|
initialValue: profileIdDefault,
|
||||||
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
const wantsExpiry = await confirm({
|
||||||
|
message: "Does this token expire?",
|
||||||
|
initialValue: false,
|
||||||
|
});
|
||||||
|
const expiresIn = wantsExpiry
|
||||||
|
? String(
|
||||||
|
await text({
|
||||||
|
message: "Expires in (duration)",
|
||||||
|
initialValue: "365d",
|
||||||
|
validate: (value) => {
|
||||||
|
try {
|
||||||
|
parseDurationMs(String(value ?? ""), { defaultUnit: "d" });
|
||||||
|
return undefined;
|
||||||
|
} catch {
|
||||||
|
return "Invalid duration (e.g. 365d, 12h, 30m)";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).trim()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await modelsAuthPasteTokenCommand(
|
||||||
|
{ provider: providerId, profileId, expiresIn },
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user