فهرست منبع

feat: global LLM config (~/.inkos/.env)

- Add 'inkos config set-global' to set LLM config once for all projects
- loadConfig() loads ~/.inkos/.env first, project .env overrides
- init detects global config: skips .env boilerplate, shows simpler next steps
- doctor checks global config status + API connectivity
- Update README/README.en/LinuxDo with global config usage
majx_mac 8 ماه پیش
والد
کامیت
fccc44aa81
7فایلهای تغییر یافته به همراه204 افزوده شده و 62 حذف شده
  1. 22 5
      README.en.md
  2. 21 5
      README.md
  3. 9 13
      linuxdo-v0.3.0.md
  4. 55 3
      packages/cli/src/commands/config.ts
  5. 19 4
      packages/cli/src/commands/doctor.ts
  6. 70 29
      packages/cli/src/commands/init.ts
  7. 8 3
      packages/cli/src/utils.ts

+ 22 - 5
README.en.md

@@ -204,18 +204,31 @@ npm i -g @actalk/inkos
 
 ### Configure
 
+**Option 1: Global config (recommended, one-time setup)**
+
 ```bash
-inkos init              # Initialize project, generates inkos.json + .env template
+inkos config set-global \
+  --provider openai \
+  --base-url https://api.openai.com/v1 \
+  --api-key sk-xxx \
+  --model gpt-4o
 ```
 
-Edit `.env`:
+Saved to `~/.inkos/.env`, shared by all projects. New projects just work without extra config.
+
+**Option 2: Per-project `.env`**
+
+```bash
+inkos init my-novel     # Initialize project
+# Edit my-novel/.env
+```
 
 ```bash
 # Required
 INKOS_LLM_PROVIDER=openai                        # openai / anthropic
 INKOS_LLM_BASE_URL=https://api.openai.com/v1     # API endpoint (proxy-friendly)
 INKOS_LLM_API_KEY=sk-xxx                          # API Key
-INKOS_LLM_MODEL=gpt-5.4                            # Model name
+INKOS_LLM_MODEL=gpt-4o                            # Model name
 
 # Optional
 # INKOS_LLM_TEMPERATURE=0.7                       # Temperature
@@ -223,6 +236,8 @@ INKOS_LLM_MODEL=gpt-5.4                            # Model name
 # INKOS_LLM_THINKING_BUDGET=0                      # Anthropic extended thinking budget
 ```
 
+Project `.env` overrides global config. Skip it if no override needed.
+
 ### Usage
 
 ```bash
@@ -257,8 +272,10 @@ inkos up                          # Daemon mode
 | `inkos status` | Project status |
 | `inkos export <id>` | Export book to txt/md |
 | `inkos radar scan` | Scan platform trends |
-| `inkos config set/show` | View/update config |
-| `inkos doctor` | Diagnose setup issues |
+| `inkos config set-global` | Set global LLM config (~/.inkos/.env) |
+| `inkos config show-global` | Show global config |
+| `inkos config set/show` | View/update project config |
+| `inkos doctor` | Diagnose setup issues (includes API connectivity test) |
 | `inkos up / down` | Start/stop daemon |
 
 All commands support `--json` for structured output. `draft`/`write next`/`book create` support `--context` for writing guidance.

+ 21 - 5
README.md

@@ -204,18 +204,31 @@ npm i -g @actalk/inkos
 
 ### 配置
 
+**方式一:全局配置(推荐,只需一次)**
+
 ```bash
-inkos init              # 初始化项目,生成 inkos.json + .env 模板
+inkos config set-global \
+  --provider openai \
+  --base-url https://api.openai.com/v1 \
+  --api-key sk-xxx \
+  --model gpt-4o
 ```
 
-编辑 `.env`:
+配置保存在 `~/.inkos/.env`,所有项目共享。之后新建项目不用再配。
+
+**方式二:项目级 `.env`**
+
+```bash
+inkos init my-novel     # 初始化项目
+# 编辑 my-novel/.env
+```
 
 ```bash
 # 必填
 INKOS_LLM_PROVIDER=openai                        # openai / anthropic
 INKOS_LLM_BASE_URL=https://api.openai.com/v1     # API 地址(支持中转站)
 INKOS_LLM_API_KEY=sk-xxx                          # API Key
-INKOS_LLM_MODEL=gpt-5.4                            # 模型名
+INKOS_LLM_MODEL=gpt-4o                            # 模型名
 
 # 可选
 # INKOS_LLM_TEMPERATURE=0.7                       # 温度
@@ -223,6 +236,7 @@ INKOS_LLM_MODEL=gpt-5.4                            # 模型名
 # INKOS_LLM_THINKING_BUDGET=0                      # Anthropic 扩展思考预算
 ```
 
+项目 `.env` 会覆盖全局配置。不需要覆盖时可以不写。
 
 ### 使用
 
@@ -258,8 +272,10 @@ inkos up                       # 守护进程模式
 | `inkos status` | 项目状态 |
 | `inkos export <id>` | 导出书籍为 txt/md |
 | `inkos radar scan` | 扫描平台趋势 |
-| `inkos config set/show` | 查看/更新配置 |
-| `inkos doctor` | 诊断配置问题 |
+| `inkos config set-global` | 设置全局 LLM 配置(~/.inkos/.env) |
+| `inkos config show-global` | 查看全局配置 |
+| `inkos config set/show` | 查看/更新项目配置 |
+| `inkos doctor` | 诊断配置问题(含 API 连通性测试) |
 | `inkos up / down` | 启动/停止守护进程 |
 
 所有命令支持 `--json` 输出结构化数据,`draft`/`write next`/`book create` 支持 `--context` 传入创作指导。

+ 9 - 13
linuxdo-v0.3.0.md

@@ -85,23 +85,19 @@ OOC检查、时间线、设定冲突、伏笔、节奏、文风、信息越界
 
 ## 配置
 
-所有 LLM 配置都在 `.env` 里(不在 inkos.json,避免 key 泄漏):
+全局配一次,所有项目共享:
 
 ```bash
-# 必填
-INKOS_LLM_PROVIDER=openai                        # openai / anthropic
-INKOS_LLM_BASE_URL=https://api.openai.com/v1     # API 地址(支持中转站)
-INKOS_LLM_API_KEY=sk-xxx                          # API Key
-INKOS_LLM_MODEL=gpt-4o                            # 模型名
-
-# 可选
-# INKOS_LLM_TEMPERATURE=0.7
-# INKOS_LLM_MAX_TOKENS=8192
-# INKOS_LLM_THINKING_BUDGET=0                      # Anthropic 扩展思考预算
-# INKOS_LLM_API_FORMAT=chat                        # chat(默认)或 responses(OpenAI Responses API)
+inkos config set-global \
+  --provider openai \
+  --base-url https://api.openai.com/v1 \
+  --api-key sk-xxx \
+  --model gpt-4o
 ```
 
-OpenAI 兼容中转站只需改 `BASE_URL`。Anthropic 直连把 `PROVIDER` 改成 `anthropic`,`BASE_URL` 改成 `https://api.anthropic.com`。
+配置存在 `~/.inkos/.env`,之后新建项目不用再配。想给某个项目用不同模型,在项目 `.env` 里覆盖就行。
+
+支持 OpenAI / Anthropic / 所有 OpenAI 兼容中转站。中转站只需改 `--base-url`。
 
 ## 其他
 

+ 55 - 3
packages/cli/src/commands/config.ts

@@ -1,7 +1,7 @@
 import { Command } from "commander";
-import { readFile, writeFile } from "node:fs/promises";
+import { readFile, writeFile, mkdir } from "node:fs/promises";
 import { join } from "node:path";
-import { findProjectRoot, log, logError } from "../utils.js";
+import { findProjectRoot, log, logError, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH } from "../utils.js";
 
 export const configCommand = new Command("config")
   .description("Manage project configuration");
@@ -38,9 +38,61 @@ configCommand
     }
   });
 
+configCommand
+  .command("set-global")
+  .description("Set global LLM config (~/.inkos/.env), shared by all projects")
+  .requiredOption("--provider <provider>", "LLM provider (openai / anthropic)")
+  .requiredOption("--base-url <url>", "API base URL")
+  .requiredOption("--api-key <key>", "API key")
+  .requiredOption("--model <model>", "Model name")
+  .option("--temperature <n>", "Temperature")
+  .option("--max-tokens <n>", "Max output tokens")
+  .option("--thinking-budget <n>", "Anthropic thinking budget")
+  .option("--api-format <format>", "API format (chat / responses)")
+  .action(async (opts) => {
+    try {
+      await mkdir(GLOBAL_CONFIG_DIR, { recursive: true });
+
+      const lines = [
+        "# InkOS Global LLM Configuration",
+        `INKOS_LLM_PROVIDER=${opts.provider}`,
+        `INKOS_LLM_BASE_URL=${opts.baseUrl}`,
+        `INKOS_LLM_API_KEY=${opts.apiKey}`,
+        `INKOS_LLM_MODEL=${opts.model}`,
+      ];
+      if (opts.temperature) lines.push(`INKOS_LLM_TEMPERATURE=${opts.temperature}`);
+      if (opts.maxTokens) lines.push(`INKOS_LLM_MAX_TOKENS=${opts.maxTokens}`);
+      if (opts.thinkingBudget) lines.push(`INKOS_LLM_THINKING_BUDGET=${opts.thinkingBudget}`);
+      if (opts.apiFormat) lines.push(`INKOS_LLM_API_FORMAT=${opts.apiFormat}`);
+
+      await writeFile(GLOBAL_ENV_PATH, lines.join("\n") + "\n", "utf-8");
+      log(`Global config saved to ${GLOBAL_ENV_PATH}`);
+      log("All projects will use this config unless overridden by project .env");
+    } catch (e) {
+      logError(`Failed to set global config: ${e}`);
+      process.exit(1);
+    }
+  });
+
+configCommand
+  .command("show-global")
+  .description("Show global LLM config (~/.inkos/.env)")
+  .action(async () => {
+    try {
+      const content = await readFile(GLOBAL_ENV_PATH, "utf-8");
+      const masked = content.replace(
+        /(INKOS_LLM_API_KEY=)(.{8})(.*)(.{4})/,
+        "$1$2...$4",
+      );
+      log(masked);
+    } catch {
+      log("No global config found. Run 'inkos config set-global' to create one.");
+    }
+  });
+
 configCommand
   .command("show")
-  .description("Show current configuration")
+  .description("Show current project configuration")
   .action(async () => {
     const root = findProjectRoot();
     const configPath = join(root, "inkos.json");

+ 19 - 4
packages/cli/src/commands/doctor.ts

@@ -1,7 +1,7 @@
 import { Command } from "commander";
 import { readFile } from "node:fs/promises";
 import { join } from "node:path";
-import { findProjectRoot, log, logError } from "../utils.js";
+import { findProjectRoot, log, logError, GLOBAL_ENV_PATH } from "../utils.js";
 
 export const doctorCommand = new Command("doctor")
   .description("Check environment and project health")
@@ -34,16 +34,31 @@ export const doctorCommand = new Command("doctor")
       checks.push({ name: ".env", ok: false, detail: "Not found" });
     }
 
-    // 4. Check LLM API key (from .env only)
+    // 4. Check global config
+    {
+      let hasGlobal = false;
+      try {
+        const globalContent = await readFile(GLOBAL_ENV_PATH, "utf-8");
+        hasGlobal = globalContent.includes("INKOS_LLM_API_KEY=") && !globalContent.includes("your-api-key-here");
+      } catch { /* no global config */ }
+      checks.push({
+        name: "Global Config",
+        ok: hasGlobal,
+        detail: hasGlobal ? `Found (${GLOBAL_ENV_PATH})` : "Not set. Run 'inkos config set-global'",
+      });
+    }
+
+    // 5. Check LLM API key (global + project .env)
     {
       const { config: loadDotenv } = await import("dotenv");
-      loadDotenv({ path: join(root, ".env") });
+      loadDotenv({ path: GLOBAL_ENV_PATH });
+      loadDotenv({ path: join(root, ".env"), override: true });
       const apiKey = process.env.INKOS_LLM_API_KEY;
       const hasKey = !!apiKey && apiKey.length > 10 && apiKey !== "your-api-key-here";
       checks.push({
         name: "LLM API Key",
         ok: hasKey,
-        detail: hasKey ? "Configured (from .env)" : "Missing in .env — set INKOS_LLM_API_KEY",
+        detail: hasKey ? "Configured" : "Missing — run 'inkos config set-global' or add to project .env",
       });
     }
 

+ 70 - 29
packages/cli/src/commands/init.ts

@@ -1,7 +1,16 @@
 import { Command } from "commander";
-import { writeFile, mkdir } from "node:fs/promises";
+import { readFile, writeFile, mkdir } from "node:fs/promises";
 import { join } from "node:path";
-import { log, logError } from "../utils.js";
+import { log, logError, GLOBAL_ENV_PATH } from "../utils.js";
+
+async function hasGlobalConfig(): Promise<boolean> {
+  try {
+    const content = await readFile(GLOBAL_ENV_PATH, "utf-8");
+    return content.includes("INKOS_LLM_API_KEY=") && !content.includes("your-api-key-here");
+  } catch {
+    return false;
+  }
+}
 
 export const initCommand = new Command("init")
   .description("Initialize a new InkOS project")
@@ -39,29 +48,50 @@ export const initCommand = new Command("init")
         "utf-8",
       );
 
-      await writeFile(
-        join(projectDir, ".env"),
-        [
-          "# LLM Configuration",
-          "# Provider: openai (OpenAI / compatible proxy), anthropic (Anthropic native)",
-          "INKOS_LLM_PROVIDER=openai",
-          "INKOS_LLM_BASE_URL=https://api.openai.com/v1",
-          "INKOS_LLM_API_KEY=your-api-key-here",
-          "INKOS_LLM_MODEL=gpt-4o",
-          "",
-          "# Optional parameters (defaults shown):",
-          "# INKOS_LLM_TEMPERATURE=0.7",
-          "# INKOS_LLM_MAX_TOKENS=8192",
-          "# INKOS_LLM_THINKING_BUDGET=0          # Anthropic extended thinking budget",
-          "# INKOS_LLM_API_FORMAT=chat             # chat (default) or responses (OpenAI Responses API)",
-          "",
-          "# Anthropic example:",
-          "# INKOS_LLM_PROVIDER=anthropic",
-          "# INKOS_LLM_BASE_URL=https://api.anthropic.com",
-          "# INKOS_LLM_MODEL=claude-sonnet-4-5-20250514",
-        ].join("\n"),
-        "utf-8",
-      );
+      const global = await hasGlobalConfig();
+
+      if (global) {
+        // Global config exists — project .env only for overrides
+        await writeFile(
+          join(projectDir, ".env"),
+          [
+            "# Project-level LLM overrides (optional)",
+            "# Global config at ~/.inkos/.env will be used by default.",
+            "# Uncomment below to override for this project only:",
+            "# INKOS_LLM_PROVIDER=openai",
+            "# INKOS_LLM_BASE_URL=https://api.openai.com/v1",
+            "# INKOS_LLM_API_KEY=your-api-key-here",
+            "# INKOS_LLM_MODEL=gpt-4o",
+          ].join("\n"),
+          "utf-8",
+        );
+      } else {
+        // No global config — full .env template
+        await writeFile(
+          join(projectDir, ".env"),
+          [
+            "# LLM Configuration",
+            "# Tip: Run 'inkos config set-global' to set once for all projects.",
+            "# Provider: openai (OpenAI / compatible proxy), anthropic (Anthropic native)",
+            "INKOS_LLM_PROVIDER=openai",
+            "INKOS_LLM_BASE_URL=https://api.openai.com/v1",
+            "INKOS_LLM_API_KEY=your-api-key-here",
+            "INKOS_LLM_MODEL=gpt-4o",
+            "",
+            "# Optional parameters (defaults shown):",
+            "# INKOS_LLM_TEMPERATURE=0.7",
+            "# INKOS_LLM_MAX_TOKENS=8192",
+            "# INKOS_LLM_THINKING_BUDGET=0          # Anthropic extended thinking budget",
+            "# INKOS_LLM_API_FORMAT=chat             # chat (default) or responses (OpenAI Responses API)",
+            "",
+            "# Anthropic example:",
+            "# INKOS_LLM_PROVIDER=anthropic",
+            "# INKOS_LLM_BASE_URL=https://api.anthropic.com",
+            "# INKOS_LLM_MODEL=claude-sonnet-4-5-20250514",
+          ].join("\n"),
+          "utf-8",
+        );
+      }
 
       await writeFile(
         join(projectDir, ".gitignore"),
@@ -71,10 +101,21 @@ export const initCommand = new Command("init")
 
       log(`Project initialized at ${projectDir}`);
       log("");
-      log("Next steps:");
-      log(`  cd ${name}`);
-      log("  # Edit .env with your LLM API credentials");
-      log("  inkos book create --title '我的小说' --genre xuanhuan --platform tomato");
+      if (global) {
+        log("Global LLM config detected. Ready to go!");
+        log("");
+        log("Next steps:");
+        log(`  cd ${name}`);
+        log("  inkos book create --title '我的小说' --genre xuanhuan --platform tomato");
+      } else {
+        log("Next steps:");
+        log(`  cd ${name}`);
+        log("  # Option 1: Set global config (recommended, one-time):");
+        log("  inkos config set-global --provider openai --base-url https://api.openai.com/v1 --api-key sk-xxx --model gpt-4o");
+        log("  # Option 2: Edit .env for this project only");
+        log("");
+        log("  inkos book create --title '我的小说' --genre xuanhuan --platform tomato");
+      }
       log("  inkos write next <book-id>");
     } catch (e) {
       logError(`Failed to initialize project: ${e}`);

+ 8 - 3
packages/cli/src/utils.ts

@@ -1,8 +1,12 @@
 import { readFile } from "node:fs/promises";
+import { homedir } from "node:os";
 import { join, resolve } from "node:path";
 import { config as loadEnv } from "dotenv";
 import { createLLMClient, type ProjectConfig, ProjectConfigSchema } from "@actalk/inkos-core";
 
+export const GLOBAL_CONFIG_DIR = join(homedir(), ".inkos");
+export const GLOBAL_ENV_PATH = join(GLOBAL_CONFIG_DIR, ".env");
+
 export async function resolveContext(opts: {
   readonly context?: string;
   readonly contextFile?: string;
@@ -30,8 +34,9 @@ export function findProjectRoot(): string {
 export async function loadConfig(): Promise<ProjectConfig> {
   const root = findProjectRoot();
 
-  // Load .env from project root
-  loadEnv({ path: join(root, ".env") });
+  // 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");
   try {
@@ -52,7 +57,7 @@ export async function loadConfig(): Promise<ProjectConfig> {
     const apiKey = env.INKOS_LLM_API_KEY;
     if (!apiKey) {
       throw new Error(
-        "INKOS_LLM_API_KEY not set. Add it to your .env file.",
+        "INKOS_LLM_API_KEY not set. Run 'inkos config set-global' or add it to project .env file.",
       );
     }
     config.llm.apiKey = apiKey;