utils.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. import { readFile, access } from "node:fs/promises";
  2. import { homedir } from "node:os";
  3. import { join, resolve } from "node:path";
  4. import { config as loadEnv } from "dotenv";
  5. import { createLLMClient, StateManager, type ProjectConfig, ProjectConfigSchema, type PipelineConfig } from "@actalk/inkos-core";
  6. export const GLOBAL_CONFIG_DIR = join(homedir(), ".inkos");
  7. export const GLOBAL_ENV_PATH = join(GLOBAL_CONFIG_DIR, ".env");
  8. export async function resolveContext(opts: {
  9. readonly context?: string;
  10. readonly contextFile?: string;
  11. }): Promise<string | undefined> {
  12. if (opts.context) return opts.context;
  13. if (opts.contextFile) {
  14. return readFile(resolve(opts.contextFile), "utf-8");
  15. }
  16. // Read from stdin if piped (non-TTY)
  17. if (!process.stdin.isTTY) {
  18. const chunks: Buffer[] = [];
  19. for await (const chunk of process.stdin) {
  20. chunks.push(chunk as Buffer);
  21. }
  22. const text = Buffer.concat(chunks).toString("utf-8").trim();
  23. if (text.length > 0) return text;
  24. }
  25. return undefined;
  26. }
  27. export function findProjectRoot(): string {
  28. return process.cwd();
  29. }
  30. export async function loadConfig(): Promise<ProjectConfig> {
  31. const root = findProjectRoot();
  32. // Load global ~/.inkos/.env first, then project .env overrides
  33. loadEnv({ path: GLOBAL_ENV_PATH });
  34. loadEnv({ path: join(root, ".env"), override: true });
  35. const configPath = join(root, "inkos.json");
  36. // Step 1: Check file exists — give a clear message if not
  37. try {
  38. await access(configPath);
  39. } catch {
  40. throw new Error(
  41. `inkos.json not found in ${root}.\nMake sure you are inside an InkOS project directory (cd into the project created by 'inkos init').`,
  42. );
  43. }
  44. // Step 2: Read and parse — surface real errors instead of swallowing them
  45. const raw = await readFile(configPath, "utf-8");
  46. let config: Record<string, unknown>;
  47. try {
  48. config = JSON.parse(raw);
  49. } catch {
  50. throw new Error(`inkos.json in ${root} is not valid JSON. Check the file for syntax errors.`);
  51. }
  52. // .env overrides inkos.json for LLM settings
  53. const env = process.env;
  54. const llm = (config.llm ?? {}) as Record<string, unknown>;
  55. if (env.INKOS_LLM_PROVIDER) llm.provider = env.INKOS_LLM_PROVIDER;
  56. if (env.INKOS_LLM_BASE_URL) llm.baseUrl = env.INKOS_LLM_BASE_URL;
  57. if (env.INKOS_LLM_MODEL) llm.model = env.INKOS_LLM_MODEL;
  58. if (env.INKOS_LLM_TEMPERATURE) llm.temperature = parseFloat(env.INKOS_LLM_TEMPERATURE);
  59. if (env.INKOS_LLM_MAX_TOKENS) llm.maxTokens = parseInt(env.INKOS_LLM_MAX_TOKENS, 10);
  60. if (env.INKOS_LLM_THINKING_BUDGET) llm.thinkingBudget = parseInt(env.INKOS_LLM_THINKING_BUDGET, 10);
  61. if (env.INKOS_LLM_API_FORMAT) llm.apiFormat = env.INKOS_LLM_API_FORMAT;
  62. config.llm = llm;
  63. // API key ONLY from env — never stored in inkos.json
  64. const apiKey = env.INKOS_LLM_API_KEY;
  65. if (!apiKey) {
  66. throw new Error(
  67. "INKOS_LLM_API_KEY not set. Run 'inkos config set-global' or add it to project .env file.",
  68. );
  69. }
  70. llm.apiKey = apiKey;
  71. return ProjectConfigSchema.parse(config);
  72. }
  73. export function createClient(config: ProjectConfig) {
  74. return createLLMClient(config.llm);
  75. }
  76. export function buildPipelineConfig(
  77. config: ProjectConfig,
  78. root: string,
  79. extra?: Partial<Pick<PipelineConfig, "notifyChannels" | "radarSources" | "externalContext">>,
  80. ): PipelineConfig {
  81. return {
  82. client: createLLMClient(config.llm),
  83. model: config.llm.model,
  84. projectRoot: root,
  85. defaultLLMConfig: config.llm,
  86. modelOverrides: config.modelOverrides,
  87. notifyChannels: extra?.notifyChannels ?? config.notify,
  88. radarSources: extra?.radarSources,
  89. externalContext: extra?.externalContext,
  90. };
  91. }
  92. export function log(message: string): void {
  93. process.stdout.write(`${message}\n`);
  94. }
  95. export function logError(message: string): void {
  96. process.stderr.write(`[ERROR] ${message}\n`);
  97. }
  98. /**
  99. * Resolve book-id: if provided use it, otherwise auto-detect when exactly one book exists.
  100. * Validates that the book actually exists.
  101. */
  102. export async function resolveBookId(
  103. bookIdArg: string | undefined,
  104. root: string,
  105. ): Promise<string> {
  106. const state = new StateManager(root);
  107. const books = await state.listBooks();
  108. if (bookIdArg) {
  109. if (!books.includes(bookIdArg)) {
  110. const available = books.length > 0 ? books.join(", ") : "(none)";
  111. throw new Error(
  112. `Book "${bookIdArg}" not found. Available books: ${available}`,
  113. );
  114. }
  115. return bookIdArg;
  116. }
  117. if (books.length === 0) {
  118. throw new Error(
  119. "No books found. Create one first:\n inkos book create --title '...' --genre xuanhuan",
  120. );
  121. }
  122. if (books.length === 1) {
  123. return books[0]!;
  124. }
  125. throw new Error(
  126. `Multiple books found: ${books.join(", ")}\nPlease specify a book-id.`,
  127. );
  128. }