Просмотр исходного кода

feat(cli): add fanfic/studio commands, --lang flag, shared config refactor

- inkos fanfic init/show/refresh: fan fiction workflow commands
- inkos studio: launch web workbench
- book create/update --lang: language selection at book level
- init --lang: project default language
- config set-global --lang: global default language
- loadConfig refactored to use core's loadProjectConfig (DRY)
- computeAnalytics import from core (DRY)
Ma 7 месяцев назад
Родитель
Сommit
cea4bf33df

+ 1 - 1
packages/cli/src/__tests__/analytics.test.ts

@@ -1,5 +1,5 @@
 import { describe, it, expect } from "vitest";
 import { describe, it, expect } from "vitest";
-import { computeAnalytics } from "../commands/analytics.js";
+import { computeAnalytics } from "@actalk/inkos-core";
 
 
 describe("computeAnalytics", () => {
 describe("computeAnalytics", () => {
   it("returns zeros for empty chapters", () => {
   it("returns zeros for empty chapters", () => {

+ 1 - 115
packages/cli/src/commands/analytics.ts

@@ -1,121 +1,7 @@
 import { Command } from "commander";
 import { Command } from "commander";
-import { StateManager } from "@actalk/inkos-core";
+import { StateManager, computeAnalytics } from "@actalk/inkos-core";
 import { loadConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 import { loadConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 
 
-interface TokenStats {
-  readonly totalPromptTokens: number;
-  readonly totalCompletionTokens: number;
-  readonly totalTokens: number;
-  readonly avgTokensPerChapter: number;
-  readonly recentTrend: ReadonlyArray<{ readonly chapter: number; readonly totalTokens: number }>;
-}
-
-interface AnalyticsData {
-  readonly bookId: string;
-  readonly totalChapters: number;
-  readonly totalWords: number;
-  readonly avgWordsPerChapter: number;
-  readonly auditPassRate: number;
-  readonly topIssueCategories: ReadonlyArray<{ readonly category: string; readonly count: number }>;
-  readonly chaptersWithMostIssues: ReadonlyArray<{ readonly chapter: number; readonly issueCount: number }>;
-  readonly statusDistribution: Record<string, number>;
-  readonly tokenStats?: TokenStats;
-}
-
-export function computeAnalytics(
-  bookId: string,
-  chapters: ReadonlyArray<{
-    readonly number: number;
-    readonly status: string;
-    readonly wordCount: number;
-    readonly auditIssues: ReadonlyArray<string>;
-    readonly tokenUsage?: {
-      readonly promptTokens: number;
-      readonly completionTokens: number;
-      readonly totalTokens: number;
-    };
-  }>,
-): AnalyticsData {
-  const totalChapters = chapters.length;
-  const totalWords = chapters.reduce((sum, ch) => sum + ch.wordCount, 0);
-  const avgWordsPerChapter = totalChapters > 0 ? Math.round(totalWords / totalChapters) : 0;
-
-  // Audit pass rate: chapters that went to ready-for-review or approved on first try
-  const passedStatuses = new Set(["ready-for-review", "approved", "published"]);
-  const auditedChapters = chapters.filter(
-    (ch) => ch.status !== "drafted" && ch.status !== "drafting" && ch.status !== "card-generated",
-  );
-  const passedChapters = auditedChapters.filter((ch) => passedStatuses.has(ch.status));
-  const auditPassRate = auditedChapters.length > 0
-    ? Math.round((passedChapters.length / auditedChapters.length) * 100)
-    : 100;
-
-  // Issue category extraction
-  const categoryCounts = new Map<string, number>();
-  for (const ch of chapters) {
-    for (const issue of ch.auditIssues) {
-      // Issue format: "[severity] description"
-      // Try to extract category from the description
-      const catMatch = issue.match(/\[(?:critical|warning|info)\]\s*(.+?)[::]/);
-      const category = catMatch?.[1] ?? "未分类";
-      categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
-    }
-  }
-  const topIssueCategories = [...categoryCounts.entries()]
-    .sort((a, b) => b[1] - a[1])
-    .slice(0, 10)
-    .map(([category, count]) => ({ category, count }));
-
-  // Chapters with most issues
-  const chaptersWithMostIssues = [...chapters]
-    .filter((ch) => ch.auditIssues.length > 0)
-    .sort((a, b) => b.auditIssues.length - a.auditIssues.length)
-    .slice(0, 5)
-    .map((ch) => ({ chapter: ch.number, issueCount: ch.auditIssues.length }));
-
-  // Status distribution
-  const statusDistribution: Record<string, number> = {};
-  for (const ch of chapters) {
-    statusDistribution[ch.status] = (statusDistribution[ch.status] ?? 0) + 1;
-  }
-
-  // Token stats
-  const chaptersWithUsage = chapters.filter((ch) => ch.tokenUsage);
-  let tokenStats: TokenStats | undefined;
-  if (chaptersWithUsage.length > 0) {
-    const totalPromptTokens = chaptersWithUsage.reduce((sum, ch) => sum + (ch.tokenUsage?.promptTokens ?? 0), 0);
-    const totalCompletionTokens = chaptersWithUsage.reduce((sum, ch) => sum + (ch.tokenUsage?.completionTokens ?? 0), 0);
-    const totalTokens = chaptersWithUsage.reduce((sum, ch) => sum + (ch.tokenUsage?.totalTokens ?? 0), 0);
-    const avgTokensPerChapter = Math.round(totalTokens / chaptersWithUsage.length);
-
-    // Recent 5 chapters trend (sorted by chapter number)
-    const recentTrend = [...chaptersWithUsage]
-      .sort((a, b) => a.number - b.number)
-      .slice(-5)
-      .map((ch) => ({ chapter: ch.number, totalTokens: ch.tokenUsage?.totalTokens ?? 0 }));
-
-    tokenStats = {
-      totalPromptTokens,
-      totalCompletionTokens,
-      totalTokens,
-      avgTokensPerChapter,
-      recentTrend,
-    };
-  }
-
-  return {
-    bookId,
-    totalChapters,
-    totalWords,
-    avgWordsPerChapter,
-    auditPassRate,
-    topIssueCategories,
-    chaptersWithMostIssues,
-    statusDistribution,
-    tokenStats,
-  };
-}
-
 export const analyticsCommand = new Command("analytics")
 export const analyticsCommand = new Command("analytics")
   .alias("stats")
   .alias("stats")
   .description("Show analytics and token stats for a book")
   .description("Show analytics and token stats for a book")

+ 4 - 0
packages/cli/src/commands/book.ts

@@ -16,6 +16,7 @@ bookCommand
   .option("--target-chapters <n>", "Target chapter count", "200")
   .option("--target-chapters <n>", "Target chapter count", "200")
   .option("--chapter-words <n>", "Words per chapter", "3000")
   .option("--chapter-words <n>", "Words per chapter", "3000")
   .option("--brief <path>", "Path to creative brief file (.md/.txt) — Architect builds from your ideas instead of generating from scratch")
   .option("--brief <path>", "Path to creative brief file (.md/.txt) — Architect builds from your ideas instead of generating from scratch")
+  .option("--lang <language>", "Writing language: zh (Chinese) or en (English). Defaults from genre.")
   .option("--json", "Output JSON")
   .option("--json", "Output JSON")
   .action(async (opts) => {
   .action(async (opts) => {
     try {
     try {
@@ -37,6 +38,7 @@ bookCommand
         status: "outlining",
         status: "outlining",
         targetChapters: parseInt(opts.targetChapters, 10),
         targetChapters: parseInt(opts.targetChapters, 10),
         chapterWordCount: parseInt(opts.chapterWords, 10),
         chapterWordCount: parseInt(opts.chapterWords, 10),
+        language: opts.lang ?? config.language,
         createdAt: now,
         createdAt: now,
         updatedAt: now,
         updatedAt: now,
       };
       };
@@ -101,6 +103,7 @@ bookCommand
   .option("--chapter-words <n>", "Words per chapter")
   .option("--chapter-words <n>", "Words per chapter")
   .option("--target-chapters <n>", "Target chapter count")
   .option("--target-chapters <n>", "Target chapter count")
   .option("--status <status>", "Book status (outlining/active/paused/completed)")
   .option("--status <status>", "Book status (outlining/active/paused/completed)")
+  .option("--lang <language>", "Writing language: zh or en")
   .option("--json", "Output JSON")
   .option("--json", "Output JSON")
   .action(async (bookIdArg: string | undefined, opts) => {
   .action(async (bookIdArg: string | undefined, opts) => {
     try {
     try {
@@ -113,6 +116,7 @@ bookCommand
       if (opts.chapterWords) updates.chapterWordCount = parseInt(opts.chapterWords, 10);
       if (opts.chapterWords) updates.chapterWordCount = parseInt(opts.chapterWords, 10);
       if (opts.targetChapters) updates.targetChapters = parseInt(opts.targetChapters, 10);
       if (opts.targetChapters) updates.targetChapters = parseInt(opts.targetChapters, 10);
       if (opts.status) updates.status = opts.status;
       if (opts.status) updates.status = opts.status;
+      if (opts.lang) updates.language = opts.lang;
 
 
       if (Object.keys(updates).length === 0) {
       if (Object.keys(updates).length === 0) {
         if (opts.json) {
         if (opts.json) {

+ 2 - 0
packages/cli/src/commands/config.ts

@@ -93,6 +93,7 @@ configCommand
   .option("--max-tokens <n>", "Max output tokens")
   .option("--max-tokens <n>", "Max output tokens")
   .option("--thinking-budget <n>", "Anthropic thinking budget")
   .option("--thinking-budget <n>", "Anthropic thinking budget")
   .option("--api-format <format>", "API format (chat / responses)")
   .option("--api-format <format>", "API format (chat / responses)")
+  .option("--lang <language>", "Default writing language: zh (Chinese) or en (English)")
   .action(async (opts) => {
   .action(async (opts) => {
     try {
     try {
       await mkdir(GLOBAL_CONFIG_DIR, { recursive: true });
       await mkdir(GLOBAL_CONFIG_DIR, { recursive: true });
@@ -108,6 +109,7 @@ configCommand
       if (opts.maxTokens) lines.push(`INKOS_LLM_MAX_TOKENS=${opts.maxTokens}`);
       if (opts.maxTokens) lines.push(`INKOS_LLM_MAX_TOKENS=${opts.maxTokens}`);
       if (opts.thinkingBudget) lines.push(`INKOS_LLM_THINKING_BUDGET=${opts.thinkingBudget}`);
       if (opts.thinkingBudget) lines.push(`INKOS_LLM_THINKING_BUDGET=${opts.thinkingBudget}`);
       if (opts.apiFormat) lines.push(`INKOS_LLM_API_FORMAT=${opts.apiFormat}`);
       if (opts.apiFormat) lines.push(`INKOS_LLM_API_FORMAT=${opts.apiFormat}`);
+      if (opts.lang) lines.push(`INKOS_DEFAULT_LANGUAGE=${opts.lang}`);
 
 
       await writeFile(GLOBAL_ENV_PATH, lines.join("\n") + "\n", "utf-8");
       await writeFile(GLOBAL_ENV_PATH, lines.join("\n") + "\n", "utf-8");
       log(`Global config saved to ${GLOBAL_ENV_PATH}`);
       log(`Global config saved to ${GLOBAL_ENV_PATH}`);

+ 187 - 0
packages/cli/src/commands/fanfic.ts

@@ -0,0 +1,187 @@
+import { Command } from "commander";
+import { readFile, readdir, stat } from "node:fs/promises";
+import { join, resolve, basename } from "node:path";
+import { PipelineRunner, type BookConfig, type FanficMode } from "@actalk/inkos-core";
+import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
+
+export const fanficCommand = new Command("fanfic")
+  .description("Fan fiction writing tools (同人创作)");
+
+fanficCommand
+  .command("init")
+  .description("Create a fanfic book from external source material")
+  .requiredOption("--title <title>", "Book title")
+  .requiredOption("--from <path>", "Source file or directory (novel text, wiki, character docs)")
+  .option("--mode <mode>", "Fanfic mode: canon|au|ooc|cp", "canon")
+  .option("--genre <genre>", "Genre", "other")
+  .option("--platform <platform>", "Target platform", "other")
+  .option("--target-chapters <n>", "Target chapter count", "100")
+  .option("--chapter-words <n>", "Words per chapter", "3000")
+  .option("--lang <language>", "Writing language: zh or en. Defaults from genre.")
+  .option("--json", "Output JSON")
+  .action(async (opts) => {
+    try {
+      const config = await loadConfig();
+      const root = findProjectRoot();
+
+      const mode = opts.mode as FanficMode;
+      if (!["canon", "au", "ooc", "cp"].includes(mode)) {
+        throw new Error(`无效的同人模式:"${mode}"。可选:canon, au, ooc, cp`);
+      }
+
+      // Read source material
+      const sourcePath = resolve(opts.from);
+      const sourceText = await readSourceMaterial(sourcePath);
+      const sourceName = basename(sourcePath);
+
+      if (!sourceText || sourceText.length < 100) {
+        throw new Error(`源素材文件内容过短(${sourceText.length} 字符)。请提供至少 100 字符的原作素材。`);
+      }
+
+      const bookId = opts.title
+        .toLowerCase()
+        .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
+        .replace(/-+/g, "-")
+        .slice(0, 30);
+
+      const now = new Date().toISOString();
+      const book: BookConfig = {
+        id: bookId,
+        title: opts.title,
+        platform: opts.platform,
+        genre: opts.genre,
+        status: "outlining",
+        targetChapters: parseInt(opts.targetChapters, 10),
+        chapterWordCount: parseInt(opts.chapterWords, 10),
+        language: opts.lang ?? config.language,
+        createdAt: now,
+        updatedAt: now,
+        fanficMode: mode,
+      };
+
+      if (!opts.json) log(`Creating fanfic "${book.title}" (${mode} mode, ${book.genre})...`);
+      if (!opts.json) log(`  Source: ${sourceName} (${sourceText.length} chars)`);
+
+      const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
+      await pipeline.initFanficBook(book, sourceText, sourceName, mode);
+
+      if (opts.json) {
+        log(JSON.stringify({
+          bookId,
+          title: book.title,
+          genre: book.genre,
+          fanficMode: mode,
+          source: sourceName,
+          location: `books/${bookId}/`,
+          nextStep: `inkos write next ${bookId}`,
+        }, null, 2));
+      } else {
+        log(`Fanfic created: ${bookId}`);
+        log(`  Mode: ${mode}`);
+        log(`  Location: books/${bookId}/`);
+        log(`  fanfic_canon.md + foundation generated.`);
+        log("");
+        log(`Next: inkos write next ${bookId}`);
+      }
+    } catch (e) {
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Failed to create fanfic: ${e}`);
+      }
+      process.exit(1);
+    }
+  });
+
+fanficCommand
+  .command("show")
+  .description("Display parsed fanfic canon")
+  .argument("[book-id]", "Book ID (auto-detected if only one book)")
+  .option("--json", "Output JSON")
+  .action(async (bookIdArg: string | undefined, opts) => {
+    try {
+      await loadConfig();
+      const root = findProjectRoot();
+      const bookId = await resolveBookId(bookIdArg, root);
+      const { StateManager } = await import("@actalk/inkos-core");
+      const state = new StateManager(root);
+      const bookDir = state.bookDir(bookId);
+
+      let canon: string;
+      try {
+        canon = await readFile(join(bookDir, "story/fanfic_canon.md"), "utf-8");
+      } catch {
+        throw new Error(`该书没有同人正典文件。用 inkos fanfic init 创建同人书。`);
+      }
+
+      if (opts.json) {
+        log(JSON.stringify({ bookId, fanficCanon: canon }, null, 2));
+      } else {
+        log(`Fanfic Canon for "${bookId}":\n`);
+        log(canon);
+      }
+    } catch (e) {
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(String(e));
+      }
+      process.exit(1);
+    }
+  });
+
+fanficCommand
+  .command("refresh")
+  .description("Re-import source material and regenerate fanfic canon")
+  .argument("[book-id]", "Book ID (auto-detected if only one book)")
+  .requiredOption("--from <path>", "Source file or directory")
+  .option("--json", "Output JSON")
+  .action(async (bookIdArg: string | undefined, opts) => {
+    try {
+      const config = await loadConfig();
+      const root = findProjectRoot();
+      const bookId = await resolveBookId(bookIdArg, root);
+      const { StateManager } = await import("@actalk/inkos-core");
+      const state = new StateManager(root);
+      const book = await state.loadBookConfig(bookId);
+
+      const mode = (book.fanficMode ?? "canon") as FanficMode;
+      const sourcePath = resolve(opts.from);
+      const sourceText = await readSourceMaterial(sourcePath);
+      const sourceName = basename(sourcePath);
+
+      if (!opts.json) log(`Refreshing fanfic canon for "${bookId}" from ${sourceName}...`);
+
+      const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
+      await pipeline.importFanficCanon(bookId, sourceText, sourceName, mode);
+
+      if (opts.json) {
+        log(JSON.stringify({ bookId, source: sourceName, refreshedAt: new Date().toISOString() }));
+      } else {
+        log(`Canon refreshed from "${sourceName}".`);
+      }
+    } catch (e) {
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Failed to refresh canon: ${e}`);
+      }
+      process.exit(1);
+    }
+  });
+
+async function readSourceMaterial(sourcePath: string): Promise<string> {
+  const s = await stat(sourcePath);
+  if (s.isDirectory()) {
+    const files = await readdir(sourcePath);
+    const textFiles = files.filter((f) => f.endsWith(".txt") || f.endsWith(".md"));
+    if (textFiles.length === 0) {
+      throw new Error(`目录 ${sourcePath} 中没有 .txt 或 .md 文件。`);
+    }
+    const contents = await Promise.all(
+      textFiles.sort().map((f) => readFile(join(sourcePath, f), "utf-8")),
+    );
+    return contents.join("\n\n---\n\n");
+  }
+  return readFile(sourcePath, "utf-8");
+}

+ 3 - 1
packages/cli/src/commands/init.ts

@@ -15,7 +15,8 @@ async function hasGlobalConfig(): Promise<boolean> {
 export const initCommand = new Command("init")
 export const initCommand = new Command("init")
   .description("Initialize an InkOS project (current directory by default)")
   .description("Initialize an InkOS project (current directory by default)")
   .argument("[name]", "Project name (creates subdirectory). Omit to init current directory.")
   .argument("[name]", "Project name (creates subdirectory). Omit to init current directory.")
-  .action(async (name?: string) => {
+  .option("--lang <language>", "Default writing language: zh (Chinese) or en (English)", "zh")
+  .action(async (name: string | undefined, opts: { lang?: string }) => {
     const projectDir = name ? join(process.cwd(), name) : process.cwd();
     const projectDir = name ? join(process.cwd(), name) : process.cwd();
     const projectName = name ?? basename(projectDir);
     const projectName = name ?? basename(projectDir);
 
 
@@ -38,6 +39,7 @@ export const initCommand = new Command("init")
       const config = {
       const config = {
         name: projectName,
         name: projectName,
         version: "0.1.0",
         version: "0.1.0",
+        language: opts.lang ?? "zh",
         llm: {
         llm: {
           provider: process.env.INKOS_LLM_PROVIDER ?? "openai",
           provider: process.env.INKOS_LLM_PROVIDER ?? "openai",
           baseUrl: process.env.INKOS_LLM_BASE_URL ?? "https://api.openai.com/v1",
           baseUrl: process.env.INKOS_LLM_BASE_URL ?? "https://api.openai.com/v1",

+ 66 - 0
packages/cli/src/commands/studio.ts

@@ -0,0 +1,66 @@
+import { Command } from "commander";
+import { findProjectRoot, log, logError } from "../utils.js";
+import { spawn } from "node:child_process";
+import { join } from "node:path";
+import { access } from "node:fs/promises";
+
+export const studioCommand = new Command("studio")
+  .description("Start InkOS Studio web workbench")
+  .option("-p, --port <port>", "Server port", "4567")
+  .action(async (opts) => {
+    const root = findProjectRoot();
+    const port = opts.port;
+
+    // Look for studio's built server entry
+    const studioPaths = [
+      join(root, "node_modules", "@actalk", "inkos-studio", "dist", "api", "index.js"),
+      join(root, "..", "studio", "src", "api", "index.ts"),
+    ];
+
+    // Try to find tsx or ts-node for running TypeScript
+    // In dev (monorepo), run studio's TS source directly via tsx
+    const studioDir = join(root, "..", "studio");
+    let studioEntry: string | undefined;
+
+    try {
+      await access(join(studioDir, "src", "api", "index.ts"));
+      studioEntry = join(studioDir, "src", "api", "index.ts");
+    } catch {
+      // Not in monorepo — look for built JS
+      for (const p of studioPaths) {
+        try {
+          await access(p);
+          studioEntry = p;
+          break;
+        } catch {
+          // continue
+        }
+      }
+    }
+
+    if (!studioEntry) {
+      logError(
+        "InkOS Studio not found. If you cloned the repo, run:\n" +
+        "  cd packages/studio && pnpm install && pnpm build\n" +
+        "Then run 'inkos studio' from the project root.",
+      );
+      process.exit(1);
+    }
+
+    log(`Starting InkOS Studio on http://localhost:${port}`);
+
+    const child = spawn("npx", ["tsx", studioEntry], {
+      cwd: root,
+      stdio: "inherit",
+      env: { ...process.env, INKOS_STUDIO_PORT: port },
+    });
+
+    child.on("error", (e) => {
+      logError(`Failed to start studio: ${e.message}`);
+      process.exit(1);
+    });
+
+    child.on("exit", (code) => {
+      process.exit(code ?? 0);
+    });
+  });

+ 4 - 0
packages/cli/src/index.ts

@@ -22,6 +22,8 @@ import { detectCommand } from "./commands/detect.js";
 import { styleCommand } from "./commands/style.js";
 import { styleCommand } from "./commands/style.js";
 import { analyticsCommand } from "./commands/analytics.js";
 import { analyticsCommand } from "./commands/analytics.js";
 import { importCommand } from "./commands/import.js";
 import { importCommand } from "./commands/import.js";
+import { fanficCommand } from "./commands/fanfic.js";
+import { studioCommand } from "./commands/studio.js";
 
 
 const require = createRequire(import.meta.url);
 const require = createRequire(import.meta.url);
 const { version } = require("../package.json") as { version: string };
 const { version } = require("../package.json") as { version: string };
@@ -54,5 +56,7 @@ program.addCommand(detectCommand);
 program.addCommand(styleCommand);
 program.addCommand(styleCommand);
 program.addCommand(analyticsCommand);
 program.addCommand(analyticsCommand);
 program.addCommand(importCommand);
 program.addCommand(importCommand);
+program.addCommand(fanficCommand);
+program.addCommand(studioCommand);
 
 
 program.parse();
 program.parse();

+ 5 - 56
packages/cli/src/utils.ts

@@ -1,11 +1,8 @@
-import { readFile, access } from "node:fs/promises";
-import { homedir } from "node:os";
-import { join, resolve } from "node:path";
-import { config as loadEnv } from "dotenv";
-import { createLLMClient, StateManager, createLogger, createStderrSink, createJsonLineSink, type ProjectConfig, ProjectConfigSchema, type PipelineConfig, type LogSink } from "@actalk/inkos-core";
+import { readFile } from "node:fs/promises";
+import { resolve } from "node:path";
+import { createLLMClient, StateManager, createLogger, createStderrSink, createJsonLineSink, loadProjectConfig, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH, type ProjectConfig, type PipelineConfig, type LogSink } from "@actalk/inkos-core";
 
 
-export const GLOBAL_CONFIG_DIR = join(homedir(), ".inkos");
-export const GLOBAL_ENV_PATH = join(GLOBAL_CONFIG_DIR, ".env");
+export { GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH };
 
 
 export async function resolveContext(opts: {
 export async function resolveContext(opts: {
   readonly context?: string;
   readonly context?: string;
@@ -32,55 +29,7 @@ export function findProjectRoot(): string {
 }
 }
 
 
 export async function loadConfig(): Promise<ProjectConfig> {
 export async function loadConfig(): Promise<ProjectConfig> {
-  const root = findProjectRoot();
-
-  // Load global ~/.inkos/.env first, then project .env overrides
-  loadEnv({ path: GLOBAL_ENV_PATH });
-  loadEnv({ path: join(root, ".env"), override: true });
-
-  const configPath = join(root, "inkos.json");
-
-  // Step 1: Check file exists — give a clear message if not
-  try {
-    await access(configPath);
-  } catch {
-    throw new Error(
-      `inkos.json not found in ${root}.\nMake sure you are inside an InkOS project directory (cd into the project created by 'inkos init').`,
-    );
-  }
-
-  // Step 2: Read and parse — surface real errors instead of swallowing them
-  const raw = await readFile(configPath, "utf-8");
-
-  let config: Record<string, unknown>;
-  try {
-    config = JSON.parse(raw);
-  } catch {
-    throw new Error(`inkos.json in ${root} is not valid JSON. Check the file for syntax errors.`);
-  }
-
-  // .env overrides inkos.json for LLM settings
-  const env = process.env;
-  const llm = (config.llm ?? {}) as Record<string, unknown>;
-  if (env.INKOS_LLM_PROVIDER) llm.provider = env.INKOS_LLM_PROVIDER;
-  if (env.INKOS_LLM_BASE_URL) llm.baseUrl = env.INKOS_LLM_BASE_URL;
-  if (env.INKOS_LLM_MODEL) llm.model = env.INKOS_LLM_MODEL;
-  if (env.INKOS_LLM_TEMPERATURE) llm.temperature = parseFloat(env.INKOS_LLM_TEMPERATURE);
-  if (env.INKOS_LLM_MAX_TOKENS) llm.maxTokens = parseInt(env.INKOS_LLM_MAX_TOKENS, 10);
-  if (env.INKOS_LLM_THINKING_BUDGET) llm.thinkingBudget = parseInt(env.INKOS_LLM_THINKING_BUDGET, 10);
-  if (env.INKOS_LLM_API_FORMAT) llm.apiFormat = env.INKOS_LLM_API_FORMAT;
-  config.llm = llm;
-
-  // API key ONLY from env — never stored in inkos.json
-  const apiKey = env.INKOS_LLM_API_KEY;
-  if (!apiKey) {
-    throw new Error(
-      "INKOS_LLM_API_KEY not set. Run 'inkos config set-global' or add it to project .env file.",
-    );
-  }
-  llm.apiKey = apiKey;
-
-  return ProjectConfigSchema.parse(config);
+  return loadProjectConfig(findProjectRoot());
 }
 }
 
 
 export function createClient(config: ProjectConfig) {
 export function createClient(config: ProjectConfig) {