config.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. import { Command } from "commander";
  2. import { readFile, writeFile, mkdir } from "node:fs/promises";
  3. import { join } from "node:path";
  4. import { findProjectRoot, log, logError, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH } from "../utils.js";
  5. import { listModelsForService } from "@actalk/inkos-core";
  6. export const configCommand = new Command("config")
  7. .description("Manage project configuration");
  8. configCommand
  9. .command("set")
  10. .description("Set a configuration value")
  11. .argument("<key>", "Config key (e.g., llm.apiKey)")
  12. .argument("<value>", "Config value")
  13. .action(async (key: string, value: string) => {
  14. const root = findProjectRoot();
  15. const configPath = join(root, "inkos.json");
  16. try {
  17. const raw = await readFile(configPath, "utf-8");
  18. const config = JSON.parse(raw);
  19. const keys = key.split(".");
  20. const KNOWN_KEYS = new Set([
  21. "llm.provider", "llm.baseUrl", "llm.model", "llm.temperature",
  22. "llm.thinkingBudget", "llm.proxyUrl", "llm.apiFormat", "llm.stream",
  23. "inputGovernanceMode",
  24. "foundation.reviewRetries",
  25. "daemon.schedule.radarCron", "daemon.schedule.writeCron",
  26. "daemon.maxConcurrentBooks", "daemon.chaptersPerCycle",
  27. "daemon.retryDelayMs", "daemon.cooldownAfterChapterMs",
  28. "daemon.maxChaptersPerDay",
  29. ]);
  30. // Allow any key under llm.extra.* (passthrough to API)
  31. if (!KNOWN_KEYS.has(key) && !key.startsWith("llm.extra.")) {
  32. // Find closest match by edit distance on the last segment
  33. const candidates = [...KNOWN_KEYS];
  34. const inputParts = key.split(".");
  35. const samePrefixCandidates = candidates.filter(k => {
  36. const parts = k.split(".");
  37. return parts.length === inputParts.length && parts.slice(0, -1).join(".") === inputParts.slice(0, -1).join(".");
  38. });
  39. const editDist = (a: string, b: string): number => {
  40. const m = a.length, n = b.length;
  41. const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_, j) => i === 0 ? j : j === 0 ? i : 0));
  42. for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++)
  43. dp[i]![j] = Math.min(dp[i-1]![j]! + 1, dp[i]![j-1]! + 1, dp[i-1]![j-1]! + (a[i-1] !== b[j-1] ? 1 : 0));
  44. return dp[m]![n]!;
  45. };
  46. const inputLast = inputParts[inputParts.length - 1]!;
  47. const suggestion = samePrefixCandidates
  48. .map(k => ({ k, d: editDist(k.split(".").pop()!, inputLast) }))
  49. .sort((a, b) => a.d - b.d)
  50. .find(x => x.d <= 3)?.k;
  51. logError(`Unknown config key "${key}".${suggestion ? ` Did you mean "${suggestion}"?` : ""}`);
  52. log(`Known keys: ${candidates.join(", ")}`);
  53. process.exit(1);
  54. }
  55. let target = config;
  56. for (let i = 0; i < keys.length - 1; i++) {
  57. const k = keys[i]!;
  58. if (!(k in target)) {
  59. target[k] = {};
  60. }
  61. target = target[k];
  62. }
  63. const finalKey = keys[keys.length - 1]!;
  64. // Auto-coerce types: numbers and booleans shouldn't be stored as strings
  65. if (/^\d+(\.\d+)?$/.test(value)) {
  66. target[finalKey] = parseFloat(value);
  67. } else if (value === "true") {
  68. target[finalKey] = true;
  69. } else if (value === "false") {
  70. target[finalKey] = false;
  71. } else {
  72. target[finalKey] = value;
  73. }
  74. await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
  75. log(`Set ${key} = ${value}`);
  76. } catch (e) {
  77. logError(`Failed to update config: ${e}`);
  78. process.exit(1);
  79. }
  80. });
  81. configCommand
  82. .command("set-global")
  83. .description("Set global LLM config (~/.inkos/.env), shared by all projects")
  84. .requiredOption("--provider <provider>", "LLM provider (openai / anthropic)")
  85. .requiredOption("--base-url <url>", "API base URL")
  86. .requiredOption("--api-key <key>", "API key")
  87. .requiredOption("--model <model>", "Model name")
  88. .option("--temperature <n>", "Temperature")
  89. .option("--max-tokens <n>", "Max output tokens")
  90. .option("--thinking-budget <n>", "Anthropic thinking budget")
  91. .option("--api-format <format>", "API format (chat / responses)")
  92. .option("--lang <language>", "Default writing language: zh (Chinese) or en (English)")
  93. .action(async (opts) => {
  94. try {
  95. await mkdir(GLOBAL_CONFIG_DIR, { recursive: true });
  96. const lines = [
  97. "# InkOS Global LLM Configuration",
  98. `INKOS_LLM_PROVIDER=${opts.provider}`,
  99. `INKOS_LLM_BASE_URL=${opts.baseUrl}`,
  100. `INKOS_LLM_API_KEY=${opts.apiKey}`,
  101. `INKOS_LLM_MODEL=${opts.model}`,
  102. ];
  103. if (opts.temperature) lines.push(`INKOS_LLM_TEMPERATURE=${opts.temperature}`);
  104. if (opts.thinkingBudget) lines.push(`INKOS_LLM_THINKING_BUDGET=${opts.thinkingBudget}`);
  105. if (opts.apiFormat) lines.push(`INKOS_LLM_API_FORMAT=${opts.apiFormat}`);
  106. if (opts.lang) lines.push(`INKOS_DEFAULT_LANGUAGE=${opts.lang}`);
  107. await writeFile(GLOBAL_ENV_PATH, lines.join("\n") + "\n", "utf-8");
  108. log(`Global config saved to ${GLOBAL_ENV_PATH}`);
  109. log("All projects will use this config unless overridden by project .env");
  110. } catch (e) {
  111. logError(`Failed to set global config: ${e}`);
  112. process.exit(1);
  113. }
  114. });
  115. configCommand
  116. .command("show-global")
  117. .description("Show global LLM config (~/.inkos/.env)")
  118. .action(async () => {
  119. try {
  120. const content = await readFile(GLOBAL_ENV_PATH, "utf-8");
  121. const masked = content.replace(
  122. /(INKOS_LLM_API_KEY=)(.{8})(.*)(.{4})/,
  123. "$1$2...$4",
  124. );
  125. log(masked);
  126. } catch {
  127. log("No global config found. Run 'inkos config set-global' to create one.");
  128. }
  129. });
  130. configCommand
  131. .command("show")
  132. .description("Show current project configuration")
  133. .action(async () => {
  134. const root = findProjectRoot();
  135. const configPath = join(root, "inkos.json");
  136. try {
  137. const raw = await readFile(configPath, "utf-8");
  138. const config = JSON.parse(raw);
  139. // Mask API key
  140. if (config.llm?.apiKey) {
  141. const key = config.llm.apiKey;
  142. config.llm.apiKey = key.slice(0, 8) + "..." + key.slice(-4);
  143. }
  144. log(JSON.stringify(config, null, 2));
  145. } catch (e) {
  146. logError(`Failed to read config: ${e}`);
  147. process.exit(1);
  148. }
  149. });
  150. const KNOWN_AGENTS = ["writer", "auditor", "reviser", "architect", "radar", "chapter-analyzer"] as const;
  151. const ENV_VAR_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
  152. function validateApiKeyEnvName(value: string): string | undefined {
  153. if (ENV_VAR_NAME_PATTERN.test(value)) return undefined;
  154. if (/^(sk-|sess-|rk-|pk-)/i.test(value) || value.includes("://")) {
  155. return "--api-key-env expects an environment variable name like PACKY_API_KEY, not a raw API key or URL.";
  156. }
  157. return `--api-key-env expects an environment variable name like PACKY_API_KEY. "${value}" is not a valid env var name.`;
  158. }
  159. configCommand
  160. .command("set-model")
  161. .description("Set model override for a specific agent (with optional provider routing)")
  162. .argument("<agent>", `Agent name (${KNOWN_AGENTS.join(", ")})`)
  163. .argument("<model>", "Model name")
  164. .option("--base-url <url>", "API base URL (for different provider)")
  165. .option("--provider <provider>", "Provider type (openai / anthropic / custom)")
  166. .option("--api-key-env <envVar>", "Env variable name for API key (e.g., PACKYAPI_KEY)")
  167. .option("--stream", "Enable streaming (default)")
  168. .option("--no-stream", "Disable streaming")
  169. .action(async (agent: string, model: string, opts: { baseUrl?: string; provider?: string; apiKeyEnv?: string; stream?: boolean }) => {
  170. if (!KNOWN_AGENTS.includes(agent as typeof KNOWN_AGENTS[number])) {
  171. logError(`Unknown agent "${agent}". Valid agents: ${KNOWN_AGENTS.join(", ")}`);
  172. process.exit(1);
  173. }
  174. if (opts.apiKeyEnv) {
  175. const validationError = validateApiKeyEnvName(opts.apiKeyEnv);
  176. if (validationError) {
  177. logError(validationError);
  178. process.exit(1);
  179. }
  180. }
  181. const root = findProjectRoot();
  182. const configPath = join(root, "inkos.json");
  183. try {
  184. const raw = await readFile(configPath, "utf-8");
  185. const config = JSON.parse(raw);
  186. const overrides = config.modelOverrides ?? {};
  187. const hasProviderOpts = opts.baseUrl || opts.provider || opts.apiKeyEnv || opts.stream === false;
  188. if (hasProviderOpts) {
  189. const override: Record<string, unknown> = { model };
  190. if (opts.baseUrl) override.baseUrl = opts.baseUrl;
  191. if (opts.provider) override.provider = opts.provider;
  192. if (opts.apiKeyEnv) override.apiKeyEnv = opts.apiKeyEnv;
  193. if (opts.stream === false) override.stream = false;
  194. config.modelOverrides = { ...overrides, [agent]: override };
  195. } else {
  196. config.modelOverrides = { ...overrides, [agent]: model };
  197. }
  198. await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
  199. log(`Model override: ${agent} → ${model}${opts.baseUrl ? ` (${opts.baseUrl})` : ""}`);
  200. } catch (e) {
  201. logError(`Failed to update config: ${e}`);
  202. process.exit(1);
  203. }
  204. });
  205. configCommand
  206. .command("remove-model")
  207. .description("Remove model override for a specific agent (falls back to default)")
  208. .argument("<agent>", "Agent name")
  209. .action(async (agent: string) => {
  210. const root = findProjectRoot();
  211. const configPath = join(root, "inkos.json");
  212. try {
  213. const raw = await readFile(configPath, "utf-8");
  214. const config = JSON.parse(raw);
  215. const overrides = config.modelOverrides;
  216. if (!overrides || !(agent in overrides)) {
  217. log(`No model override for "${agent}".`);
  218. return;
  219. }
  220. const { [agent]: _, ...rest } = overrides;
  221. config.modelOverrides = Object.keys(rest).length > 0 ? rest : undefined;
  222. await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
  223. log(`Removed model override for ${agent}. Will use default model.`);
  224. } catch (e) {
  225. logError(`Failed to update config: ${e}`);
  226. process.exit(1);
  227. }
  228. });
  229. configCommand
  230. .command("show-models")
  231. .description("Show model routing for all agents")
  232. .option("--json", "Output JSON")
  233. .action(async (opts) => {
  234. const root = findProjectRoot();
  235. const configPath = join(root, "inkos.json");
  236. try {
  237. const raw = await readFile(configPath, "utf-8");
  238. const config = JSON.parse(raw);
  239. const defaultModel = config.llm?.model ?? "(not set)";
  240. const overrides: Record<string, unknown> = config.modelOverrides ?? {};
  241. if (opts.json) {
  242. log(JSON.stringify({ defaultModel, overrides }, null, 2));
  243. return;
  244. }
  245. log(`Default model: ${defaultModel}\n`);
  246. if (Object.keys(overrides).length === 0) {
  247. log("No agent-specific overrides. All agents use the default model.");
  248. return;
  249. }
  250. log("Agent overrides:");
  251. for (const [agent, value] of Object.entries(overrides)) {
  252. if (typeof value === "string") {
  253. log(` ${agent} → ${value}`);
  254. } else {
  255. const o = value as Record<string, unknown>;
  256. const parts = [o.model as string];
  257. if (o.baseUrl) parts.push(`@ ${o.baseUrl}`);
  258. if (o.stream === false) parts.push("[no-stream]");
  259. log(` ${agent} → ${parts.join(" ")}`);
  260. }
  261. }
  262. log("");
  263. const usingDefault = KNOWN_AGENTS.filter((a) => !(a in overrides));
  264. if (usingDefault.length > 0) {
  265. log(`Using default: ${usingDefault.join(", ")}`);
  266. }
  267. } catch (e) {
  268. logError(`Failed to read config: ${e}`);
  269. process.exit(1);
  270. }
  271. });
  272. // B17: list-models 命令 —— 列出指定 service 的可用模型(含元数据)
  273. configCommand
  274. .command("list-models <service>")
  275. .description("List available models for a service (with maxOutput / contextWindow / abilities)")
  276. .option("--api-key <key>", "API Key (also reads from INKOS_LLM_API_KEY env)")
  277. .option("--base-url <url>", "Live /models probe baseUrl (for custom/newapi)")
  278. .option("--json", "Output as JSON")
  279. .action(async (service: string, opts: { apiKey?: string; baseUrl?: string; json?: boolean }) => {
  280. const apiKey = opts.apiKey ?? process.env.INKOS_LLM_API_KEY;
  281. const models = await listModelsForService(service, apiKey, opts.baseUrl);
  282. if (models.length === 0) {
  283. logError(`${service} 没有可用模型(可能需要 --api-key 和 --base-url)`);
  284. process.exit(1);
  285. }
  286. if (opts.json) {
  287. log(JSON.stringify(models, null, 2));
  288. return;
  289. }
  290. log(`${service}:${models.length} 个模型\n`);
  291. for (const m of models) {
  292. const maxOut = m.maxOutput ? `out=${m.maxOutput}` : "out=?";
  293. const ctx = m.contextWindow > 0 ? `ctx=${m.contextWindow}` : "ctx=?";
  294. log(` ${m.id.padEnd(42)} ${maxOut.padEnd(14)} ${ctx}`);
  295. }
  296. });