diff --git a/package.json b/package.json index 68adb791..1db24af4 100644 --- a/package.json +++ b/package.json @@ -8,17 +8,19 @@ "multica": "./bin/multica-interactive.mjs", "multica-interactive": "./bin/multica-interactive.mjs", "multica-cli": "./bin/multica-cli.mjs", - "multica-profile": "./bin/multica-profile.mjs" + "multica-profile": "./bin/multica-profile.mjs", + "multica-credentials": "./bin/multica-credentials.mjs" }, "scripts": { "dev": "concurrently -n gateway,console,web -c blue,yellow,green \"pnpm dev:gateway\" \"pnpm dev:console\" \"pnpm dev:web\"", - "agent:cli": "tsx --env-file=.env src/agent/cli/non-interactive.ts", - "agent:interactive": "tsx --env-file=.env src/agent/cli/interactive.ts", - "agent:profile": "tsx --env-file=.env src/agent/cli/profile.ts", - "skills:cli": "tsx --env-file=.env src/agent/cli/skills.ts", - "tools:cli": "tsx --env-file=.env src/agent/cli/tools.ts", - "dev:gateway": "tsx --env-file=.env --watch src/gateway/main.ts", - "dev:console": "tsx --env-file=.env --watch src/console/main.ts", + "agent:cli": "tsx src/agent/cli/non-interactive.ts", + "agent:interactive": "tsx src/agent/cli/interactive.ts", + "agent:profile": "tsx src/agent/cli/profile.ts", + "credentials:cli": "tsx src/agent/credentials-cli.ts", + "skills:cli": "tsx src/agent/cli/skills.ts", + "tools:cli": "tsx src/agent/cli/tools.ts", + "dev:gateway": "tsx --watch src/gateway/main.ts", + "dev:console": "tsx --watch src/console/main.ts", "dev:web": "pnpm --filter @multica/web dev", "dev:desktop": "pnpm --filter @multica/desktop dev", "build": "turbo build", @@ -59,6 +61,7 @@ "@nestjs/websockets": "^11.1.12", "@sinclair/typebox": "^0.34.41", "fast-glob": "^3.3.3", + "json5": "^2.2.3", "linkedom": "^0.18.12", "nestjs-pino": "^4.5.0", "pino": "^10.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54cf4ac2..450459fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,13 +34,13 @@ importers: dependencies: '@mariozechner/pi-agent-core': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mariozechner/pi-ai': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mariozechner/pi-coding-agent': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 @@ -68,6 +68,9 @@ importers: fast-glob: specifier: ^3.3.3 version: 3.3.3 + json5: + specifier: ^2.2.3 + version: 2.2.3 linkedom: specifier: ^0.18.12 version: 0.18.12 @@ -6397,11 +6400,11 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 - '@anthropic-ai/sdk@0.71.2(zod@3.25.76)': + '@anthropic-ai/sdk@0.71.2(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 optionalDependencies: - zod: 3.25.76 + zod: 4.3.6 '@aws-crypto/crc32@5.2.0': dependencies: @@ -7424,12 +7427,12 @@ snapshots: '@floating-ui/utils@0.2.10': {} - '@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))': + '@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))': dependencies: google-auth-library: 10.5.0 ws: 8.18.3 optionalDependencies: - '@modelcontextprotocol/sdk': 1.25.3(hono@4.11.7)(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.25.3(hono@4.11.7)(zod@4.3.6) transitivePeerDependencies: - bufferutil - supports-color @@ -7684,9 +7687,9 @@ snapshots: std-env: 3.10.0 yoctocolors: 2.1.2 - '@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)': + '@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)': dependencies: - '@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + '@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mariozechner/pi-tui': 0.50.3 transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -7697,21 +7700,21 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)': + '@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)': dependencies: - '@anthropic-ai/sdk': 0.71.2(zod@3.25.76) + '@anthropic-ai/sdk': 0.71.2(zod@4.3.6) '@aws-sdk/client-bedrock-runtime': 3.978.0 - '@google/genai': 1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76)) + '@google/genai': 1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6)) '@mistralai/mistralai': 1.10.0 '@sinclair/typebox': 0.34.48 ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) chalk: 5.6.2 - openai: 6.10.0(ws@8.18.3)(zod@3.25.76) + openai: 6.10.0(ws@8.18.3)(zod@4.3.6) partial-json: 0.1.7 proxy-agent: 6.5.0 undici: 7.19.2 - zod-to-json-schema: 3.25.1(zod@3.25.76) + zod-to-json-schema: 3.25.1(zod@4.3.6) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -7721,12 +7724,12 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)': + '@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)': dependencies: '@mariozechner/clipboard': 0.3.0 '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) - '@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + '@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) + '@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mariozechner/pi-tui': 0.50.3 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 @@ -7784,6 +7787,29 @@ snapshots: - hono - supports-color + '@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.11.7) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 7.5.1(express@5.2.1) + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - hono + - supports-color + optional: true + '@mozilla/readability@0.6.0': {} '@mswjs/interceptors@0.40.0': @@ -12023,10 +12049,10 @@ snapshots: powershell-utils: 0.1.0 wsl-utils: 0.3.1 - openai@6.10.0(ws@8.18.3)(zod@3.25.76): + openai@6.10.0(ws@8.18.3)(zod@4.3.6): optionalDependencies: ws: 8.18.3 - zod: 3.25.76 + zod: 4.3.6 optionator@0.9.4: dependencies: @@ -13642,6 +13668,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod-validation-error@4.0.2(zod@4.3.6): dependencies: zod: 4.3.6 diff --git a/scripts/build-cli.js b/scripts/build-cli.js index cae9e43d..8c85fa21 100644 --- a/scripts/build-cli.js +++ b/scripts/build-cli.js @@ -32,6 +32,7 @@ async function build() { { entry: "src/agent/cli/interactive.ts", outfile: "bin/multica-interactive.mjs" }, { entry: "src/agent/cli/non-interactive.ts", outfile: "bin/multica-cli.mjs" }, { entry: "src/agent/cli/profile.ts", outfile: "bin/multica-profile.mjs" }, + { entry: "src/agent/credentials-cli.ts", outfile: "bin/multica-credentials.mjs" }, ]; for (const { entry, outfile } of entryPoints) { diff --git a/src/agent/cli/skills.ts b/src/agent/cli/skills.ts index b00457a5..c5d67fe8 100644 --- a/src/agent/cli/skills.ts +++ b/src/agent/cli/skills.ts @@ -22,6 +22,7 @@ import { checkEligibilityDetailed, type DiagnosticItem, } from "../skills/index.js"; +import { credentialManager } from "../credentials.js"; // ============================================================================ // Types @@ -365,7 +366,7 @@ function checkBinaries(bins: string[]): Map { function checkEnvVars(envs: string[]): Map { const result = new Map(); for (const env of envs) { - result.set(env, env in process.env); + result.set(env, credentialManager.hasEnv(env)); } return result; } diff --git a/src/agent/credentials-cli.ts b/src/agent/credentials-cli.ts new file mode 100644 index 00000000..9fae8bbd --- /dev/null +++ b/src/agent/credentials-cli.ts @@ -0,0 +1,160 @@ +#!/usr/bin/env node +/** + * Credentials CLI + * + * Commands: + * init Create credentials.json5 and skills.env.json5 + */ + +import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs"; +import { dirname } from "node:path"; +import { getCredentialsPath, getSkillsEnvPath } from "./credentials.js"; + +type Command = "init" | "help"; + +function printUsage(): void { + console.log("Usage: pnpm credentials:cli [options]"); + console.log(""); + console.log("Commands:"); + console.log(" init Create credentials.json5 and skills.env.json5 (empty templates)"); + console.log(" help Show this help"); + console.log(""); + console.log("Options:"); + console.log(" --force Overwrite existing files"); + console.log(" --core-only Only create credentials.json5"); + console.log(" --skills-only Only create skills.env.json5"); + console.log(" --path Override credentials path (SMC_CREDENTIALS_PATH)"); + console.log(" --skills-path Override skills env path (SMC_SKILLS_ENV_PATH)"); + console.log(""); + console.log("Examples:"); + console.log(" pnpm credentials:cli init"); + console.log(" pnpm credentials:cli init --force"); + console.log(" pnpm credentials:cli init --core-only"); + console.log(" pnpm credentials:cli init --skills-only"); +} + +function buildCoreTemplate(): string { + return `{ + version: 1, + llm: { + // provider: "openai", + providers: { + // openai: { apiKey: "sk-...", baseUrl: "https://api.openai.com/v1", model: "gpt-4.1" } + } + }, + tools: { + // brave: { apiKey: "brv-..." }, + // perplexity: { apiKey: "pplx-...", baseUrl: "https://api.perplexity.ai", model: "perplexity/sonar-pro" } + } +} +`; +} + +function buildSkillsTemplate(): string { + return `{ + env: { + // Dynamic keys (skills, plugins, integrations) + // LINEAR_API_KEY: "lin-..." + } +} +`; +} + +function parseArgs(argv: string[]) { + const args = [...argv]; + let force = false; + let pathOverride: string | undefined; + let skillsPathOverride: string | undefined; + let coreOnly = false; + let skillsOnly = false; + const positional: string[] = []; + + while (args.length > 0) { + const arg = args.shift(); + if (!arg) break; + if (arg === "--force" || arg === "-f") { + force = true; + continue; + } + if (arg === "--core-only") { + coreOnly = true; + continue; + } + if (arg === "--skills-only") { + skillsOnly = true; + continue; + } + if (arg === "--path") { + pathOverride = args.shift(); + continue; + } + if (arg === "--skills-path") { + skillsPathOverride = args.shift(); + continue; + } + if (arg === "--help" || arg === "-h") { + return { command: "help" as Command, force, pathOverride, skillsPathOverride, coreOnly, skillsOnly }; + } + positional.push(arg); + } + + const command = (positional[0] || "help") as Command; + return { command, force, pathOverride, skillsPathOverride, coreOnly, skillsOnly }; +} + +function cmdInit(force: boolean, pathOverride?: string, skillsPathOverride?: string, coreOnly?: boolean, skillsOnly?: boolean): void { + const createCore = skillsOnly ? false : true; + const createSkills = coreOnly ? false : true; + + if (!createCore && !createSkills) { + console.error("Error: both --core-only and --skills-only were provided."); + process.exit(1); + } + + if (createCore) { + const path = pathOverride ?? getCredentialsPath(); + if (existsSync(path) && !force) { + console.error(`Error: credentials file already exists at ${path}`); + console.error("Use --force to overwrite."); + process.exit(1); + } + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, buildCoreTemplate(), "utf8"); + chmodSync(path, 0o600); + console.log(`Created: ${path}`); + } + + if (createSkills) { + const skillsPath = skillsPathOverride ?? getSkillsEnvPath(); + if (existsSync(skillsPath) && !force) { + console.error(`Error: skills env file already exists at ${skillsPath}`); + console.error("Use --force to overwrite."); + process.exit(1); + } + mkdirSync(dirname(skillsPath), { recursive: true }); + writeFileSync(skillsPath, buildSkillsTemplate(), "utf8"); + chmodSync(skillsPath, 0o600); + console.log(`Created: ${skillsPath}`); + } + + console.log("Edit these files to add your credentials."); +} + +async function main() { + const { command, force, pathOverride, skillsPathOverride, coreOnly, skillsOnly } = parseArgs(process.argv.slice(2)); + + switch (command) { + case "init": + cmdInit(force, pathOverride, skillsPathOverride, coreOnly, skillsOnly); + break; + case "help": + default: + printUsage(); + break; + } +} + +main().catch((err) => { + console.error(err?.stack || String(err)); + process.exit(1); +}); diff --git a/src/agent/credentials.ts b/src/agent/credentials.ts new file mode 100644 index 00000000..6e1e3dc5 --- /dev/null +++ b/src/agent/credentials.ts @@ -0,0 +1,202 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import JSON5 from "json5"; +import { DATA_DIR } from "../shared/paths.js"; + +type ProviderConfig = { + apiKey?: string | undefined; + baseUrl?: string | undefined; + model?: string | undefined; +}; + +type ToolConfig = { + apiKey?: string | undefined; + baseUrl?: string | undefined; + model?: string | undefined; +}; + +export type CredentialsConfig = { + version?: number | undefined; + llm?: { + provider?: string | undefined; + providers?: Record | undefined; + } | undefined; + tools?: Record | undefined; +}; + +type SkillsEnvConfig = { + env?: Record | undefined; +}; + +const DEFAULT_CREDENTIALS_PATH = join(DATA_DIR, "credentials.json5"); +const DEFAULT_SKILLS_ENV_PATH = join(DATA_DIR, "skills.env.json5"); + +function expandHome(value: string): string { + if (value === "~") return homedir(); + if (value.startsWith("~/")) { + return join(homedir(), value.slice(2)); + } + return value; +} + +function isTestEnv(): boolean { + return ( + process.env.NODE_ENV === "test" || + process.env.VITEST !== undefined || + process.env.VITEST_WORKER_ID !== undefined + ); +} + +function isString(value: unknown): value is string { + return typeof value === "string"; +} + +function setEnvValue(target: Record, key: string, value: unknown): void { + if (isString(value)) { + target[key] = value; + } +} + +function applyEnvMap(target: Record, env?: Record): void { + if (!env) return; + for (const [key, value] of Object.entries(env)) { + setEnvValue(target, key, value); + } +} + +export function getCredentialsPath(): string { + const raw = process.env.SMC_CREDENTIALS_PATH ?? DEFAULT_CREDENTIALS_PATH; + return expandHome(raw); +} + +export function getSkillsEnvPath(): string { + const raw = process.env.SMC_SKILLS_ENV_PATH ?? DEFAULT_SKILLS_ENV_PATH; + return expandHome(raw); +} + +export class CredentialManager { + private corePath: string | null = null; + private skillsPath: string | null = null; + private disabledState: boolean | null = null; + private coreConfig: CredentialsConfig | null = null; + private skillsConfig: SkillsEnvConfig | null = null; + private resolvedSkillsEnv: Record | null = null; + + private isDisabled(): boolean { + if (process.env.SMC_CREDENTIALS_DISABLE === "1") return true; + return isTestEnv(); + } + + private loadCore(): void { + const path = getCredentialsPath(); + const disabled = this.isDisabled(); + + if (this.corePath === path && this.disabledState === disabled && this.coreConfig) { + return; + } + + this.corePath = path; + this.disabledState = disabled; + this.coreConfig = null; + + if (disabled) return; + if (!existsSync(path)) return; + + const raw = readFileSync(path, "utf8"); + try { + this.coreConfig = JSON5.parse(raw) as CredentialsConfig; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to parse credentials file (${path}): ${message}`); + } + } + + private loadSkillsEnv(): void { + const path = getSkillsEnvPath(); + const disabled = this.isDisabled(); + + if (this.skillsPath === path && this.disabledState === disabled && this.resolvedSkillsEnv) { + return; + } + + this.skillsPath = path; + this.disabledState = disabled; + this.skillsConfig = null; + this.resolvedSkillsEnv = null; + + if (disabled) return; + if (!existsSync(path)) return; + + const raw = readFileSync(path, "utf8"); + try { + this.skillsConfig = JSON5.parse(raw) as SkillsEnvConfig; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to parse skills env file (${path}): ${message}`); + } + } + + private buildSkillsEnv(): Record { + const env: Record = {}; + if (!this.skillsConfig) return env; + + applyEnvMap(env, this.skillsConfig.env); + + return env; + } + + private getResolvedSkillsEnv(): Record { + this.loadSkillsEnv(); + if (!this.resolvedSkillsEnv) { + this.resolvedSkillsEnv = this.buildSkillsEnv(); + } + return this.resolvedSkillsEnv; + } + + getLlmProvider(): string | undefined { + this.loadCore(); + return this.coreConfig?.llm?.provider; + } + + getLlmProviderConfig(provider: string): ProviderConfig | undefined { + this.loadCore(); + return this.coreConfig?.llm?.providers?.[provider]; + } + + getToolConfig(toolName: string): ToolConfig | undefined { + this.loadCore(); + return this.coreConfig?.tools?.[toolName]; + } + + getEnv(name: string): string | undefined { + const resolved = this.getResolvedSkillsEnv(); + if (Object.prototype.hasOwnProperty.call(resolved, name)) { + return resolved[name]; + } + return process.env[name]; + } + + hasEnv(name: string): boolean { + const resolved = this.getResolvedSkillsEnv(); + if (Object.prototype.hasOwnProperty.call(resolved, name)) { + return true; + } + return name in process.env; + } + + getResolvedEnvSnapshot(): Record { + return { ...this.getResolvedSkillsEnv() }; + } + + reset(): void { + this.corePath = null; + this.skillsPath = null; + this.disabledState = null; + this.coreConfig = null; + this.skillsConfig = null; + this.resolvedSkillsEnv = null; + } +} + +export const credentialManager = new CredentialManager(); diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 441af311..bf73268f 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -6,6 +6,7 @@ import { resolveModel, resolveTools } from "./tools.js"; import { SessionManager } from "./session/session-manager.js"; import { ProfileManager } from "./profile/index.js"; import { SkillManager } from "./skills/index.js"; +import { credentialManager, getCredentialsPath } from "./credentials.js"; import { checkContextWindow, DEFAULT_CONTEXT_TOKENS, @@ -19,28 +20,7 @@ import { mergeToolsConfig, type ToolsConfig } from "./tools/policy.js"; */ function resolveApiKey(provider: string, explicitKey?: string): string | undefined { if (explicitKey) return explicitKey; - - const providerEnvMap: Record = { - openai: "OPENAI_API_KEY", - anthropic: "ANTHROPIC_API_KEY", - google: "GOOGLE_API_KEY", - "google-genai": "GOOGLE_API_KEY", - kimi: "MOONSHOT_API_KEY", - "kimi-coding": "MOONSHOT_API_KEY", - deepseek: "DEEPSEEK_API_KEY", - groq: "GROQ_API_KEY", - mistral: "MISTRAL_API_KEY", - together: "TOGETHER_API_KEY", - }; - - const envVar = providerEnvMap[provider]; - if (envVar) { - return process.env[envVar]; - } - - // Try generic format: PROVIDER_API_KEY - const normalizedProvider = provider.toUpperCase().replace(/-/g, "_"); - return process.env[`${normalizedProvider}_API_KEY`]; + return credentialManager.getLlmProviderConfig(provider)?.apiKey; } /** @@ -49,28 +29,7 @@ function resolveApiKey(provider: string, explicitKey?: string): string | undefin */ function resolveBaseUrl(provider: string, explicitUrl?: string): string | undefined { if (explicitUrl) return explicitUrl; - - const providerEnvMap: Record = { - openai: "OPENAI_BASE_URL", - anthropic: "ANTHROPIC_BASE_URL", - google: "GOOGLE_BASE_URL", - "google-genai": "GOOGLE_BASE_URL", - kimi: "MOONSHOT_BASE_URL", - "kimi-coding": "MOONSHOT_BASE_URL", - deepseek: "DEEPSEEK_BASE_URL", - groq: "GROQ_BASE_URL", - mistral: "MISTRAL_BASE_URL", - together: "TOGETHER_BASE_URL", - }; - - const envVar = providerEnvMap[provider]; - if (envVar) { - return process.env[envVar]; - } - - // Try generic format: PROVIDER_BASE_URL - const normalizedProvider = provider.toUpperCase().replace(/-/g, "_"); - return process.env[`${normalizedProvider}_BASE_URL`]; + return credentialManager.getLlmProviderConfig(provider)?.baseUrl; } /** @@ -79,28 +38,7 @@ function resolveBaseUrl(provider: string, explicitUrl?: string): string | undefi */ function resolveModelId(provider: string, explicitModel?: string): string | undefined { if (explicitModel) return explicitModel; - - const providerEnvMap: Record = { - openai: "OPENAI_MODEL", - anthropic: "ANTHROPIC_MODEL", - google: "GOOGLE_MODEL", - "google-genai": "GOOGLE_MODEL", - kimi: "MOONSHOT_MODEL", - "kimi-coding": "MOONSHOT_MODEL", - deepseek: "DEEPSEEK_MODEL", - groq: "GROQ_MODEL", - mistral: "MISTRAL_MODEL", - together: "TOGETHER_MODEL", - }; - - const envVar = providerEnvMap[provider]; - if (envVar) { - return process.env[envVar]; - } - - // Try generic format: PROVIDER_MODEL - const normalizedProvider = provider.toUpperCase().replace(/-/g, "_"); - return process.env[`${normalizedProvider}_MODEL`]; + return credentialManager.getLlmProviderConfig(provider)?.model; } export class Agent { @@ -122,7 +60,7 @@ export class Agent { this.debug = options.debug ?? false; // Resolve provider and model from options > env vars > defaults - const resolvedProvider = options.provider ?? process.env.LLM_PROVIDER ?? "kimi-coding"; + const resolvedProvider = options.provider ?? credentialManager.getLlmProvider() ?? "kimi-coding"; const resolvedModel = resolveModelId(resolvedProvider, options.model); const apiKey = resolveApiKey(resolvedProvider, options.apiKey); @@ -181,8 +119,7 @@ export class Agent { if (!model) { throw new Error( `Unknown model: provider="${effectiveProvider}", model="${effectiveModel}". ` + - `Check your LLM_PROVIDER and model env vars (e.g. OPENAI_MODEL). ` + - `For OpenRouter, use LLM_PROVIDER=openrouter.`, + `Check ${getCredentialsPath()} for llm.provider and llm.providers.${effectiveProvider}.model.`, ); } diff --git a/src/agent/session/session-manager.ts b/src/agent/session/session-manager.ts index 1f309925..e6793b36 100644 --- a/src/agent/session/session-manager.ts +++ b/src/agent/session/session-manager.ts @@ -3,6 +3,7 @@ import { getModel, type Model } from "@mariozechner/pi-ai"; import type { SessionEntry, SessionMeta } from "./types.js"; import { appendEntry, readEntries, writeEntries } from "./storage.js"; import { compactMessages, compactMessagesAsync } from "./compaction.js"; +import { credentialManager } from "../credentials.js"; /** Get Kimi model for summarization (use a cheaper model than k2-thinking) */ function getSummaryModel(): Model { @@ -11,7 +12,12 @@ function getSummaryModel(): Model { /** Get Kimi API key */ function getSummaryApiKey(): string | undefined { - return process.env.KIMI_API_KEY ?? process.env.MOONSHOT_API_KEY; + const providers = ["kimi", "moonshot", "kimi-coding"]; + for (const provider of providers) { + const apiKey = credentialManager.getLlmProviderConfig(provider)?.apiKey; + if (apiKey) return apiKey; + } + return undefined; } export type SessionManagerOptions = { diff --git a/src/agent/skills/eligibility.ts b/src/agent/skills/eligibility.ts index 83e099d1..62d96da5 100644 --- a/src/agent/skills/eligibility.ts +++ b/src/agent/skills/eligibility.ts @@ -19,6 +19,7 @@ import { normalizeRequirements, normalizePlatforms, } from "./types.js"; +import { credentialManager, getSkillsEnvPath } from "../credentials.js"; // ============================================================================ // Diagnostic Types @@ -77,7 +78,7 @@ export function binaryExists(binary: string): boolean { * @returns True if set (even if empty string) */ function envExists(envVar: string): boolean { - return envVar in process.env; + return credentialManager.hasEnv(envVar); } // ============================================================================ @@ -450,7 +451,7 @@ function generateEnvHint(envVars: string[], skill: Skill): string { // Check for well-known API key patterns if (envVar.endsWith("_API_KEY") || envVar.endsWith("_KEY")) { const service = envVar.replace(/_API_KEY$|_KEY$/, "").toLowerCase(); - hints.push(`Set ${envVar} in your environment or add to .env file`); + hints.push(`Set ${envVar} in your environment or add to ${getSkillsEnvPath()}`); // Add provider-specific hints const providerHint = getApiKeyHint(envVar); diff --git a/src/agent/tools/web/web-search.ts b/src/agent/tools/web/web-search.ts index 5b69a850..e04ec161 100644 --- a/src/agent/tools/web/web-search.ts +++ b/src/agent/tools/web/web-search.ts @@ -14,6 +14,7 @@ import { } from "./cache.js"; import type { CacheEntry } from "./cache.js"; import { jsonResult, readNumberParam, readStringParam } from "./param-helpers.js"; +import { credentialManager } from "../../credentials.js"; const SEARCH_PROVIDERS = ["brave", "perplexity"] as const; type SearchProvider = (typeof SEARCH_PROVIDERS)[number]; @@ -178,21 +179,21 @@ function inferPerplexityBaseUrl(apiKey: string): string { } function resolvePerplexityApiKey(): { apiKey: string; source: string } | { apiKey: null; source: "none" } { - const perplexityKey = (process.env.PERPLEXITY_API_KEY ?? "").trim(); + const perplexityKey = (credentialManager.getToolConfig("perplexity")?.apiKey ?? "").trim(); if (perplexityKey) { - return { apiKey: perplexityKey, source: "PERPLEXITY_API_KEY" }; + return { apiKey: perplexityKey, source: "perplexity" }; } - const openrouterKey = (process.env.OPENROUTER_API_KEY ?? "").trim(); + const openrouterKey = (credentialManager.getToolConfig("openrouter")?.apiKey ?? "").trim(); if (openrouterKey) { - return { apiKey: openrouterKey, source: "OPENROUTER_API_KEY" }; + return { apiKey: openrouterKey, source: "openrouter" }; } return { apiKey: null, source: "none" }; } function resolveBraveApiKey(): string | undefined { - return (process.env.BRAVE_API_KEY ?? "").trim() || undefined; + return (credentialManager.getToolConfig("brave")?.apiKey ?? "").trim() || undefined; } function resolveProvider(requested?: string): SearchProvider { @@ -340,24 +341,26 @@ async function runWebSearch(params: { return { error: "missing_api_key", message: - "Perplexity search requires PERPLEXITY_API_KEY or OPENROUTER_API_KEY environment variable.", + "Perplexity search requires tools.perplexity.apiKey (or tools.openrouter.apiKey) in credentials.json5.", }; } const apiKey = perplexityResult.apiKey; - const baseUrl = inferPerplexityBaseUrl(apiKey); + const perplexityConfig = credentialManager.getToolConfig("perplexity"); + const baseUrl = (perplexityConfig?.baseUrl ?? "").trim() || inferPerplexityBaseUrl(apiKey); + const model = (perplexityConfig?.model ?? "").trim() || DEFAULT_PERPLEXITY_MODEL; const { content, citations } = await runPerplexitySearch({ query: params.query, apiKey, baseUrl, - model: DEFAULT_PERPLEXITY_MODEL, + model, timeoutSeconds: params.timeoutSeconds, }); const payload = { query: params.query, provider: params.provider, - model: DEFAULT_PERPLEXITY_MODEL, + model, tookMs: Date.now() - start, content, citations, @@ -371,7 +374,7 @@ async function runWebSearch(params: { if (!apiKey) { return { error: "missing_api_key", - message: "Brave search requires BRAVE_API_KEY environment variable.", + message: "Brave search requires tools.brave.apiKey in credentials.json5.", }; }