feat(credentials): add JSON5 credential system

This commit is contained in:
Jiayuan 2026-02-01 02:28:27 +08:00
parent b1d80f29ae
commit 3ee8946e29
10 changed files with 454 additions and 110 deletions

View file

@ -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<string, boolean> {
function checkEnvVars(envs: string[]): Map<string, boolean> {
const result = new Map<string, boolean>();
for (const env of envs) {
result.set(env, env in process.env);
result.set(env, credentialManager.hasEnv(env));
}
return result;
}

View file

@ -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 <command> [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);
});

202
src/agent/credentials.ts Normal file
View file

@ -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<string, ProviderConfig> | undefined;
} | undefined;
tools?: Record<string, ToolConfig> | undefined;
};
type SkillsEnvConfig = {
env?: Record<string, string> | 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<string, string>, key: string, value: unknown): void {
if (isString(value)) {
target[key] = value;
}
}
function applyEnvMap(target: Record<string, string>, env?: Record<string, string>): 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<string, string> | 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<string, string> {
const env: Record<string, string> = {};
if (!this.skillsConfig) return env;
applyEnvMap(env, this.skillsConfig.env);
return env;
}
private getResolvedSkillsEnv(): Record<string, string> {
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<string, string> {
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();

View file

@ -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<string, string> = {
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<string, string> = {
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<string, string> = {
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.`,
);
}

View file

@ -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<any> {
@ -11,7 +12,12 @@ function getSummaryModel(): Model<any> {
/** 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 = {

View file

@ -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);

View file

@ -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.",
};
}