diff --git a/CHANGELOG.md b/CHANGELOG.md index 813704b8f..c169107d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 2026.1.15 (unreleased) - Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf. +- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows. - Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj. - Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter. - Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts. diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index 2bccdcd33..40d5b26da 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -4,13 +4,14 @@ import { githubCopilotLoginCommand, modelsAliasesAddCommand, modelsAliasesListCommand, - modelsAliasesRemoveCommand, - modelsAuthAddCommand, - modelsAuthOrderClearCommand, - modelsAuthOrderGetCommand, - modelsAuthOrderSetCommand, - modelsAuthPasteTokenCommand, - modelsAuthSetupTokenCommand, + modelsAliasesRemoveCommand, + modelsAuthAddCommand, + modelsAuthLoginCommand, + modelsAuthOrderClearCommand, + modelsAuthOrderGetCommand, + modelsAuthOrderSetCommand, + modelsAuthPasteTokenCommand, + modelsAuthSetupTokenCommand, modelsFallbacksAddCommand, modelsFallbacksClearCommand, modelsFallbacksListCommand, @@ -309,6 +310,28 @@ export function registerModelsCli(program: Command) { } }); + auth + .command("login") + .description("Run a provider plugin auth flow (OAuth/API key)") + .option("--provider ", "Provider id registered by a plugin") + .option("--method ", "Provider auth method id") + .option("--set-default", "Apply the provider's default model recommendation", false) + .action(async (opts) => { + try { + await modelsAuthLoginCommand( + { + provider: opts.provider as string | undefined, + method: opts.method as string | undefined, + setDefault: Boolean(opts.setDefault), + }, + 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)") diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index a51b796ab..6a717d710 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -49,6 +49,9 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string { ` origin: ${plugin.origin}`, ]; if (plugin.version) parts.push(` version: ${plugin.version}`); + if (plugin.providerIds.length > 0) { + parts.push(` providers: ${plugin.providerIds.join(", ")}`); + } if (plugin.error) parts.push(chalk.red(` error: ${plugin.error}`)); return parts.join("\n"); } @@ -138,6 +141,9 @@ export function registerPluginsCli(program: Command) { if (plugin.gatewayMethods.length > 0) { lines.push(`Gateway methods: ${plugin.gatewayMethods.join(", ")}`); } + if (plugin.providerIds.length > 0) { + lines.push(`Providers: ${plugin.providerIds.join(", ")}`); + } if (plugin.cliCommands.length > 0) { lines.push(`CLI commands: ${plugin.cliCommands.join(", ")}`); } diff --git a/src/commands/models.ts b/src/commands/models.ts index 2003644a0..5a1c103c8 100644 --- a/src/commands/models.ts +++ b/src/commands/models.ts @@ -6,6 +6,7 @@ export { } from "./models/aliases.js"; export { modelsAuthAddCommand, + modelsAuthLoginCommand, modelsAuthPasteTokenCommand, modelsAuthSetupTokenCommand, } from "./models/auth.js"; diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 0b11f1e68..4a7610d6c 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -8,12 +8,21 @@ import { upsertAuthProfile, } from "../../agents/auth-profiles.js"; import { normalizeProviderId } from "../../agents/model-selection.js"; +import { resolveAgentDir, resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; -import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js"; +import { CONFIG_PATH_CLAWDBOT, readConfigFileSnapshot } from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.js"; import { applyAuthProfileConfig } from "../onboard-auth.js"; +import { isRemoteEnvironment } from "../antigravity-oauth.js"; +import { openUrl } from "../onboard-helpers.js"; +import { createVpsAwareOAuthHandlers } from "../oauth-flow.js"; import { updateConfig } from "./shared.js"; +import { resolvePluginProviders } from "../../plugins/providers.js"; +import { createClackPrompter } from "../../wizard/clack-prompter.js"; +import type { ProviderAuthMethod, ProviderAuthResult, ProviderPlugin } from "../../plugins/types.js"; +import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js"; const confirm = (params: Parameters[0]) => clackConfirm({ @@ -215,3 +224,204 @@ export async function modelsAuthAddCommand(_opts: Record, runtime await modelsAuthPasteTokenCommand({ provider: providerId, profileId, expiresIn }, runtime); } + +type LoginOptions = { + provider?: string; + method?: string; + setDefault?: boolean; +}; + +function resolveProviderMatch( + providers: ProviderPlugin[], + rawProvider?: string, +): ProviderPlugin | null { + const raw = rawProvider?.trim(); + if (!raw) return null; + const normalized = normalizeProviderId(raw); + return ( + providers.find((provider) => normalizeProviderId(provider.id) === normalized) ?? + providers.find( + (provider) => + provider.aliases?.some((alias) => normalizeProviderId(alias) === normalized) ?? false, + ) ?? + null + ); +} + +function pickAuthMethod(provider: ProviderPlugin, rawMethod?: string): ProviderAuthMethod | null { + const raw = rawMethod?.trim(); + if (!raw) return null; + const normalized = raw.toLowerCase(); + return ( + provider.auth.find((method) => method.id.toLowerCase() === normalized) ?? + provider.auth.find((method) => method.label.toLowerCase() === normalized) ?? + null + ); +} + +function isPlainRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function mergeConfigPatch(base: T, patch: unknown): T { + if (!isPlainRecord(base) || !isPlainRecord(patch)) { + return patch as T; + } + + const next: Record = { ...base }; + for (const [key, value] of Object.entries(patch)) { + const existing = next[key]; + if (isPlainRecord(existing) && isPlainRecord(value)) { + next[key] = mergeConfigPatch(existing, value); + } else { + next[key] = value; + } + } + return next as T; +} + +function applyDefaultModel(cfg: ClawdbotConfig, model: string): ClawdbotConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[model] = models[model] ?? {}; + + const existingModel = cfg.agents?.defaults?.model; + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + model: { + ...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel + ? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks } + : undefined), + primary: model, + }, + }, + }, + }; +} + +function credentialMode(credential: AuthProfileCredential): "api_key" | "oauth" | "token" { + if (credential.type === "api_key") return "api_key"; + if (credential.type === "token") return "token"; + return "oauth"; +} + +export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: RuntimeEnv) { + if (!process.stdin.isTTY) { + throw new Error("models auth login requires an interactive TTY."); + } + + const snapshot = await readConfigFileSnapshot(); + if (!snapshot.valid) { + const issues = snapshot.issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n"); + throw new Error(`Invalid config at ${snapshot.path}\n${issues}`); + } + + const config = snapshot.config; + const defaultAgentId = resolveDefaultAgentId(config); + const agentDir = resolveAgentDir(config, defaultAgentId); + const workspaceDir = + resolveAgentWorkspaceDir(config, defaultAgentId) ?? resolveDefaultAgentWorkspaceDir(); + + const providers = resolvePluginProviders({ config, workspaceDir }); + if (providers.length === 0) { + throw new Error("No provider plugins found. Install one via `clawdbot plugins install`."); + } + + const prompter = createClackPrompter(); + const selectedProvider = + resolveProviderMatch(providers, opts.provider) ?? + (await prompter.select({ + message: "Select a provider", + options: providers.map((provider) => ({ + value: provider.id, + label: provider.label, + hint: provider.docsPath ? `Docs: ${provider.docsPath}` : undefined, + })), + }).then((id) => resolveProviderMatch(providers, String(id)))); + + if (!selectedProvider) { + throw new Error("Unknown provider. Use --provider to pick a provider plugin."); + } + + const chosenMethod = + pickAuthMethod(selectedProvider, opts.method) ?? + (selectedProvider.auth.length === 1 + ? selectedProvider.auth[0] + : await prompter.select({ + message: `Auth method for ${selectedProvider.label}`, + options: selectedProvider.auth.map((method) => ({ + value: method.id, + label: method.label, + hint: method.hint, + })), + }).then((id) => + selectedProvider.auth.find((method) => method.id === String(id)), + )); + + if (!chosenMethod) { + throw new Error("Unknown auth method. Use --method to select one."); + } + + const isRemote = isRemoteEnvironment(); + const result: ProviderAuthResult = await chosenMethod.run({ + config, + agentDir, + workspaceDir, + prompter, + runtime, + isRemote, + openUrl: async (url) => { + await openUrl(url); + }, + oauth: { + createVpsAwareHandlers: (params) => createVpsAwareOAuthHandlers(params), + }, + }); + + for (const profile of result.profiles) { + upsertAuthProfile({ + profileId: profile.profileId, + credential: profile.credential, + agentDir, + }); + } + + await updateConfig((cfg) => { + let next = cfg; + if (result.configPatch) { + next = mergeConfigPatch(next, result.configPatch); + } + for (const profile of result.profiles) { + next = applyAuthProfileConfig(next, { + profileId: profile.profileId, + provider: profile.credential.provider, + mode: credentialMode(profile.credential), + }); + } + if (opts.setDefault && result.defaultModel) { + next = applyDefaultModel(next, result.defaultModel); + } + return next; + }); + + runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); + for (const profile of result.profiles) { + runtime.log( + `Auth profile: ${profile.profileId} (${profile.credential.provider}/${credentialMode(profile.credential)})`, + ); + } + if (result.defaultModel) { + runtime.log( + opts.setDefault + ? `Default model set to ${result.defaultModel}` + : `Default model available: ${result.defaultModel} (use --set-default to apply)`, + ); + } + if (result.notes && result.notes.length > 0) { + await prompter.note(result.notes.join("\n"), "Provider notes"); + } +} diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index a3e8f3ec9..942406e86 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -190,6 +190,7 @@ function createPluginRecord(params: { status: params.enabled ? "loaded" : "disabled", toolNames: [], channelIds: [], + providerIds: [], gatewayMethods: [], cliCommands: [], services: [], diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts new file mode 100644 index 000000000..881c7e5d8 --- /dev/null +++ b/src/plugins/providers.ts @@ -0,0 +1,23 @@ +import { createSubsystemLogger } from "../logging.js"; +import { loadClawdbotPlugins } from "./loader.js"; +import type { ProviderPlugin } from "./types.js"; + +const log = createSubsystemLogger("plugins"); + +export function resolvePluginProviders(params: { + config?: Parameters[0]["config"]; + workspaceDir?: string; +}): ProviderPlugin[] { + const registry = loadClawdbotPlugins({ + config: params.config, + workspaceDir: params.workspaceDir, + logger: { + info: (msg) => log.info(msg), + warn: (msg) => log.warn(msg), + error: (msg) => log.error(msg), + debug: (msg) => log.debug(msg), + }, + }); + + return registry.providers.map((entry) => entry.provider); +} diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index d7e6296af..897376d41 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -11,6 +11,7 @@ import type { ClawdbotPluginChannelRegistration, ClawdbotPluginCliRegistrar, ClawdbotPluginHttpHandler, + ProviderPlugin, ClawdbotPluginService, ClawdbotPluginToolContext, ClawdbotPluginToolFactory, @@ -47,6 +48,12 @@ export type PluginChannelRegistration = { source: string; }; +export type PluginProviderRegistration = { + pluginId: string; + provider: ProviderPlugin; + source: string; +}; + export type PluginServiceRegistration = { pluginId: string; service: ClawdbotPluginService; @@ -66,6 +73,7 @@ export type PluginRecord = { error?: string; toolNames: string[]; channelIds: string[]; + providerIds: string[]; gatewayMethods: string[]; cliCommands: string[]; services: string[]; @@ -78,6 +86,7 @@ export type PluginRegistry = { plugins: PluginRecord[]; tools: PluginToolRegistration[]; channels: PluginChannelRegistration[]; + providers: PluginProviderRegistration[]; gatewayHandlers: GatewayRequestHandlers; httpHandlers: PluginHttpRegistration[]; cliRegistrars: PluginCliRegistration[]; @@ -95,6 +104,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { plugins: [], tools: [], channels: [], + providers: [], gatewayHandlers: {}, httpHandlers: [], cliRegistrars: [], @@ -189,6 +199,35 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); }; + const registerProvider = (record: PluginRecord, provider: ProviderPlugin) => { + const id = typeof provider?.id === "string" ? provider.id.trim() : ""; + if (!id) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "provider registration missing id", + }); + return; + } + const existing = registry.providers.find((entry) => entry.provider.id === id); + if (existing) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `provider already registered: ${id} (${existing.pluginId})`, + }); + return; + } + record.providerIds.push(id); + registry.providers.push({ + pluginId: record.id, + provider, + source: record.source, + }); + }; + const registerCli = ( record: PluginRecord, registrar: ClawdbotPluginCliRegistrar, @@ -241,6 +280,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerTool: (tool, opts) => registerTool(record, tool, opts), registerHttpHandler: (handler) => registerHttpHandler(record, handler), registerChannel: (registration) => registerChannel(record, registration), + registerProvider: (provider) => registerProvider(record, provider), registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler), registerCli: (registrar, opts) => registerCli(record, registrar, opts), registerService: (service) => registerService(record, service), @@ -254,6 +294,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { pushDiagnostic, registerTool, registerChannel, + registerProvider, registerGatewayMethod, registerCli, registerService, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index f1726b45f..69ccfe501 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1,10 +1,15 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { Command } from "commander"; +import type { AuthProfileCredential, OAuthCredential } from "../agents/auth-profiles/types.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import type { ChannelDock } from "../channels/dock.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { ClawdbotConfig } from "../config/config.js"; +import type { ModelProviderConfig } from "../config/types.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import type { createVpsAwareOAuthHandlers } from "../commands/oauth-flow.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; export type PluginLogger = { @@ -54,6 +59,48 @@ export type ClawdbotPluginToolFactory = ( ctx: ClawdbotPluginToolContext, ) => AnyAgentTool | AnyAgentTool[] | null | undefined; +export type ProviderAuthKind = "oauth" | "api_key" | "token" | "device_code" | "custom"; + +export type ProviderAuthResult = { + profiles: Array<{ profileId: string; credential: AuthProfileCredential }>; + configPatch?: Partial; + defaultModel?: string; + notes?: string[]; +}; + +export type ProviderAuthContext = { + config: ClawdbotConfig; + agentDir?: string; + workspaceDir?: string; + prompter: WizardPrompter; + runtime: RuntimeEnv; + isRemote: boolean; + openUrl: (url: string) => Promise; + oauth: { + createVpsAwareHandlers: typeof createVpsAwareOAuthHandlers; + }; +}; + +export type ProviderAuthMethod = { + id: string; + label: string; + hint?: string; + kind: ProviderAuthKind; + run: (ctx: ProviderAuthContext) => Promise; +}; + +export type ProviderPlugin = { + id: string; + label: string; + docsPath?: string; + aliases?: string[]; + envVars?: string[]; + models?: ModelProviderConfig; + auth: ProviderAuthMethod[]; + formatApiKey?: (cred: AuthProfileCredential) => string; + refreshOAuth?: (cred: OAuthCredential) => Promise; +}; + export type ClawdbotPluginGatewayMethod = { method: string; handler: GatewayRequestHandler; @@ -123,6 +170,7 @@ export type ClawdbotPluginApi = { registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => void; registerCli: (registrar: ClawdbotPluginCliRegistrar, opts?: { commands?: string[] }) => void; registerService: (service: ClawdbotPluginService) => void; + registerProvider: (provider: ProviderPlugin) => void; resolvePath: (input: string) => string; };