Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
# InkOS Environment Configuration
# Copy to .env and fill in your values

# LLM Provider (openai, anthropic, custom)
# LLM Configuration
# Provider: openai / anthropic / custom
INKOS_LLM_PROVIDER=openai

# API Base URL (OpenAI-compatible endpoint)
INKOS_LLM_BASE_URL=https://api.openai.com/v1

# API Key
INKOS_LLM_API_KEY=sk-your-key-here
INKOS_LLM_API_KEY=your-api-key-here

# Model name
INKOS_LLM_MODEL=gpt-4o
Expand Down
31 changes: 31 additions & 0 deletions packages/cli/src/__tests__/cli-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ describe("CLI integration", () => {
expect(envContent).toContain("INKOS_LLM_API_KEY");
});

it("includes optional env guidance without duplicate anthropic examples", async () => {
const envContent = await readFile(join(projectDir, ".env"), "utf-8");
expect(envContent).toContain("# INKOS_LLM_API_FORMAT=chat");
expect(envContent).toContain("# INKOS_LLM_API_KEY=");
expect(envContent.match(/# INKOS_LLM_PROVIDER=anthropic/g) ?? []).toHaveLength(1);
});

it("creates .gitignore", async () => {
const gitignore = await readFile(join(projectDir, ".gitignore"), "utf-8");
expect(gitignore).toContain(".env");
Expand Down Expand Up @@ -485,6 +492,30 @@ describe("CLI integration", () => {
}
});

it("treats placeholder API keys as missing config", async () => {
await stat(join(projectDir, "inkos.json")).catch(() => {
run(["init"]);
});
const envPath = join(projectDir, ".env");
const originalEnv = await readFile(envPath, "utf-8");

try {
await writeFile(envPath, [
"INKOS_LLM_PROVIDER=openai",
"INKOS_LLM_BASE_URL=https://api.openai.com/v1",
"INKOS_LLM_MODEL=gpt-5.4",
"INKOS_LLM_API_KEY=your-api-key-here",
"",
].join("\n"), "utf-8");

const { stdout } = runStderr(["doctor"]);
expect(stdout).toContain("LLM API Key: Missing");
expect(stdout).toContain("No LLM config available");
} finally {
await writeFile(envPath, originalEnv, "utf-8");
}
});

it("reports legacy books in the version migration check", async () => {
const bookDir = join(projectDir, "books", "legacy-doctor-hint");
const storyDir = join(bookDir, "story");
Expand Down
14 changes: 7 additions & 7 deletions packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Command } from "commander";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { findProjectRoot, log, logError, GLOBAL_ENV_PATH } from "../utils.js";
import { hasConfiguredApiKey, isApiKeyOptionalForEndpoint } from "@actalk/inkos-core";
import { findProjectRoot, log, logError, GLOBAL_ENV_PATH, hasConfiguredApiKeyInEnvContent } from "../utils.js";
import {
ensureNodeRuntimePinFiles,
evaluateSqliteMemorySupport,
Expand Down Expand Up @@ -64,7 +65,7 @@ export const doctorCommand = new Command("doctor")
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");
hasGlobal = hasConfiguredApiKeyInEnvContent(globalContent);
} catch { /* no global config */ }
checks.push({
name: "Global Config",
Expand All @@ -79,7 +80,6 @@ export const doctorCommand = new Command("doctor")
const { config: loadDotenv } = await import("dotenv");
loadDotenv({ path: GLOBAL_ENV_PATH });
loadDotenv({ path: join(root, ".env"), override: true });
const { isApiKeyOptionalForEndpoint } = await import("@actalk/inkos-core");
let provider = process.env.INKOS_LLM_PROVIDER;
let baseUrl = process.env.INKOS_LLM_BASE_URL;
try {
Expand All @@ -91,7 +91,7 @@ export const doctorCommand = new Command("doctor")
}
const apiKey = process.env.INKOS_LLM_API_KEY;
const apiKeyOptional = isApiKeyOptionalForEndpoint({ provider, baseUrl });
const hasKey = apiKeyOptional || (!!apiKey && apiKey.length > 10 && apiKey !== "your-api-key-here");
const hasKey = apiKeyOptional || hasConfiguredApiKey(apiKey);
checks.push({
name: "LLM API Key",
ok: hasKey,
Expand Down Expand Up @@ -149,7 +149,7 @@ export const doctorCommand = new Command("doctor")

// 6. API connectivity test
try {
const { createLLMClient, chatCompletion, LLMConfigSchema, isApiKeyOptionalForEndpoint } = await import("@actalk/inkos-core");
const { createLLMClient, chatCompletion, LLMConfigSchema } = await import("@actalk/inkos-core");
const { loadConfig } = await import("../utils.js");

let llmConfig;
Expand All @@ -165,11 +165,11 @@ export const doctorCommand = new Command("doctor")
provider: env.INKOS_LLM_PROVIDER,
baseUrl: env.INKOS_LLM_BASE_URL,
});
if ((env.INKOS_LLM_API_KEY || apiKeyOptional) && env.INKOS_LLM_BASE_URL && env.INKOS_LLM_MODEL) {
if ((hasConfiguredApiKey(env.INKOS_LLM_API_KEY) || apiKeyOptional) && env.INKOS_LLM_BASE_URL && env.INKOS_LLM_MODEL) {
llmConfig = LLMConfigSchema.parse({
provider: env.INKOS_LLM_PROVIDER ?? "custom",
baseUrl: env.INKOS_LLM_BASE_URL,
apiKey: env.INKOS_LLM_API_KEY ?? "",
apiKey: hasConfiguredApiKey(env.INKOS_LLM_API_KEY) ? env.INKOS_LLM_API_KEY : "",
model: env.INKOS_LLM_MODEL,
});
}
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Command } from "commander";
import { access, readFile, writeFile, mkdir } from "node:fs/promises";
import { join, basename, resolve } from "node:path";
import { log, logError, GLOBAL_ENV_PATH } from "../utils.js";
import { log, logError, GLOBAL_ENV_PATH, hasConfiguredApiKeyInEnvContent } 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");
return hasConfiguredApiKeyInEnvContent(content);
} catch {
return false;
}
Expand Down Expand Up @@ -107,8 +107,8 @@ export const initCommand = new Command("init")
"",
"# Anthropic example:",
"# INKOS_LLM_PROVIDER=anthropic",
"# INKOS_LLM_PROVIDER=anthropic",
"# INKOS_LLM_BASE_URL=",
"# INKOS_LLM_API_KEY=",
"# INKOS_LLM_MODEL=",
].join("\n"),
"utf-8",
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFile, stat } from "node:fs/promises";
import { join, 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";
import { parse as parseDotenv } from "dotenv";
import { createLLMClient, StateManager, createLogger, createStderrSink, createJsonLineSink, loadProjectConfig, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH, hasConfiguredApiKey, type ProjectConfig, type PipelineConfig, type LogSink } from "@actalk/inkos-core";
import { formatSqliteMemorySupportWarning } from "./runtime-requirements.js";

export { GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH };
Expand Down Expand Up @@ -99,6 +100,11 @@ export function logError(message: string): void {
process.stderr.write(`[ERROR] ${message}\n`);
}

export function hasConfiguredApiKeyInEnvContent(content: string): boolean {
const parsed = parseDotenv(content);
return hasConfiguredApiKey(parsed.INKOS_LLM_API_KEY);
}

/**
* Resolve book-id: if provided use it, otherwise auto-detect when exactly one book exists.
* Validates that the book actually exists.
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/__tests__/config-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,25 @@ describe("loadProjectConfig local provider auth", () => {
await writeFile(join(root, ".env"), "", "utf-8");
await expect(loadProjectConfig(root)).rejects.toThrow(/INKOS_LLM_API_KEY not set/i);
});

it("rejects placeholder API keys copied from env examples", async () => {
root = await mkdtemp(join(tmpdir(), "inkos-config-loader-placeholder-"));
for (const key of ENV_KEYS) {
previousEnv.set(key, process.env[key]);
process.env[key] = "";
}

await writeFile(join(root, "inkos.json"), JSON.stringify({
name: "placeholder-project",
version: "0.1.0",
llm: {
provider: "openai",
baseUrl: "https://api.openai.com/v1",
model: "gpt-5.4",
},
}, null, 2), "utf-8");
await writeFile(join(root, ".env"), "INKOS_LLM_API_KEY=your-api-key-here\n", "utf-8");

await expect(loadProjectConfig(root)).rejects.toThrow(/INKOS_LLM_API_KEY not set/i);
});
});
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export { loadRuntimeStateSnapshot, buildRuntimeStateArtifacts, saveRuntimeStateS
export { splitChapters, type SplitChapter } from "./utils/chapter-splitter.js";
export { countChapterLength, resolveLengthCountingMode, formatLengthCount, buildLengthSpec, isOutsideSoftRange, isOutsideHardRange, chooseNormalizeMode, type LengthLanguage } from "./utils/length-metrics.js";
export { createLogger, createStderrSink, createJsonLineSink, nullSink, type Logger, type LogSink, type LogLevel, type LogEntry } from "./utils/logger.js";
export { loadProjectConfig, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH, isApiKeyOptionalForEndpoint } from "./utils/config-loader.js";
export { loadProjectConfig, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH, hasConfiguredApiKey, isApiKeyOptionalForEndpoint } from "./utils/config-loader.js";
export { computeAnalytics, type AnalyticsData, type TokenStats } from "./utils/analytics.js";
export {
collectStaleHookDebt,
Expand Down
32 changes: 29 additions & 3 deletions packages/core/src/utils/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,31 @@ import { ProjectConfigSchema, type ProjectConfig } from "../models/project.js";
export const GLOBAL_CONFIG_DIR = join(homedir(), ".inkos");
export const GLOBAL_ENV_PATH = join(GLOBAL_CONFIG_DIR, ".env");

const API_KEY_PLACEHOLDERS = new Set([
"your-api-key-here",
"sk-your-key-here",
]);

function normalizeApiKey(apiKey: string | undefined): string {
const value = (apiKey ?? "").trim();
if (
(value.startsWith("\"") && value.endsWith("\""))
|| (value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1).trim();
}
return value;
}

function isPlaceholderApiKey(apiKey: string | undefined): boolean {
return API_KEY_PLACEHOLDERS.has(normalizeApiKey(apiKey).toLowerCase());
}

export function hasConfiguredApiKey(apiKey: string | undefined): boolean {
const value = normalizeApiKey(apiKey);
return value.length > 0 && !isPlaceholderApiKey(value);
}

export function isApiKeyOptionalForEndpoint(params: {
readonly provider?: string | undefined;
readonly baseUrl?: string | undefined;
Expand Down Expand Up @@ -101,12 +126,13 @@ export async function loadProjectConfig(
if (env.INKOS_DEFAULT_LANGUAGE) config.language = env.INKOS_DEFAULT_LANGUAGE;

// API key ONLY from env — never stored in inkos.json
const apiKey = env.INKOS_LLM_API_KEY;
const apiKey = normalizeApiKey(env.INKOS_LLM_API_KEY);
const provider = typeof llm.provider === "string" ? llm.provider : undefined;
const baseUrl = typeof llm.baseUrl === "string" ? llm.baseUrl : undefined;
const apiKeyOptional = isApiKeyOptionalForEndpoint({ provider, baseUrl });
const hasApiKey = hasConfiguredApiKey(apiKey);

if (!apiKey && options?.requireApiKey !== false && !apiKeyOptional) {
if (!hasApiKey && options?.requireApiKey !== false && !apiKeyOptional) {
throw new Error(
"INKOS_LLM_API_KEY not set. Run 'inkos config set-global' or add it to project .env file.",
);
Expand All @@ -122,7 +148,7 @@ export async function loadProjectConfig(
? llm.model
: "noop-model";
}
llm.apiKey = apiKey ?? "";
llm.apiKey = hasApiKey ? apiKey : "";

return ProjectConfigSchema.parse(config);
}
Expand Down