From a0c865803a1d275c0ec8e011ad56811e6fb56dd7 Mon Sep 17 00:00:00 2001 From: Sangyeon Cho Date: Sun, 12 Jul 2026 15:14:10 +0900 Subject: [PATCH 01/17] feat: add claude-code and codex-cli provider definitions --- src/constants.ts | 65 ++++++++++++++++++++++++++++++++++++++++-- test/constants.test.ts | 43 ++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index 1c1eb2bb..030782a7 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -56,6 +56,8 @@ export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; export type OpenWikiProvider = | "anthropic" | "baseten" + | "claude-code" + | "codex-cli" | "fireworks" | "nvidia" | "openai" @@ -66,9 +68,11 @@ export type OpenWikiProvider = /** * How a provider authenticates. Providers default to `"api-key"` (a pasted * secret persisted to a `*_API_KEY` env var); `"oauth"` providers instead run a - * browser login flow and persist short-lived access/refresh tokens. + * browser login flow and persist short-lived access/refresh tokens. `"cli"` + * providers delegate the whole agent loop to a locally installed headless CLI + * that manages its own authentication, so no key or token is read. */ -export type ProviderAuthMethod = "api-key" | "oauth"; +export type ProviderAuthMethod = "api-key" | "oauth" | "cli"; export type SelectableOpenWikiProvider = OpenWikiProvider; @@ -104,6 +108,12 @@ type ProviderConfig = { * with an alternative base URL (e.g. a self-hosted or proxied endpoint). */ baseUrlEnvKey?: string; + /** + * Executable name for `"cli"` auth providers. These providers delegate the + * whole agent loop to a locally installed headless CLI and never read + * `apiKeyEnvKey`. + */ + cliCommand?: string; /** * When true, the provider has no default endpoint and requires a base URL to * be supplied via {@link ProviderConfig.baseUrlEnvKey}. @@ -122,6 +132,8 @@ export const SELECTABLE_OPENWIKI_PROVIDERS = [ "fireworks", "baseten", "nvidia", + "claude-code", + "codex-cli", ] as const satisfies readonly SelectableOpenWikiProvider[]; export const PROVIDER_CONFIGS: Record = { @@ -134,6 +146,25 @@ export const PROVIDER_CONFIGS: Record = { { id: "moonshotai/Kimi-K2.7-Code", label: "Kimi K2.7 Code" }, ], }, + "claude-code": { + // Never read: "cli" auth providers skip all API-key checks. + apiKeyEnvKey: "OPENWIKI_CLI_AUTH_UNUSED", + authMethod: "cli", + cliCommand: "claude", + label: "Claude Code (CLI)", + modelOptions: [ + { id: "sonnet", label: "Sonnet" }, + { id: "opus", label: "Opus" }, + { id: "haiku", label: "Haiku" }, + ], + }, + "codex-cli": { + apiKeyEnvKey: "OPENWIKI_CLI_AUTH_UNUSED", + authMethod: "cli", + cliCommand: "codex", + label: "Codex (CLI)", + modelOptions: OPENAI_MODEL_OPTIONS, + }, fireworks: { apiKeyEnvKey: FIREWORKS_API_KEY_ENV_KEY, baseURL: "https://api.fireworks.ai/inference/v1", @@ -241,6 +272,36 @@ export function providerUsesOAuth(provider: OpenWikiProvider): boolean { return getProviderAuthMethod(provider) === "oauth"; } +export function providerUsesCliAuth(provider: OpenWikiProvider): boolean { + return getProviderConfig(provider).authMethod === "cli"; +} + +export function getProviderCliCommand( + provider: OpenWikiProvider, +): string | null { + return getProviderConfig(provider).cliCommand ?? null; +} + +export const OPENWIKI_CLI_TIMEOUT_SECONDS_ENV_KEY = + "OPENWIKI_CLI_TIMEOUT_SECONDS"; +export const DEFAULT_CLI_TIMEOUT_SECONDS = 1800; + +export function resolveCliTimeoutSeconds( + env: NodeJS.ProcessEnv = process.env, +): number { + const raw = env[OPENWIKI_CLI_TIMEOUT_SECONDS_ENV_KEY]; + + if (raw === undefined) { + return DEFAULT_CLI_TIMEOUT_SECONDS; + } + + const parsed = Number.parseInt(raw, 10); + + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : DEFAULT_CLI_TIMEOUT_SECONDS; +} + /** * Resolves the base URL for a provider, preferring an alternative base URL from * the provider's configured environment variable over the built-in default. diff --git a/test/constants.test.ts b/test/constants.test.ts index bbcae399..7b121a6b 100644 --- a/test/constants.test.ts +++ b/test/constants.test.ts @@ -1,18 +1,23 @@ import { describe, expect, test } from "vitest"; import { + DEFAULT_CLI_TIMEOUT_SECONDS, DEFAULT_MODEL_ID, DEFAULT_PROVIDER_RETRY_ATTEMPTS, DEFAULT_PROVIDER, getDefaultModelId, + getProviderCliCommand, getProviderModelOptions, isValidBaseUrl, isValidModelId, isValidProvider, normalizeModelId, normalizeProvider, + providerUsesCliAuth, + resolveCliTimeoutSeconds, resolveConfiguredProvider, resolveProviderBaseUrl, resolveProviderRetryAttempts, + SELECTABLE_OPENWIKI_PROVIDERS, } from "../src/constants.ts"; describe("isValidModelId", () => { @@ -200,3 +205,41 @@ describe("getDefaultModelId", () => { }, ); }); + +describe("cli providers", () => { + test("claude-code and codex-cli are valid selectable providers", () => { + expect(isValidProvider("claude-code")).toBe(true); + expect(isValidProvider("codex-cli")).toBe(true); + expect(SELECTABLE_OPENWIKI_PROVIDERS).toContain("claude-code"); + expect(SELECTABLE_OPENWIKI_PROVIDERS).toContain("codex-cli"); + }); + + test("cli providers use cli auth and expose a cli command", () => { + expect(providerUsesCliAuth("claude-code")).toBe(true); + expect(providerUsesCliAuth("codex-cli")).toBe(true); + expect(providerUsesCliAuth("anthropic")).toBe(false); + expect(providerUsesCliAuth("openai-chatgpt")).toBe(false); + expect(getProviderCliCommand("claude-code")).toBe("claude"); + expect(getProviderCliCommand("codex-cli")).toBe("codex"); + expect(getProviderCliCommand("openai")).toBe(null); + }); + + test("cli providers have model defaults", () => { + expect(getDefaultModelId("claude-code")).toBe("sonnet"); + expect(getDefaultModelId("codex-cli")).toBe("gpt-5.6-terra"); + expect(isValidModelId("sonnet")).toBe(true); + }); + + test("resolveCliTimeoutSeconds falls back on missing or invalid values", () => { + expect(resolveCliTimeoutSeconds({})).toBe(DEFAULT_CLI_TIMEOUT_SECONDS); + expect( + resolveCliTimeoutSeconds({ OPENWIKI_CLI_TIMEOUT_SECONDS: "600" }), + ).toBe(600); + expect( + resolveCliTimeoutSeconds({ OPENWIKI_CLI_TIMEOUT_SECONDS: "abc" }), + ).toBe(DEFAULT_CLI_TIMEOUT_SECONDS); + expect( + resolveCliTimeoutSeconds({ OPENWIKI_CLI_TIMEOUT_SECONDS: "-5" }), + ).toBe(DEFAULT_CLI_TIMEOUT_SECONDS); + }); +}); From fe999fdfdb466ba9bb659530ecc5b7f2a15b610c Mon Sep 17 00:00:00 2001 From: Sangyeon Cho Date: Sun, 12 Jul 2026 15:20:00 +0900 Subject: [PATCH 02/17] feat: add cli-runner types and session store Co-Authored-By: Claude Fable 5 --- src/agent/cli-runner/sessions.ts | 86 ++++++++++++++++++++++++++++++++ src/agent/cli-runner/types.ts | 30 +++++++++++ test/cli-runner-sessions.test.ts | 77 ++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 src/agent/cli-runner/sessions.ts create mode 100644 src/agent/cli-runner/types.ts create mode 100644 test/cli-runner-sessions.test.ts diff --git a/src/agent/cli-runner/sessions.ts b/src/agent/cli-runner/sessions.ts new file mode 100644 index 00000000..b3484864 --- /dev/null +++ b/src/agent/cli-runner/sessions.ts @@ -0,0 +1,86 @@ +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { openWikiEnvDir } from "../../env.js"; +import { isFileNotFoundError } from "../../fs-errors.js"; + +const CLI_SESSIONS_FILENAME = "cli-sessions.json"; + +type CliSessionEntry = { + engine: string; + sessionId: string; + updatedAt: string; +}; + +type CliSessionMap = Record; + +export function cliSessionsPath(baseDir: string = openWikiEnvDir): string { + return path.join(baseDir, CLI_SESSIONS_FILENAME); +} + +export async function getCliSession( + threadId: string, + engine: string, + baseDir: string = openWikiEnvDir, +): Promise { + const sessions = await readSessions(baseDir); + const entry = sessions[threadId]; + + return entry && entry.engine === engine ? entry.sessionId : null; +} + +export async function saveCliSession( + threadId: string, + engine: string, + sessionId: string, + baseDir: string = openWikiEnvDir, +): Promise { + const sessions = await readSessions(baseDir); + + sessions[threadId] = { + engine, + sessionId, + updatedAt: new Date().toISOString(), + }; + + const filePath = cliSessionsPath(baseDir); + await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); + await writeFile(filePath, `${JSON.stringify(sessions, null, 2)}\n`, "utf8"); + await chmod(filePath, 0o600); +} + +async function readSessions(baseDir: string): Promise { + let content: string; + + try { + content = await readFile(cliSessionsPath(baseDir), "utf8"); + } catch (error) { + if (isFileNotFoundError(error)) { + return {}; + } + + throw error; + } + + try { + const parsed: unknown = JSON.parse(content); + + return isCliSessionMap(parsed) ? parsed : {}; + } catch { + // Corrupt session cache: start over instead of failing the run. + return {}; + } +} + +function isCliSessionMap(value: unknown): value is CliSessionMap { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + + return Object.values(value).every( + (entry) => + typeof entry === "object" && + entry !== null && + typeof (entry as CliSessionEntry).engine === "string" && + typeof (entry as CliSessionEntry).sessionId === "string", + ); +} diff --git a/src/agent/cli-runner/types.ts b/src/agent/cli-runner/types.ts new file mode 100644 index 00000000..589b87f3 --- /dev/null +++ b/src/agent/cli-runner/types.ts @@ -0,0 +1,30 @@ +import type { OpenWikiProvider } from "../../constants.js"; +import type { + OpenWikiCommand, + OpenWikiOutputMode, + OpenWikiRunEvent, +} from "../types.js"; + +export type CliRunSpec = { + command: OpenWikiCommand; + cwd: string; + modelId: string; + outputMode: OpenWikiOutputMode; + resumeSessionId: string | null; + systemPrompt: string; + userPrompt: string; +}; + +export type CliParsedEvent = + | { kind: "event"; event: OpenWikiRunEvent } + | { kind: "session"; sessionId: string } + | { kind: "result"; isError: boolean; message: string }; + +export type CliEngineAdapter = { + /** Executable name looked up on PATH, e.g. "claude". */ + cliCommand: string; + engine: OpenWikiProvider; + buildArgs(spec: CliRunSpec): string[]; + /** Parse one stdout line into zero or more parsed events. Must not throw. */ + parseLine(line: string): CliParsedEvent[]; +}; diff --git a/test/cli-runner-sessions.test.ts b/test/cli-runner-sessions.test.ts new file mode 100644 index 00000000..bbfc1486 --- /dev/null +++ b/test/cli-runner-sessions.test.ts @@ -0,0 +1,77 @@ +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { + cliSessionsPath, + getCliSession, + saveCliSession, +} from "../src/agent/cli-runner/sessions.ts"; + +async function makeTempDir(): Promise { + return mkdtemp(path.join(os.tmpdir(), "openwiki-cli-sessions-")); +} + +describe("cli session store", () => { + test("returns null when no session file exists", async () => { + const dir = await makeTempDir(); + + expect(await getCliSession("thread-1", "claude-code", dir)).toBe(null); + }); + + test("round-trips a saved session", async () => { + const dir = await makeTempDir(); + + await saveCliSession("thread-1", "claude-code", "session-abc", dir); + + expect(await getCliSession("thread-1", "claude-code", dir)).toBe( + "session-abc", + ); + }); + + test("does not return sessions recorded for another engine", async () => { + const dir = await makeTempDir(); + + await saveCliSession("thread-1", "claude-code", "session-abc", dir); + + expect(await getCliSession("thread-1", "codex-cli", dir)).toBe(null); + }); + + test("overwrites an existing thread entry", async () => { + const dir = await makeTempDir(); + + await saveCliSession("thread-1", "claude-code", "session-a", dir); + await saveCliSession("thread-1", "claude-code", "session-b", dir); + + expect(await getCliSession("thread-1", "claude-code", dir)).toBe( + "session-b", + ); + }); + + test("recovers from a corrupt session file", async () => { + const dir = await makeTempDir(); + + await writeFile(cliSessionsPath(dir), "not json", "utf8"); + + expect(await getCliSession("thread-1", "claude-code", dir)).toBe(null); + await saveCliSession("thread-1", "claude-code", "session-a", dir); + expect(await getCliSession("thread-1", "claude-code", dir)).toBe( + "session-a", + ); + }); + + test("persists updatedAt metadata as ISO timestamp", async () => { + const dir = await makeTempDir(); + + await saveCliSession("thread-1", "claude-code", "session-a", dir); + const raw = JSON.parse( + await readFile(cliSessionsPath(dir), "utf8"), + ) as Record< + string, + { engine: string; sessionId: string; updatedAt: string } + >; + + expect(raw["thread-1"].engine).toBe("claude-code"); + expect(Date.parse(raw["thread-1"].updatedAt)).not.toBeNaN(); + }); +}); From 1c5fe3f502238304d749cf9504e6b0110a377eb0 Mon Sep 17 00:00:00 2001 From: Sangyeon Cho Date: Sun, 12 Jul 2026 15:29:26 +0900 Subject: [PATCH 03/17] feat: add claude headless engine adapter Add claudeAdapter (CliEngineAdapter for engine "claude-code", cliCommand "claude") that builds the headless stream-json argv and parses the CLI's NDJSON stdout lines into CliParsedEvent values. Co-Authored-By: Claude Fable 5 --- src/agent/cli-runner/claude.ts | 180 ++++++++++++++++++++++++++++++ test/cli-runner-claude.test.ts | 193 +++++++++++++++++++++++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 src/agent/cli-runner/claude.ts create mode 100644 test/cli-runner-claude.test.ts diff --git a/src/agent/cli-runner/claude.ts b/src/agent/cli-runner/claude.ts new file mode 100644 index 00000000..97fda627 --- /dev/null +++ b/src/agent/cli-runner/claude.ts @@ -0,0 +1,180 @@ +import type { CliEngineAdapter, CliParsedEvent, CliRunSpec } from "./types.js"; + +const CLAUDE_ALLOWED_TOOLS = [ + "Read", + "Glob", + "Grep", + "LS", + "Write", + "Edit", + "MultiEdit", + "TodoWrite", + "Task", + "Bash(git log:*)", + "Bash(git show:*)", + "Bash(git diff:*)", + "Bash(git status:*)", + "Bash(git blame:*)", + "Bash(rg:*)", + "Bash(ls:*)", + // Exact plan-file cleanup commands referenced by the CLI prompt. + "Bash(rm -f openwiki/_plan.md)", + "Bash(rm -f _plan.md)", +].join(","); + +export const claudeAdapter: CliEngineAdapter = { + cliCommand: "claude", + engine: "claude-code", + + buildArgs(spec: CliRunSpec): string[] { + const args = [ + "-p", + "--output-format", + "stream-json", + "--verbose", + "--model", + spec.modelId, + "--permission-mode", + "acceptEdits", + "--append-system-prompt", + spec.systemPrompt, + "--allowedTools", + CLAUDE_ALLOWED_TOOLS, + ]; + + if (spec.resumeSessionId) { + args.push("--resume", spec.resumeSessionId); + } + + return args; + }, + + parseLine(line: string): CliParsedEvent[] { + const trimmed = line.trim(); + + if (trimmed.length === 0) { + return []; + } + + let payload: unknown; + + try { + payload = JSON.parse(trimmed); + } catch { + return [debugEvent(`claude.unparsed ${trimmed.slice(0, 200)}`)]; + } + + if (!isRecord(payload) || typeof payload.type !== "string") { + return []; + } + + switch (payload.type) { + case "system": + return payload.subtype === "init" && + typeof payload.session_id === "string" + ? [{ kind: "session", sessionId: payload.session_id }] + : []; + case "assistant": + return parseAssistantMessage(payload); + case "user": + return parseToolResults(payload); + case "result": { + const isError = + payload.is_error === true || payload.subtype !== "success"; + + return [ + { + kind: "result", + isError, + message: typeof payload.result === "string" ? payload.result : "", + }, + debugEvent( + `claude.result subtype=${String(payload.subtype)} cost=${String( + payload.total_cost_usd, + )} turns=${String(payload.num_turns)}`, + ), + ]; + } + default: + return []; + } + }, +}; + +function parseAssistantMessage( + payload: Record, +): CliParsedEvent[] { + const events: CliParsedEvent[] = []; + + for (const block of getContentBlocks(payload)) { + if (block.type === "text" && typeof block.text === "string") { + if (block.text.length > 0) { + events.push({ + kind: "event", + event: { type: "text", text: block.text }, + }); + } + } else if ( + block.type === "tool_use" && + typeof block.id === "string" && + typeof block.name === "string" + ) { + events.push({ + kind: "event", + event: { + type: "tool_start", + call: `${block.name}(${formatToolInput(block.input)})`, + id: block.id, + input: block.input, + name: block.name, + }, + }); + } + } + + return events; +} + +function parseToolResults(payload: Record): CliParsedEvent[] { + const events: CliParsedEvent[] = []; + + for (const block of getContentBlocks(payload)) { + if (block.type === "tool_result" && typeof block.tool_use_id === "string") { + events.push({ + kind: "event", + event: { + type: "tool_end", + id: block.tool_use_id, + name: "tool", + status: block.is_error === true ? "error" : "finished", + }, + }); + } + } + + return events; +} + +function getContentBlocks( + payload: Record, +): Record[] { + if (!isRecord(payload.message) || !Array.isArray(payload.message.content)) { + return []; + } + + return payload.message.content.filter(isRecord); +} + +function formatToolInput(input: unknown): string { + const formatted = JSON.stringify(input) ?? ""; + + return formatted.length > 200 ? `${formatted.slice(0, 197)}...` : formatted; +} + +function debugEvent(message: string): CliParsedEvent { + return { kind: "event", event: { type: "debug", message } }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/test/cli-runner-claude.test.ts b/test/cli-runner-claude.test.ts new file mode 100644 index 00000000..2b0254ce --- /dev/null +++ b/test/cli-runner-claude.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, test } from "vitest"; +import { claudeAdapter } from "../src/agent/cli-runner/claude.ts"; +import type { CliRunSpec } from "../src/agent/cli-runner/types.ts"; + +const BASE_SPEC: CliRunSpec = { + command: "init", + cwd: "/repo", + modelId: "sonnet", + outputMode: "repository", + resumeSessionId: null, + systemPrompt: "SYSTEM", + userPrompt: "USER", +}; + +const INIT_LINE = JSON.stringify({ + type: "system", + subtype: "init", + cwd: "/repo", + session_id: "016b7e7b-d417-4219-a519-89e6407a961d", +}); + +const TOOL_USE_LINE = JSON.stringify({ + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_011yB1jJZ9RkM34U3DzWZv4f", + name: "Bash", + input: { command: "echo hello", description: "Run echo hello" }, + }, + ], + }, +}); + +const THINKING_LINE = JSON.stringify({ + type: "assistant", + message: { + role: "assistant", + content: [{ type: "thinking", thinking: "internal", signature: "sig" }], + }, +}); + +const TEXT_LINE = JSON.stringify({ + type: "assistant", + message: { role: "assistant", content: [{ type: "text", text: "OK" }] }, +}); + +const TOOL_RESULT_LINE = JSON.stringify({ + type: "user", + message: { + role: "user", + content: [ + { + tool_use_id: "toolu_011yB1jJZ9RkM34U3DzWZv4f", + type: "tool_result", + content: "hello", + is_error: false, + }, + ], + }, +}); + +const RESULT_LINE = JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + result: "OK", + session_id: "016b7e7b-d417-4219-a519-89e6407a961d", + total_cost_usd: 0, + num_turns: 2, +}); + +describe("claudeAdapter.buildArgs", () => { + test("builds headless stream-json argv without user prompt", () => { + const args = claudeAdapter.buildArgs(BASE_SPEC); + + expect(args).toContain("-p"); + expect(args).toContain("stream-json"); + expect(args).toContain("--verbose"); + expect(args).toContain("--append-system-prompt"); + expect(args[args.indexOf("--model") + 1]).toBe("sonnet"); + expect(args[args.indexOf("--permission-mode") + 1]).toBe("acceptEdits"); + expect(args).not.toContain("USER"); + expect(args).not.toContain("--resume"); + }); + + test("allowedTools excludes network tools and broad bash", () => { + const args = claudeAdapter.buildArgs(BASE_SPEC); + const allowed = args[args.indexOf("--allowedTools") + 1]; + + expect(allowed).toContain("Read"); + expect(allowed).toContain("Bash(git log:*)"); + expect(allowed).not.toContain("WebFetch"); + expect(allowed).not.toContain("WebSearch"); + expect(allowed).not.toContain("Bash(rm -rf"); + }); + + test("adds --resume for followup runs", () => { + const args = claudeAdapter.buildArgs({ + ...BASE_SPEC, + resumeSessionId: "session-1", + }); + + expect(args[args.indexOf("--resume") + 1]).toBe("session-1"); + }); +}); + +describe("claudeAdapter.parseLine", () => { + test("captures session id from system init", () => { + expect(claudeAdapter.parseLine(INIT_LINE)).toEqual([ + { kind: "session", sessionId: "016b7e7b-d417-4219-a519-89e6407a961d" }, + ]); + }); + + test("maps tool_use to tool_start", () => { + const [parsed] = claudeAdapter.parseLine(TOOL_USE_LINE); + + expect(parsed).toMatchObject({ + kind: "event", + event: { + type: "tool_start", + id: "toolu_011yB1jJZ9RkM34U3DzWZv4f", + name: "Bash", + }, + }); + }); + + test("skips thinking blocks", () => { + expect(claudeAdapter.parseLine(THINKING_LINE)).toEqual([]); + }); + + test("maps text blocks to text events", () => { + expect(claudeAdapter.parseLine(TEXT_LINE)).toEqual([ + { kind: "event", event: { type: "text", text: "OK" } }, + ]); + }); + + test("maps tool_result to tool_end", () => { + const [parsed] = claudeAdapter.parseLine(TOOL_RESULT_LINE); + + expect(parsed).toMatchObject({ + kind: "event", + event: { + type: "tool_end", + id: "toolu_011yB1jJZ9RkM34U3DzWZv4f", + status: "finished", + }, + }); + }); + + test("maps result to run result", () => { + const parsed = claudeAdapter.parseLine(RESULT_LINE); + + expect(parsed[0]).toEqual({ + kind: "result", + isError: false, + message: "OK", + }); + }); + + test("treats error results as failures", () => { + const line = JSON.stringify({ + type: "result", + subtype: "error_during_execution", + is_error: true, + result: "boom", + }); + + expect(claudeAdapter.parseLine(line)[0]).toEqual({ + kind: "result", + isError: true, + message: "boom", + }); + }); + + test("ignores unparseable and irrelevant lines without throwing", () => { + expect(claudeAdapter.parseLine("")).toEqual([]); + expect(claudeAdapter.parseLine("not json")).toEqual([ + { + kind: "event", + event: { + type: "debug", + message: expect.stringContaining("unparsed") as string, + }, + }, + ]); + expect( + claudeAdapter.parseLine(JSON.stringify({ type: "rate_limit_event" })), + ).toEqual([]); + }); +}); From c33197530d0907f5aeb44c90417919ba2e51c6e8 Mon Sep 17 00:00:00 2001 From: Sangyeon Cho Date: Sun, 12 Jul 2026 15:36:08 +0900 Subject: [PATCH 04/17] feat: add codex headless engine adapter --- src/agent/cli-runner/codex.ts | 140 ++++++++++++++++++++++++++++ test/cli-runner-codex.test.ts | 169 ++++++++++++++++++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 src/agent/cli-runner/codex.ts create mode 100644 test/cli-runner-codex.test.ts diff --git a/src/agent/cli-runner/codex.ts b/src/agent/cli-runner/codex.ts new file mode 100644 index 00000000..da123aec --- /dev/null +++ b/src/agent/cli-runner/codex.ts @@ -0,0 +1,140 @@ +import type { CliEngineAdapter, CliParsedEvent, CliRunSpec } from "./types.js"; + +export const codexAdapter: CliEngineAdapter = { + cliCommand: "codex", + engine: "codex-cli", + + buildArgs(spec: CliRunSpec): string[] { + const flags = [ + "--json", + "--skip-git-repo-check", + "--sandbox", + "workspace-write", + "--model", + spec.modelId, + ]; + + return spec.resumeSessionId + ? ["exec", "resume", spec.resumeSessionId, ...flags, "-"] + : ["exec", ...flags, "-"]; + }, + + parseLine(line: string): CliParsedEvent[] { + const trimmed = line.trim(); + + if (trimmed.length === 0) { + return []; + } + + let payload: unknown; + + try { + payload = JSON.parse(trimmed); + } catch { + return [debugEvent(`codex.unparsed ${trimmed.slice(0, 200)}`)]; + } + + if (!isRecord(payload) || typeof payload.type !== "string") { + return []; + } + + switch (payload.type) { + case "thread.started": + return typeof payload.thread_id === "string" + ? [{ kind: "session", sessionId: payload.thread_id }] + : []; + case "item.started": + return parseItem(payload.item, "start"); + case "item.completed": + return parseItem(payload.item, "end"); + case "turn.completed": + return [debugEvent(`codex.usage ${JSON.stringify(payload.usage)}`)]; + case "turn.failed": + case "error": + return [ + { + kind: "result", + isError: true, + message: extractErrorMessage(payload), + }, + ]; + default: + return []; + } + }, +}; + +function parseItem(item: unknown, phase: "start" | "end"): CliParsedEvent[] { + if (!isRecord(item) || typeof item.type !== "string") { + return []; + } + + if (item.type === "reasoning") { + return []; + } + + if (item.type === "agent_message") { + return phase === "end" && typeof item.text === "string" && item.text + ? [{ kind: "event", event: { type: "text", text: item.text } }] + : []; + } + + const id = typeof item.id === "string" ? item.id : `codex:${item.type}`; + + if (phase === "start") { + return [ + { + kind: "event", + event: { + type: "tool_start", + call: formatItemCall(item), + id, + input: item, + name: item.type, + }, + }, + ]; + } + + const failed = + item.status === "failed" || + (typeof item.exit_code === "number" && item.exit_code !== 0); + + return [ + { + kind: "event", + event: { + type: "tool_end", + id, + name: item.type, + status: failed ? "error" : "finished", + }, + }, + ]; +} + +function formatItemCall(item: Record): string { + if (typeof item.command === "string") { + return `Execute(${JSON.stringify(item.command)})`; + } + + return `${String(item.type)}(${JSON.stringify(item).slice(0, 160)})`; +} + +function extractErrorMessage(payload: Record): string { + if (isRecord(payload.error) && typeof payload.error.message === "string") { + return payload.error.message; + } + + return typeof payload.message === "string" + ? payload.message + : "codex exec failed"; +} + +function debugEvent(message: string): CliParsedEvent { + return { kind: "event", event: { type: "debug", message } }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/test/cli-runner-codex.test.ts b/test/cli-runner-codex.test.ts new file mode 100644 index 00000000..5b880d90 --- /dev/null +++ b/test/cli-runner-codex.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from "vitest"; +import { codexAdapter } from "../src/agent/cli-runner/codex.ts"; +import type { CliRunSpec } from "../src/agent/cli-runner/types.ts"; + +const BASE_SPEC: CliRunSpec = { + command: "init", + cwd: "/repo", + modelId: "gpt-5.6-terra", + outputMode: "repository", + resumeSessionId: null, + systemPrompt: "SYSTEM", + userPrompt: "USER", +}; + +const THREAD_LINE = JSON.stringify({ + type: "thread.started", + thread_id: "019f54e5-6409-7bf3-8788-4732226cb68a", +}); + +const MESSAGE_LINE = JSON.stringify({ + type: "item.completed", + item: { id: "item_2", type: "agent_message", text: "OK" }, +}); + +const COMMAND_START_LINE = JSON.stringify({ + type: "item.started", + item: { + id: "item_1", + type: "command_execution", + command: "/bin/bash -lc 'echo hello'", + aggregated_output: "", + exit_code: null, + status: "in_progress", + }, +}); + +const COMMAND_DONE_LINE = JSON.stringify({ + type: "item.completed", + item: { + id: "item_1", + type: "command_execution", + command: "/bin/bash -lc 'echo hello'", + aggregated_output: "hello\n", + exit_code: 0, + status: "completed", + }, +}); + +const USAGE_LINE = JSON.stringify({ + type: "turn.completed", + usage: { + input_tokens: 24521, + cached_input_tokens: 22016, + output_tokens: 113, + }, +}); + +describe("codexAdapter.buildArgs", () => { + test("builds exec argv with stdin prompt sentinel", () => { + const args = codexAdapter.buildArgs(BASE_SPEC); + + expect(args[0]).toBe("exec"); + expect(args).toContain("--json"); + expect(args).toContain("--skip-git-repo-check"); + expect(args[args.indexOf("--sandbox") + 1]).toBe("workspace-write"); + expect(args[args.indexOf("--model") + 1]).toBe("gpt-5.6-terra"); + expect(args.at(-1)).toBe("-"); + expect(args).not.toContain("USER"); + }); + + test("uses exec resume for followups", () => { + const args = codexAdapter.buildArgs({ + ...BASE_SPEC, + resumeSessionId: "thread-9", + }); + + expect(args.slice(0, 3)).toEqual(["exec", "resume", "thread-9"]); + expect(args.at(-1)).toBe("-"); + }); +}); + +describe("codexAdapter.parseLine", () => { + test("captures thread id as session", () => { + expect(codexAdapter.parseLine(THREAD_LINE)).toEqual([ + { kind: "session", sessionId: "019f54e5-6409-7bf3-8788-4732226cb68a" }, + ]); + }); + + test("maps agent_message completion to text", () => { + expect(codexAdapter.parseLine(MESSAGE_LINE)).toEqual([ + { kind: "event", event: { type: "text", text: "OK" } }, + ]); + }); + + test("maps command_execution start to tool_start", () => { + const [parsed] = codexAdapter.parseLine(COMMAND_START_LINE); + + expect(parsed).toMatchObject({ + kind: "event", + event: { type: "tool_start", id: "item_1", name: "command_execution" }, + }); + }); + + test("maps command_execution completion to tool_end", () => { + expect(codexAdapter.parseLine(COMMAND_DONE_LINE)).toEqual([ + { + kind: "event", + event: { + type: "tool_end", + id: "item_1", + name: "command_execution", + status: "finished", + }, + }, + ]); + }); + + test("flags failed commands as errors", () => { + const line = JSON.stringify({ + type: "item.completed", + item: { + id: "item_1", + type: "command_execution", + command: "false", + exit_code: 1, + status: "failed", + }, + }); + + expect(codexAdapter.parseLine(line)[0]).toMatchObject({ + event: { type: "tool_end", status: "error" }, + }); + }); + + test("reports turn.failed as an error result", () => { + const line = JSON.stringify({ + type: "turn.failed", + error: { message: "boom" }, + }); + + expect(codexAdapter.parseLine(line)).toEqual([ + { kind: "result", isError: true, message: "boom" }, + ]); + }); + + test("emits usage as debug and ignores noise", () => { + expect(codexAdapter.parseLine(USAGE_LINE)).toEqual([ + { + kind: "event", + event: { + type: "debug", + message: expect.stringContaining("output_tokens") as string, + }, + }, + ]); + expect( + codexAdapter.parseLine(JSON.stringify({ type: "turn.started" })), + ).toEqual([]); + expect( + codexAdapter.parseLine( + JSON.stringify({ + type: "item.completed", + item: { id: "r1", type: "reasoning", text: "…" }, + }), + ), + ).toEqual([]); + expect(codexAdapter.parseLine("")).toEqual([]); + }); +}); From aa68eaf24d13b372a71c8f5ab32d2b646fe6d5b0 Mon Sep 17 00:00:00 2001 From: Sangyeon Cho Date: Sun, 12 Jul 2026 15:47:54 +0900 Subject: [PATCH 05/17] refactor: extract shared prompt sections and add cli prompt builder Export OutputPromptConfig/getOutputPromptConfig and add an optional output config parameter to createModeInstructions/createUserPrompt. Extract createSecuritySection, createDocumentationGoalSections, createStewardshipSections, and the OPENWIKI_CLI_REFERENCE constant out of createSystemPrompt as pure text moves (output byte-identical). Add src/agent/cli-runner/prompt.ts with real-path CLI prompt builders (getCliOutputPromptConfig, createCliSystemPrompt, createCliUserPrompt) plus tests. Co-Authored-By: Claude Fable 5 --- src/agent/cli-runner/prompt.ts | 129 +++++++++++++++++++++++++++++++++ src/agent/prompt.ts | 74 ++++++++++++------- test/cli-runner-prompt.test.ts | 96 ++++++++++++++++++++++++ 3 files changed, 273 insertions(+), 26 deletions(-) create mode 100644 src/agent/cli-runner/prompt.ts create mode 100644 test/cli-runner-prompt.test.ts diff --git a/src/agent/cli-runner/prompt.ts b/src/agent/cli-runner/prompt.ts new file mode 100644 index 00000000..1bcd9d0b --- /dev/null +++ b/src/agent/cli-runner/prompt.ts @@ -0,0 +1,129 @@ +import { + createDocumentationGoalSections, + createModeInstructions, + createSecuritySection, + createStewardshipSections, + createUserPrompt, + getOutputPromptConfig, + OPENWIKI_CLI_REFERENCE, + type OutputPromptConfig, +} from "../prompt.js"; +import type { OpenWikiProvider } from "../../constants.js"; +import type { + OpenWikiCommand, + OpenWikiOutputMode, + OpenWikiRunOptions, + RunContext, +} from "../types.js"; + +export function getCliOutputPromptConfig( + outputMode: OpenWikiOutputMode, +): OutputPromptConfig { + const base = getOutputPromptConfig(outputMode); + + if (outputMode === "repository") { + return { + ...base, + docsLocation: "the repository's openwiki/ directory", + filesystemRootInstruction: + "You are running inside the target repository checkout with your own native file and shell tools. Use real repository-relative paths. Create and update generated wiki pages only under openwiki/, such as openwiki/quickstart.md or openwiki/architecture/overview.md.", + metadataPath: "openwiki/.last-update.json", + planPath: "openwiki/_plan.md", + quickstartPath: "openwiki/quickstart.md", + removePlanCommand: "rm -f openwiki/_plan.md", + writePathExample: + "repository-relative paths under openwiki/, for example openwiki/quickstart.md or openwiki/architecture/overview.md", + }; + } + + return { + ...base, + docsLocation: "the local wiki directory (your current working directory)", + filesystemRootInstruction: + "You are running inside the local wiki directory (~/.openwiki/wiki) with your own native file and shell tools. Use real paths relative to the current working directory, such as quickstart.md, sources/gmail.md, and topics/ai-research.md. Do not create a nested openwiki/ directory.", + metadataPath: ".last-update.json", + planPath: "_plan.md", + quickstartPath: "quickstart.md", + removePlanCommand: "rm -f _plan.md", + writePathExample: + "paths relative to the current working directory, for example quickstart.md or sources/gmail.md", + }; +} + +export function createCliSystemPrompt( + command: OpenWikiCommand, + outputMode: OpenWikiOutputMode, + engine: OpenWikiProvider, +): string { + const output = getCliOutputPromptConfig(outputMode); + + return ` +You are OpenWiki, an expert technical writer, software architect, and product analyst. + +Your job is to inspect the relevant source evidence, then produce documentation in ${output.docsLocation} that is excellent for both humans and future agents. + +Run discipline: +- ${output.filesystemRootInstruction} +- Use your own built-in file discovery, read, write, and shell tools. Prefer targeted reads over full-file reads when files are large. +- Do not exhaustively read every file. Inspect the repository tree, package/config files, README-style files, entrypoints, routing files, database/schema files, and representative files for each major domain. +- Prefer commands like rg --files with excludes for .git, node_modules, dist, build, cache directories, and existing generated wiki output. +- Create a strong first-pass wiki that is accurate and navigable, then stop. The wiki can be refined in later update runs. +- Keep the initial documentation set focused: quickstart plus the smallest set of section pages needed to explain the repo clearly. +- ${output.searchBoundaryInstruction} + +${createDocumentationGoalSections(output)} + +${createStewardshipSections(output)} + +${createSecuritySection(output)} + +${output.rootAgentInstructions} + +${createCliSubagentSection(engine)} + +${OPENWIKI_CLI_REFERENCE} + +Mode-specific behavior: +${createModeInstructions(command, outputMode, output)} +`.trim(); +} + +function createCliSubagentSection(engine: OpenWikiProvider): string { + if (engine !== "claude-code") { + return ""; + } + + return ` +Subagent discipline: +- You may use your subagent/task tool to parallelize read-only research during init and update runs when the repository has multiple substantial domains. +- Default to 1-2 subagents. Subagents must only inspect and summarize; the main agent must synthesize the final docs and perform all writes. +`.trim(); +} + +export function createCliUserPrompt( + command: OpenWikiCommand, + cwd: string, + context: RunContext, + options: OpenWikiRunOptions, + outputMode: OpenWikiOutputMode, +): string { + if (options.isFollowup === true && options.userMessage?.trim()) { + return options.userMessage.trim(); + } + + const output = getCliOutputPromptConfig(outputMode); + const rootLabel = + outputMode === "local-wiki" ? "Local wiki root" : "Repository root"; + + return ` +${createUserPrompt(command, context, options.userMessage ?? null, outputMode, output)} + +${rootLabel} (your current working directory): +${cwd} + +Runtime note: +- You are running with your own native file and shell tools on the host filesystem. All relative paths resolve against the working directory above. +- ${output.writeBoundaryInstruction} +- Do not search parent directories or unrelated directories outside the working directory above. +`.trim(); +} diff --git a/src/agent/prompt.ts b/src/agent/prompt.ts index e5526c80..d642e637 100644 --- a/src/agent/prompt.ts +++ b/src/agent/prompt.ts @@ -83,26 +83,24 @@ Subagent discipline: - Ask each subagent to return concise findings with source paths and notable open questions. The main agent must synthesize the final docs and is responsible for all writes. - Treat subagent reports as internal discovery notes. Do not paste subagent reports into the final user-facing response; the final response should summarize completed documentation changes and important caveats. -Planning discipline: -- After discovery and before writing final documentation, create a temporary ${output.planPath} file that lists the intended wiki pages, source evidence for each page, and remaining questions. -- Use ${output.planPath} when writing this temporary plan with filesystem tools. -- Before completing the run, delete ${output.planPath}. If there is no filesystem delete tool, use shell execute from the runtime root, for example ${output.removePlanCommand}. -- Do not leave ${output.planPath} in the final wiki. +${createStewardshipSections(output)} -Git discipline: -- Use git heavily where it helps explain why code exists, not just what code exists. -- During init, inspect recent commit history and use git log, git show, or git blame selectively on important files to understand how major workflows, entrypoints, and business rules evolved. -- ${output.gitDisciplineInstruction} -- Use git status and git diff to account for uncommitted local changes, especially if they touch existing docs or important source files. -- Do not over-index on ancient history. Focus on recent commits and high-signal history for important files. +${output.rootAgentInstructions} -Existing documentation discipline: -- Treat existing README files, docs/ trees, root documentation files, runbooks, and SKILL.md files as primary source material. -- Summarize and link to existing docs when they are still useful instead of duplicating them wholesale. -- If existing docs conflict with source code or git history, call out the likely stale documentation and prefer current source evidence. +${OPENWIKI_CLI_REFERENCE} -${output.rootAgentInstructions} +If the user asks what the CLI can do, asks for commands/options/usage/examples, or asks for more details about OpenWiki itself, run \`openwiki --help\` with the available tools when possible and base your answer on the help output. If you cannot run the command, answer from the CLI reference above and say you could not verify live help output. + +${createSecuritySection(output)} +${createDocumentationGoalSections(output)} + +Mode-specific behavior: +${createModeInstructions(command, outputMode)} +`.trim(); +} + +export const OPENWIKI_CLI_REFERENCE = ` OpenWiki CLI reference: - \`openwiki\` opens the interactive chat interface and waits for user input. - \`openwiki "message"\` sends a chat message immediately, then keeps the chat open. @@ -114,16 +112,23 @@ OpenWiki CLI reference: - \`openwiki -p "message"\` or \`openwiki --print "message"\` runs once, prints the final assistant output, and exits. - \`openwiki --modelId \` selects a model ID for that run. - \`openwiki --help\` prints current usage, options, and examples. +`.trim(); -If the user asks what the CLI can do, asks for commands/options/usage/examples, or asks for more details about OpenWiki itself, run \`openwiki --help\` with the available tools when possible and base your answer on the help output. If you cannot run the command, answer from the CLI reference above and say you could not verify live help output. - +export function createSecuritySection(output: OutputPromptConfig): string { + return ` Security and privacy rules: - Do not read or document secret values, credentials, private keys, tokens, .env files, or other sensitive material. - Do not read .env files. .env.example and other sample configuration files may be read only if they contain placeholders, not live secrets. - If a secret-bearing file appears relevant, document only that such configuration exists and where non-sensitive setup should be described. - Keep all documentation under ${output.docsLocation}. - ${output.writeBoundaryInstruction} +`.trim(); +} +export function createDocumentationGoalSections( + output: OutputPromptConfig, +): string { + return ` Documentation goals: - Someone with zero knowledge of the wiki should be able to start at ${output.quickstartPath} and understand what the knowledge base covers, how it is organized, what it tracks, and where to go next. - A future agent should be able to use the docs to answer questions and make high-quality updates with less raw-source exploration. @@ -159,18 +164,36 @@ Coverage self-check: - Before finishing, verify that every identified area is either documented or backlogged. - Keep deferred areas in a concise \`## Backlog\` section at the end of ${output.quickstartPath}; do not create a separate backlog page. - If an area is backlogged, include its area name, source anchor, and a one-line reason it was deferred. +`.trim(); +} -Mode-specific behavior: -${createModeInstructions(command, outputMode)} +export function createStewardshipSections(output: OutputPromptConfig): string { + return ` +Planning discipline: +- After discovery and before writing final documentation, create a temporary ${output.planPath} file that lists the intended wiki pages, source evidence for each page, and remaining questions. +- Use ${output.planPath} when writing this temporary plan with filesystem tools. +- Before completing the run, delete ${output.planPath}. If there is no filesystem delete tool, use shell execute from the runtime root, for example ${output.removePlanCommand}. +- Do not leave ${output.planPath} in the final wiki. + +Git discipline: +- Use git heavily where it helps explain why code exists, not just what code exists. +- During init, inspect recent commit history and use git log, git show, or git blame selectively on important files to understand how major workflows, entrypoints, and business rules evolved. +- ${output.gitDisciplineInstruction} +- Use git status and git diff to account for uncommitted local changes, especially if they touch existing docs or important source files. +- Do not over-index on ancient history. Focus on recent commits and high-signal history for important files. + +Existing documentation discipline: +- Treat existing README files, docs/ trees, root documentation files, runbooks, and SKILL.md files as primary source material. +- Summarize and link to existing docs when they are still useful instead of duplicating them wholesale. +- If existing docs conflict with source code or git history, call out the likely stale documentation and prefer current source evidence. `.trim(); } export function createModeInstructions( command: OpenWikiCommand, outputMode: OpenWikiOutputMode = "local-wiki", + output: OutputPromptConfig = getOutputPromptConfig(outputMode), ): string { - const output = getOutputPromptConfig(outputMode); - if (command === "chat") { return ` - This is an interactive chat turn. @@ -225,9 +248,8 @@ export function createUserPrompt( context: RunContext, userMessage: string | null = null, outputMode: OpenWikiOutputMode = "local-wiki", + output: OutputPromptConfig = getOutputPromptConfig(outputMode), ): string { - const output = getOutputPromptConfig(outputMode); - if (command === "chat") { return userMessage?.trim() || "Start an OpenWiki chat."; } @@ -274,7 +296,7 @@ function formatWikiGoal(wikiGoal: string | undefined): string { return wikiGoal?.trim() || "(not provided)"; } -type OutputPromptConfig = { +export type OutputPromptConfig = { docsLocation: string; filesystemRootInstruction: string; gitDisciplineInstruction: string; @@ -294,7 +316,7 @@ type OutputPromptConfig = { writePathExample: string; }; -function getOutputPromptConfig( +export function getOutputPromptConfig( outputMode: OpenWikiOutputMode, ): OutputPromptConfig { if (outputMode === "local-wiki") { diff --git a/test/cli-runner-prompt.test.ts b/test/cli-runner-prompt.test.ts new file mode 100644 index 00000000..fa3a801c --- /dev/null +++ b/test/cli-runner-prompt.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "vitest"; +import { + createCliSystemPrompt, + createCliUserPrompt, + getCliOutputPromptConfig, +} from "../src/agent/cli-runner/prompt.ts"; +import type { RunContext } from "../src/agent/types.ts"; + +const CONTEXT: RunContext = { + gitSummary: "GIT-SUMMARY", + lastUpdate: null, +}; + +describe("getCliOutputPromptConfig", () => { + test("repository mode uses real repo-relative paths", () => { + const config = getCliOutputPromptConfig("repository"); + + expect(config.quickstartPath).toBe("openwiki/quickstart.md"); + expect(config.metadataPath).toBe("openwiki/.last-update.json"); + expect(config.planPath).toBe("openwiki/_plan.md"); + expect(config.removePlanCommand).toBe("rm -f openwiki/_plan.md"); + expect(config.filesystemRootInstruction).not.toContain("virtual"); + }); + + test("local-wiki mode uses cwd-relative paths", () => { + const config = getCliOutputPromptConfig("local-wiki"); + + expect(config.quickstartPath).toBe("quickstart.md"); + expect(config.metadataPath).toBe(".last-update.json"); + expect(config.removePlanCommand).toBe("rm -f _plan.md"); + expect(config.filesystemRootInstruction).not.toContain("virtual"); + }); +}); + +describe("createCliSystemPrompt", () => { + test("contains no virtual-path or langchain tool instructions", () => { + const prompt = createCliSystemPrompt("init", "repository", "claude-code"); + + expect(prompt).not.toContain("virtual path"); + expect(prompt).not.toContain("virtual filesystem"); + expect(prompt).not.toContain("write_file"); + expect(prompt).not.toContain("openwiki_ingest"); + expect(prompt).toContain("openwiki/quickstart.md"); + expect(prompt).toContain("Do not modify source code."); + }); + + test("keeps mode instructions per command", () => { + expect( + createCliSystemPrompt("init", "repository", "claude-code"), + ).toContain("initial documentation run"); + expect( + createCliSystemPrompt("update", "repository", "claude-code"), + ).toContain("maintenance update run"); + expect( + createCliSystemPrompt("chat", "repository", "claude-code"), + ).toContain("interactive chat turn"); + }); + + test("includes subagent guidance only for claude", () => { + expect( + createCliSystemPrompt("init", "repository", "claude-code"), + ).toContain("subagent"); + expect( + createCliSystemPrompt("init", "repository", "codex-cli"), + ).not.toContain("subagent"); + }); +}); + +describe("createCliUserPrompt", () => { + test("wraps the standard user prompt with a real-path runtime note", () => { + const prompt = createCliUserPrompt( + "init", + "/work/repo", + CONTEXT, + {}, + "repository", + ); + + expect(prompt).toContain("GIT-SUMMARY"); + expect(prompt).toContain("/work/repo"); + expect(prompt).toContain("current working directory"); + expect(prompt).not.toContain("virtual"); + }); + + test("followup chat message passes through untouched", () => { + const prompt = createCliUserPrompt( + "chat", + "/work/repo", + CONTEXT, + { isFollowup: true, userMessage: " follow up " }, + "repository", + ); + + expect(prompt).toBe("follow up"); + }); +}); From d19f961758a922351ec9b8fd774b4b92271936d8 Mon Sep 17 00:00:00 2001 From: Sangyeon Cho Date: Sun, 12 Jul 2026 16:02:26 +0900 Subject: [PATCH 06/17] fix: override remaining virtual-path fields in cli prompt config The CLI output config still inherited virtual-root wording from the base config: repository-mode rootAgentInstructions and writeBoundaryInstruction spliced "/openwiki", "/AGENTS.md", and "/CLAUDE.md" style paths into CLI prompts, and local-wiki writeBoundaryInstruction and localWikiSynthesisInstruction kept leading-slash canonical wiki refs and langchain-tool wording. Override all four with real relative-path equivalents (the synthesis block via a verbatim-preserving slash rewrite) and add regression tests asserting no leading-slash wiki paths remain in either mode's system and user prompts. Co-Authored-By: Claude Fable 5 --- src/agent/cli-runner/prompt.ts | 25 +++++++++ test/cli-runner-prompt.test.ts | 94 ++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/src/agent/cli-runner/prompt.ts b/src/agent/cli-runner/prompt.ts index 1bcd9d0b..1dd55a90 100644 --- a/src/agent/cli-runner/prompt.ts +++ b/src/agent/cli-runner/prompt.ts @@ -31,6 +31,14 @@ export function getCliOutputPromptConfig( planPath: "openwiki/_plan.md", quickstartPath: "openwiki/quickstart.md", removePlanCommand: "rm -f openwiki/_plan.md", + rootAgentInstructions: `Root agent instruction files: +- Do not create or update AGENTS.md or CLAUDE.md files at the repository root during normal code wiki runs. +- Keep generated wiki content under the repository openwiki/ directory. +- openwiki/INSTRUCTIONS.md is the shared, user-authored OpenWiki brief for this repository. Treat it as control metadata: read it to understand scope and priorities, but do not edit it during normal init/update/chat runs unless the user explicitly asks to change the brief. +- Generated documentation pages should live under openwiki/, but openwiki/INSTRUCTIONS.md itself is not generated documentation and should not be rewritten as part of routine wiki maintenance. +- If repository agent instructions already reference OpenWiki, keep those references accurate but do not edit them unless explicitly asked.`, + writeBoundaryInstruction: + "Do not modify source code. Write generated wiki pages only under the repository's openwiki/ directory.", writePathExample: "repository-relative paths under openwiki/, for example openwiki/quickstart.md or openwiki/architecture/overview.md", }; @@ -41,15 +49,32 @@ export function getCliOutputPromptConfig( docsLocation: "the local wiki directory (your current working directory)", filesystemRootInstruction: "You are running inside the local wiki directory (~/.openwiki/wiki) with your own native file and shell tools. Use real paths relative to the current working directory, such as quickstart.md, sources/gmail.md, and topics/ai-research.md. Do not create a nested openwiki/ directory.", + localWikiSynthesisInstruction: toCwdRelativeCanonicalPaths( + base.localWikiSynthesisInstruction, + ), metadataPath: ".last-update.json", planPath: "_plan.md", quickstartPath: "quickstart.md", removePlanCommand: "rm -f _plan.md", + writeBoundaryInstruction: + "Do not modify files outside the current working directory (~/.openwiki/wiki). The only source data outside this directory that may be inspected is connector raw data or explicit shell reads requested by the source-specific prompt.", writePathExample: "paths relative to the current working directory, for example quickstart.md or sources/gmail.md", }; } +/** + * Rewrites the base config's virtual-root canonical wiki references + * (/quickstart.md, /themes.md, /sources/.md, ...) to cwd-relative + * form for CLI runs, preserving the rest of the text verbatim. + */ +function toCwdRelativeCanonicalPaths(text: string): string { + return text.replace( + /(^|[\s(,])\/(quickstart\.md|open-questions\.md|themes\.md|commitments\.md|personal-logistics\.md|sources\/)/g, + "$1$2", + ); +} + export function createCliSystemPrompt( command: OpenWikiCommand, outputMode: OpenWikiOutputMode, diff --git a/test/cli-runner-prompt.test.ts b/test/cli-runner-prompt.test.ts index fa3a801c..0b12c43d 100644 --- a/test/cli-runner-prompt.test.ts +++ b/test/cli-runner-prompt.test.ts @@ -30,6 +30,42 @@ describe("getCliOutputPromptConfig", () => { expect(config.removePlanCommand).toBe("rm -f _plan.md"); expect(config.filesystemRootInstruction).not.toContain("virtual"); }); + + test("repository write boundary and root agent instructions use real paths", () => { + const config = getCliOutputPromptConfig("repository"); + + expect(config.writeBoundaryInstruction).not.toMatch(/[\s(]\/openwiki\b/); + expect(config.writeBoundaryInstruction).toContain( + "under the repository's openwiki/ directory", + ); + expect(config.rootAgentInstructions).not.toMatch( + /[\s(]\/(?:openwiki\b|AGENTS\.md|CLAUDE\.md)/, + ); + expect(config.rootAgentInstructions).toContain("openwiki/INSTRUCTIONS.md"); + expect(config.rootAgentInstructions).toContain( + "AGENTS.md or CLAUDE.md files at the repository root", + ); + }); + + test("local-wiki write boundary and synthesis block use cwd-relative paths", () => { + const config = getCliOutputPromptConfig("local-wiki"); + + expect(config.writeBoundaryInstruction).not.toContain("filesystem tools"); + expect(config.writeBoundaryInstruction).toContain( + "current working directory", + ); + expect(config.localWikiSynthesisInstruction).not.toMatch( + /[\s(]\/(?:quickstart|open-questions|themes|commitments|personal-logistics)\.md/, + ); + expect(config.localWikiSynthesisInstruction).not.toContain("/sources/"); + expect(config.localWikiSynthesisInstruction).toContain("- quickstart.md:"); + expect(config.localWikiSynthesisInstruction).toContain( + "- sources/.md:", + ); + expect(config.localWikiSynthesisInstruction).toContain( + "read open-questions.md if it exists", + ); + }); }); describe("createCliSystemPrompt", () => { @@ -64,6 +100,33 @@ describe("createCliSystemPrompt", () => { createCliSystemPrompt("init", "repository", "codex-cli"), ).not.toContain("subagent"); }); + + test("repository system prompt never uses leading-slash wiki paths", () => { + const prompt = createCliSystemPrompt("init", "repository", "claude-code"); + + expect(prompt).not.toMatch(/[\s(]\/openwiki\b/); + expect(prompt).not.toMatch(/[\s(]\/(?:AGENTS|CLAUDE)\.md/); + expect(prompt).toContain("openwiki/INSTRUCTIONS.md"); + expect(prompt).toContain( + "AGENTS.md or CLAUDE.md files at the repository root", + ); + expect(prompt).toContain("only under the repository's openwiki/ directory"); + }); + + test("local-wiki system prompt uses the real cwd write boundary", () => { + const prompt = createCliSystemPrompt("update", "local-wiki", "claude-code"); + + expect(prompt).not.toContain("virtual"); + expect(prompt).not.toContain("constrained connector tools"); + expect(prompt).not.toMatch( + /[\s(]\/(?:quickstart|open-questions|themes|commitments|personal-logistics|sources|_plan)\b/, + ); + expect(prompt).toContain( + "Do not modify files outside the current working directory", + ); + expect(prompt).toContain("rm -f _plan.md"); + expect(prompt).toContain("such as quickstart.md"); + }); }); describe("createCliUserPrompt", () => { @@ -82,6 +145,37 @@ describe("createCliUserPrompt", () => { expect(prompt).not.toContain("virtual"); }); + test("repository runtime note never uses leading-slash wiki paths", () => { + const prompt = createCliUserPrompt( + "init", + "/work/repo", + CONTEXT, + {}, + "repository", + ); + + expect(prompt).not.toMatch(/[\s(]\/openwiki\b/); + expect(prompt).toContain("only under the repository's openwiki/ directory"); + }); + + test("local-wiki runtime note uses the real cwd write boundary", () => { + const prompt = createCliUserPrompt( + "update", + "/home/user/.openwiki/wiki", + CONTEXT, + {}, + "local-wiki", + ); + + expect(prompt).toContain("Local wiki root"); + expect(prompt).toContain("/home/user/.openwiki/wiki"); + expect(prompt).toContain( + "Do not modify files outside the current working directory", + ); + expect(prompt).not.toContain("virtual"); + expect(prompt).not.toContain("constrained connector tools"); + }); + test("followup chat message passes through untouched", () => { const prompt = createCliUserPrompt( "chat", From cdae2b8b4498401cf574b765481c80aca8a254a0 Mon Sep 17 00:00:00 2001 From: Sangyeon Cho Date: Sun, 12 Jul 2026 16:14:37 +0900 Subject: [PATCH 07/17] feat: add cli runner core with streaming, timeout and session resume Add stdinPayload to CliEngineAdapter (claude: user prompt only; codex: system+user combined) and implement src/agent/cli-runner/index.ts: executeCliRun spawns the headless CLI, streams stdout via readline, buffers the last 2048 bytes of stderr, enforces a SIGTERM->SIGKILL timeout, backfills tool_end names from tool_start, and retries once with a fresh session when a resumed run fails. runOpenWikiCliAgent builds prompts, resolves/saves sessions, snapshots wiki content and writes last-update metadata (model provider/modelId). ensureCliBinaryAvailable probes the CLI --version and surfaces an install hint on ENOENT. Co-Authored-By: Claude Fable 5 --- src/agent/cli-runner/claude.ts | 4 + src/agent/cli-runner/codex.ts | 4 + src/agent/cli-runner/index.ts | 271 ++++++++++++++++++++++++++++++++ src/agent/cli-runner/types.ts | 2 + test/cli-runner-claude.test.ts | 6 + test/cli-runner-codex.test.ts | 6 + test/cli-runner-execute.test.ts | 165 +++++++++++++++++++ 7 files changed, 458 insertions(+) create mode 100644 src/agent/cli-runner/index.ts create mode 100644 test/cli-runner-execute.test.ts diff --git a/src/agent/cli-runner/claude.ts b/src/agent/cli-runner/claude.ts index 97fda627..a12512af 100644 --- a/src/agent/cli-runner/claude.ts +++ b/src/agent/cli-runner/claude.ts @@ -49,6 +49,10 @@ export const claudeAdapter: CliEngineAdapter = { return args; }, + stdinPayload(spec: CliRunSpec): string { + return spec.userPrompt; + }, + parseLine(line: string): CliParsedEvent[] { const trimmed = line.trim(); diff --git a/src/agent/cli-runner/codex.ts b/src/agent/cli-runner/codex.ts index da123aec..4af8f0d0 100644 --- a/src/agent/cli-runner/codex.ts +++ b/src/agent/cli-runner/codex.ts @@ -19,6 +19,10 @@ export const codexAdapter: CliEngineAdapter = { : ["exec", ...flags, "-"]; }, + stdinPayload(spec: CliRunSpec): string { + return `${spec.systemPrompt}\n\n${spec.userPrompt}`; + }, + parseLine(line: string): CliParsedEvent[] { const trimmed = line.trim(); diff --git a/src/agent/cli-runner/index.ts b/src/agent/cli-runner/index.ts new file mode 100644 index 00000000..240f3ed9 --- /dev/null +++ b/src/agent/cli-runner/index.ts @@ -0,0 +1,271 @@ +import { execFile, spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import { promisify } from "node:util"; +import { + getProviderCliCommand, + getProviderLabel, + providerUsesCliAuth, + resolveCliTimeoutSeconds, + type OpenWikiProvider, +} from "../../constants.js"; +import { + createOpenWikiContentSnapshot, + createRunContext, + writeLastUpdateMetadata, +} from "../utils.js"; +import type { + OpenWikiCommand, + OpenWikiRunOptions, + OpenWikiRunResult, +} from "../types.js"; +import { claudeAdapter } from "./claude.js"; +import { codexAdapter } from "./codex.js"; +import { createCliSystemPrompt, createCliUserPrompt } from "./prompt.js"; +import { getCliSession, saveCliSession } from "./sessions.js"; +import type { CliEngineAdapter, CliRunSpec } from "./types.js"; + +const execFileAsync = promisify(execFile); + +const CLI_INSTALL_HINTS: Record = { + claude: "npm install -g @anthropic-ai/claude-code", + codex: "npm install -g @openai/codex", +}; + +export async function ensureCliBinaryAvailable( + provider: OpenWikiProvider, +): Promise { + const cliCommand = getProviderCliCommand(provider); + + if (!providerUsesCliAuth(provider) || !cliCommand) { + throw new Error(`${provider} is not a CLI-based provider.`); + } + + try { + await execFileAsync(cliCommand, ["--version"], { timeout: 10_000 }); + } catch (error) { + if (isSpawnNotFoundError(error)) { + const hint = CLI_INSTALL_HINTS[cliCommand]; + + throw new Error( + `${cliCommand} CLI not found. Install it${hint ? ` with: ${hint}` : ""} and sign in, then retry. It is required to run OpenWiki with ${getProviderLabel(provider)}.`, + { cause: error }, + ); + } + + throw error; + } +} + +function isSpawnNotFoundError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + (error as NodeJS.ErrnoException).code === "ENOENT" + ); +} + +function getEngineAdapter(provider: OpenWikiProvider): CliEngineAdapter { + if (provider === "claude-code") { + return claudeAdapter; + } + + if (provider === "codex-cli") { + return codexAdapter; + } + + throw new Error(`No CLI engine adapter for provider ${provider}.`); +} + +export async function runOpenWikiCliAgent( + command: OpenWikiCommand, + cwd: string, + options: OpenWikiRunOptions, + run: { modelId: string; provider: OpenWikiProvider }, +): Promise { + const outputMode = options.outputMode ?? "local-wiki"; + const model = `${run.provider}/${run.modelId}`; + const adapter = getEngineAdapter(run.provider); + const context = await createRunContext(command, cwd, outputMode); + const snapshotBefore = + command === "chat" + ? null + : await createOpenWikiContentSnapshot(cwd, outputMode); + + emitDebug(options, `cli.engine=${run.provider} model=${run.modelId}`); + + const resumeSessionId = + options.isFollowup === true && options.threadId + ? await getCliSession(options.threadId, run.provider) + : null; + + const spec: CliRunSpec = { + command, + cwd, + modelId: run.modelId, + outputMode, + resumeSessionId, + systemPrompt: createCliSystemPrompt(command, outputMode, run.provider), + userPrompt: createCliUserPrompt(command, cwd, context, options, outputMode), + }; + + const result = await executeCliRun(adapter, spec, options); + + if (options.threadId && result.sessionId) { + await saveCliSession(options.threadId, run.provider, result.sessionId); + } + + if ( + command !== "chat" && + snapshotBefore !== (await createOpenWikiContentSnapshot(cwd, outputMode)) + ) { + await writeLastUpdateMetadata(command, cwd, model, outputMode); + emitDebug(options, "cli.metadata=written"); + } + + return { command, model }; +} + +export async function executeCliRun( + adapter: CliEngineAdapter, + spec: CliRunSpec, + options: OpenWikiRunOptions, + spawnCommand: string = adapter.cliCommand, +): Promise<{ sessionId: string | null }> { + try { + return await executeCliRunOnce(adapter, spec, options, spawnCommand); + } catch (error) { + if (!spec.resumeSessionId) { + throw error; + } + + // Expired/unknown sessions surface as CLI failures; fall back to a + // fresh session once. + emitDebug( + options, + `cli.resume failed (${getErrorMessage(error)}); retrying with a new session`, + ); + + return executeCliRunOnce( + adapter, + { ...spec, resumeSessionId: null }, + options, + spawnCommand, + ); + } +} + +async function executeCliRunOnce( + adapter: CliEngineAdapter, + spec: CliRunSpec, + options: OpenWikiRunOptions, + spawnCommand: string, +): Promise<{ sessionId: string | null }> { + const timeoutMs = resolveCliTimeoutSeconds() * 1000; + + return new Promise((resolve, reject) => { + const child = spawn(spawnCommand, adapter.buildArgs(spec), { + cwd: spec.cwd, + env: process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + + let sessionId: string | null = null; + let resultError: string | null = null; + let stderrTail = ""; + let settled = false; + let timedOut = false; + const toolNames = new Map(); + + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + setTimeout(() => child.kill("SIGKILL"), 5_000).unref(); + }, timeoutMs); + + const settle = (error: Error | null) => { + if (settled) { + return; + } + + settled = true; + clearTimeout(timer); + + if (error) { + reject(error); + } else { + resolve({ sessionId }); + } + }; + + child.on("error", (error) => settle(error)); + + child.stderr.on("data", (chunk: Buffer) => { + stderrTail = `${stderrTail}${chunk.toString("utf8")}`.slice(-2048); + }); + + const lines = createInterface({ input: child.stdout }); + + lines.on("line", (line) => { + for (const parsed of adapter.parseLine(line)) { + if (parsed.kind === "session") { + sessionId = parsed.sessionId; + } else if (parsed.kind === "result") { + if (parsed.isError) { + resultError = parsed.message || "CLI agent reported an error."; + } + } else if (parsed.kind === "event") { + const event = parsed.event; + + if (event.type === "tool_start") { + toolNames.set(event.id, event.name); + } + + if (event.type === "debug" && options.debug !== true) { + continue; + } + + options.onEvent?.( + event.type === "tool_end" && event.name === "tool" + ? { ...event, name: toolNames.get(event.id) ?? event.name } + : event, + ); + } + } + }); + + child.on("close", (code) => { + if (timedOut) { + settle( + new Error( + `${spawnCommand} run timed out after ${timeoutMs / 1000}s and was killed.`, + ), + ); + } else if (code !== 0) { + settle( + new Error( + `${spawnCommand} exited with exit code ${code ?? "unknown"}.${ + stderrTail ? `\nstderr:\n${stderrTail}` : "" + }`, + ), + ); + } else if (resultError) { + settle(new Error(`CLI agent failed: ${resultError}`)); + } else { + settle(null); + } + }); + + child.stdin.write(adapter.stdinPayload(spec)); + child.stdin.end(); + }); +} + +function emitDebug(options: OpenWikiRunOptions, message: string): void { + if (options.debug === true) { + options.onEvent?.({ type: "debug", message }); + } +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/agent/cli-runner/types.ts b/src/agent/cli-runner/types.ts index 589b87f3..6ea92315 100644 --- a/src/agent/cli-runner/types.ts +++ b/src/agent/cli-runner/types.ts @@ -27,4 +27,6 @@ export type CliEngineAdapter = { buildArgs(spec: CliRunSpec): string[]; /** Parse one stdout line into zero or more parsed events. Must not throw. */ parseLine(line: string): CliParsedEvent[]; + /** What to write to the CLI's stdin (the prompt channel). */ + stdinPayload(spec: CliRunSpec): string; }; diff --git a/test/cli-runner-claude.test.ts b/test/cli-runner-claude.test.ts index 2b0254ce..0e890bbf 100644 --- a/test/cli-runner-claude.test.ts +++ b/test/cli-runner-claude.test.ts @@ -107,6 +107,12 @@ describe("claudeAdapter.buildArgs", () => { }); }); +describe("claudeAdapter.stdinPayload", () => { + test("stdin carries only the user prompt", () => { + expect(claudeAdapter.stdinPayload(BASE_SPEC)).toBe("USER"); + }); +}); + describe("claudeAdapter.parseLine", () => { test("captures session id from system init", () => { expect(claudeAdapter.parseLine(INIT_LINE)).toEqual([ diff --git a/test/cli-runner-codex.test.ts b/test/cli-runner-codex.test.ts index 5b880d90..ff1ddc35 100644 --- a/test/cli-runner-codex.test.ts +++ b/test/cli-runner-codex.test.ts @@ -79,6 +79,12 @@ describe("codexAdapter.buildArgs", () => { }); }); +describe("codexAdapter.stdinPayload", () => { + test("stdin carries system and user prompt combined", () => { + expect(codexAdapter.stdinPayload(BASE_SPEC)).toBe("SYSTEM\n\nUSER"); + }); +}); + describe("codexAdapter.parseLine", () => { test("captures thread id as session", () => { expect(codexAdapter.parseLine(THREAD_LINE)).toEqual([ diff --git a/test/cli-runner-execute.test.ts b/test/cli-runner-execute.test.ts new file mode 100644 index 00000000..c2d38184 --- /dev/null +++ b/test/cli-runner-execute.test.ts @@ -0,0 +1,165 @@ +import { mkdtemp, writeFile, chmod } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { executeCliRun } from "../src/agent/cli-runner/index.ts"; +import { claudeAdapter } from "../src/agent/cli-runner/claude.ts"; +import type { OpenWikiRunEvent } from "../src/agent/types.ts"; +import type { CliRunSpec } from "../src/agent/cli-runner/types.ts"; + +const SPEC: CliRunSpec = { + command: "init", + cwd: os.tmpdir(), + modelId: "sonnet", + outputMode: "repository", + resumeSessionId: null, + systemPrompt: "SYSTEM", + userPrompt: "USER", +}; + +/** + * Creates an executable node script that ignores argv, echoes the given + * stdout lines, and exits with the given code. + */ +async function makeStubCli(lines: string[], exitCode = 0): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), "openwiki-stub-cli-")); + const scriptPath = path.join(dir, "stub-cli"); + const body = [ + "#!/usr/bin/env node", + "process.stdin.resume();", + "process.stdin.on('end', () => {", + ` for (const line of ${JSON.stringify(lines)}) { console.log(line); }`, + ` process.exit(${exitCode});`, + "});", + "process.stdin.on('data', () => {});", + ].join("\n"); + + await writeFile(scriptPath, body, "utf8"); + await chmod(scriptPath, 0o755); + + return scriptPath; +} + +describe("executeCliRun", () => { + test("streams parsed events and captures the session id", async () => { + const stub = await makeStubCli([ + JSON.stringify({ type: "system", subtype: "init", session_id: "s-1" }), + JSON.stringify({ + type: "assistant", + message: { content: [{ type: "text", text: "hello" }] }, + }), + JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + result: "hello", + }), + ]); + const events: OpenWikiRunEvent[] = []; + + const result = await executeCliRun( + claudeAdapter, + SPEC, + { onEvent: (event) => events.push(event) }, + stub, + ); + + expect(result.sessionId).toBe("s-1"); + expect(events.some((e) => e.type === "text" && e.text === "hello")).toBe( + true, + ); + }); + + test("fills tool_end names from the matching tool_start", async () => { + const stub = await makeStubCli([ + JSON.stringify({ + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "t1", + name: "Bash", + input: { command: "ls" }, + }, + ], + }, + }), + JSON.stringify({ + type: "user", + message: { + content: [ + { type: "tool_result", tool_use_id: "t1", is_error: false }, + ], + }, + }), + JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + result: "", + }), + ]); + const events: OpenWikiRunEvent[] = []; + + await executeCliRun( + claudeAdapter, + SPEC, + { onEvent: (e) => events.push(e) }, + stub, + ); + + const toolEnd = events.find((e) => e.type === "tool_end"); + expect(toolEnd).toMatchObject({ name: "Bash", status: "finished" }); + }); + + test("throws with stderr tail on nonzero exit", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "openwiki-stub-cli-")); + const scriptPath = path.join(dir, "stub-cli"); + await writeFile( + scriptPath, + "#!/usr/bin/env node\nprocess.stderr.write('login required');\nprocess.exit(2);\n", + "utf8", + ); + await chmod(scriptPath, 0o755); + + await expect( + executeCliRun(claudeAdapter, SPEC, {}, scriptPath), + ).rejects.toThrow(/exit code 2[\s\S]*login required/); + }); + + test("throws when the agent reports an error result", async () => { + const stub = await makeStubCli([ + JSON.stringify({ + type: "result", + subtype: "error_max_turns", + is_error: true, + result: "too long", + }), + ]); + + await expect(executeCliRun(claudeAdapter, SPEC, {}, stub)).rejects.toThrow( + /too long/, + ); + }); + + test("retries once without resume when a resumed run fails", async () => { + const failingStub = await makeStubCli([], 1); + const events: OpenWikiRunEvent[] = []; + + await expect( + executeCliRun( + claudeAdapter, + { ...SPEC, resumeSessionId: "expired" }, + { debug: true, onEvent: (e) => events.push(e) }, + failingStub, + ), + ).rejects.toThrow(); + + expect( + events.some( + (e) => e.type === "debug" && e.message.includes("resume failed"), + ), + ).toBe(true); + }); +}); From f2567abdbd0c1ad2f2a0cab6578a197604018b56 Mon Sep 17 00:00:00 2001 From: Sangyeon Cho Date: Sun, 12 Jul 2026 16:22:14 +0900 Subject: [PATCH 08/17] fix: swallow cli stdin EPIPE so early exits reject instead of crashing child.on("error") only covers the ChildProcess emitter, not the stdin stream. When the CLI exits before consuming stdin (e.g. an instant auth failure) while a multi-KB prompt is still flushing, the write emits EPIPE on a listener-less stream and crashes the whole process via uncaughtException. Register a no-op stdin error handler and let the close handler settle the run with the real exit-code/stderr failure. Regression test drives a 1MB stdin payload into a stub that exits immediately without reading. Co-Authored-By: Claude Fable 5 --- src/agent/cli-runner/index.ts | 8 ++++++++ test/cli-runner-execute.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/agent/cli-runner/index.ts b/src/agent/cli-runner/index.ts index 240f3ed9..d7b3ac68 100644 --- a/src/agent/cli-runner/index.ts +++ b/src/agent/cli-runner/index.ts @@ -255,6 +255,14 @@ async function executeCliRunOnce( } }); + // The CLI can exit before consuming stdin (e.g. an instant auth + // failure), so flushing the prompt hits a closed pipe and emits EPIPE + // on the stdin stream, which child.on("error") does not cover. Without + // a listener that becomes an uncaughtException that crashes the whole + // process. Swallow stdin errors and let the close handler settle with + // the more informative exit-code/stderr failure. + child.stdin.on("error", () => {}); + child.stdin.write(adapter.stdinPayload(spec)); child.stdin.end(); }); diff --git a/test/cli-runner-execute.test.ts b/test/cli-runner-execute.test.ts index c2d38184..b9906199 100644 --- a/test/cli-runner-execute.test.ts +++ b/test/cli-runner-execute.test.ts @@ -128,6 +128,29 @@ describe("executeCliRun", () => { ).rejects.toThrow(/exit code 2[\s\S]*login required/); }); + test("rejects cleanly when the CLI exits without reading a large stdin payload", async () => { + // The stub exits immediately and never consumes stdin, so flushing a + // payload larger than the pipe buffer hits a closed fd (EPIPE). Without + // a stdin error handler that crashes the process instead of rejecting. + const dir = await mkdtemp(path.join(os.tmpdir(), "openwiki-stub-cli-")); + const scriptPath = path.join(dir, "stub-cli"); + await writeFile( + scriptPath, + "#!/usr/bin/env node\nprocess.stderr.write('auth failed');\nprocess.exit(2);\n", + "utf8", + ); + await chmod(scriptPath, 0o755); + + const largeSpec: CliRunSpec = { + ...SPEC, + userPrompt: "U".repeat(1024 * 1024), + }; + + await expect( + executeCliRun(claudeAdapter, largeSpec, {}, scriptPath), + ).rejects.toThrow(/exit code 2[\s\S]*auth failed/); + }); + test("throws when the agent reports an error result", async () => { const stub = await makeStubCli([ JSON.stringify({ From 4eb45a9132a10ee8af2524ea294b55d04fc37d25 Mon Sep 17 00:00:00 2001 From: Sangyeon Cho Date: Sun, 12 Jul 2026 16:29:32 +0900 Subject: [PATCH 09/17] feat: route cli providers to the headless cli runner Co-Authored-By: Claude Fable 5 --- src/agent/index.ts | 24 ++++++++++++++ test/cli-runner-integration.test.ts | 51 +++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 test/cli-runner-integration.test.ts diff --git a/src/agent/index.ts b/src/agent/index.ts index 1c9e37ca..8f519d06 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -17,6 +17,10 @@ import { } from "../env.js"; import { isFileNotFoundError } from "../fs-errors.js"; import { openWikiLocalWikiDir } from "../openwiki-home.js"; +import { + ensureCliBinaryAvailable, + runOpenWikiCliAgent, +} from "./cli-runner/index.js"; import { OpenWikiLocalShellBackend } from "./docs-only-backend.js"; import { CODEX_ORIGINATOR, @@ -50,6 +54,7 @@ import { OPENWIKI_PROVIDER_ENV_KEY, OPENWIKI_PROVIDER_RETRY_ATTEMPTS_ENV_KEY, providerRequiresBaseUrl, + providerUsesCliAuth, resolveConfiguredProvider, resolveProviderBaseUrl, resolveProviderRetryAttempts, @@ -111,6 +116,19 @@ export async function runOpenWikiAgent( if (providerBaseUrl) { emitDebug(options, `provider.baseUrl=${JSON.stringify(providerBaseUrl)}`); } + + if (providerUsesCliAuth(provider)) { + await ensureCliBinaryAvailable(provider); + emitDebug(options, `credentials=${provider} cli available`); + const cliModelId = resolveModelId(options, provider); + emitDebug(options, `model=${cliModelId}`); + + return await runOpenWikiCliAgent(command, runtimeCwd, options, { + modelId: cliModelId, + provider, + }); + } + ensureProviderKey(provider); emitDebug(options, `credentials=${provider} key present`); ensureProviderBaseUrl(provider); @@ -422,6 +440,12 @@ function createModel( modelId: string, providerRetryAttempts: number, ) { + if (providerUsesCliAuth(provider)) { + throw new Error( + "CLI providers are executed by runOpenWikiCliAgent, not createModel.", + ); + } + const retryOptions = { maxRetries: providerRetryAttempts }; if (provider === "anthropic") { diff --git a/test/cli-runner-integration.test.ts b/test/cli-runner-integration.test.ts new file mode 100644 index 00000000..e9254251 --- /dev/null +++ b/test/cli-runner-integration.test.ts @@ -0,0 +1,51 @@ +import { mkdtemp } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { runOpenWikiAgent } from "../src/agent/index.ts"; +import { ensureCliBinaryAvailable } from "../src/agent/cli-runner/index.ts"; + +const ENV_KEYS = ["OPENWIKI_PROVIDER", "PATH", "HOME"] as const; +const savedEnv = new Map(ENV_KEYS.map((key) => [key, process.env[key]])); + +afterEach(() => { + for (const [key, value] of savedEnv) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); + +describe("cli provider routing", () => { + test("cli provider does not require an API key env var", async () => { + process.env.OPENWIKI_PROVIDER = "claude-code"; + // Empty PATH: the run must fail on the missing CLI binary, not on a + // missing API key. That proves the branch skipped ensureProviderKey. + process.env.PATH = "/nonexistent"; + // Isolated HOME so loadOpenWikiEnv cannot pick up a real ~/.openwiki/.env. + process.env.HOME = await mkdtemp(path.join(os.tmpdir(), "openwiki-home-")); + + await expect( + runOpenWikiAgent("chat", process.cwd(), { + outputMode: "repository", + userMessage: "hello", + }), + ).rejects.toThrow(/claude CLI not found/); + }); + + test("ensureCliBinaryAvailable names the install command", async () => { + process.env.PATH = "/nonexistent"; + + await expect(ensureCliBinaryAvailable("codex-cli")).rejects.toThrow( + /codex CLI not found[\s\S]*@openai\/codex/, + ); + }); + + test("non-cli providers are rejected by ensureCliBinaryAvailable", async () => { + await expect(ensureCliBinaryAvailable("openai")).rejects.toThrow( + /not a CLI-based provider/, + ); + }); +}); From d080dbb9c69b95c03af31dac8652d4d012b5b26c Mon Sep 17 00:00:00 2001 From: Sangyeon Cho Date: Sun, 12 Jul 2026 16:35:54 +0900 Subject: [PATCH 10/17] feat: support cli auth providers in the onboarding wizard --- src/credentials.tsx | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/credentials.tsx b/src/credentials.tsx index 9b3ae8bf..3a3e741c 100644 --- a/src/credentials.tsx +++ b/src/credentials.tsx @@ -11,6 +11,7 @@ import { getDefaultModelId, getProviderApiKeyEnvKey, getProviderBaseUrlEnvKey, + getProviderCliCommand, getProviderLabel, getProviderModelOptions, isValidBaseUrl, @@ -27,6 +28,7 @@ import { OPENWIKI_X_CLIENT_ID_ENV_KEY, type OpenWikiProvider, providerRequiresBaseUrl, + providerUsesCliAuth, providerUsesOAuth, resolveConfiguredProvider, SELECTABLE_OPENWIKI_PROVIDERS, @@ -380,12 +382,21 @@ export function needsCredentialSetup( * else it is a pasted API key. */ function needsCredentialStep(provider: OpenWikiProvider): boolean { + if (providerUsesCliAuth(provider)) { + // CLI providers authenticate through the CLI's own login; the run + // itself validates availability with a clear error. + return false; + } + return providerUsesOAuth(provider) ? !hasValidStoredToken() : !process.env[getProviderApiKeyEnvKey(provider)]; } -/** The step that collects the provider's primary credential. */ +/** + * The step that collects the provider's primary credential. CLI providers + * never reach this because `needsCredentialStep` returns false for them. + */ function credentialStep(provider: OpenWikiProvider): PromptStep { return providerUsesOAuth(provider) ? "oauth-login" : "api-key"; } @@ -411,6 +422,10 @@ function isBaseUrlConfigured(provider: OpenWikiProvider): boolean { } function isCredentialConfigured(provider: OpenWikiProvider): boolean { + if (providerUsesCliAuth(provider)) { + return true; + } + return providerUsesOAuth(provider) ? hasValidStoredToken() : Boolean(process.env[getProviderApiKeyEnvKey(provider)]); @@ -420,6 +435,10 @@ function getCredentialSetupDetail( provider: OpenWikiProvider, tokens: CodexTokens | null = null, ): string { + if (providerUsesCliAuth(provider)) { + return `uses your local ${getProviderCliCommand(provider)} login`; + } + if (providerUsesOAuth(provider)) { if (!isCredentialConfigured(provider) && !tokens) { return "sign in with your ChatGPT account"; @@ -1955,7 +1974,13 @@ export function InitSetup({ detail={getProviderSetupDetail(provider)} /> Date: Sun, 12 Jul 2026 16:47:33 +0900 Subject: [PATCH 11/17] fix: exempt cli-auth providers from non-interactive api-key gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The non-interactive startup gate required the configured provider's API key env var to be set for --print / non-TTY runs. CLI-auth providers (claude-code, codex-cli) have no API key (auth is their own CLI login, validated at run time by ensureCliBinaryAvailable), so their placeholder key env var is never set and every non-interactive run — including the CI `code --update --print` path — failed fast with "OPENWIKI_CLI_AUTH_UNUSED is required for non-interactive runs". Skip the API-key gate when providerUsesCliAuth(provider). Co-Authored-By: Claude Fable 5 --- src/startup.ts | 9 +++++++++ test/startup.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/startup.ts b/src/startup.ts index 4302ba74..af7d7a47 100644 --- a/src/startup.ts +++ b/src/startup.ts @@ -2,6 +2,7 @@ import { shouldCheckUpdateNoop, getUpdateNoopStatus } from "./agent/utils.js"; import type { CliCommand } from "./commands.js"; import { getProviderApiKeyEnvKey, + providerUsesCliAuth, resolveConfiguredProvider, } from "./constants.js"; @@ -37,6 +38,14 @@ export async function resolveStartupCommand( (command.print || !isStdinTTY) ) { const provider = resolveConfiguredProvider(); + + // CLI-auth providers (claude-code, codex-cli) have no API key: they sign in + // through their own CLI login, which is validated at run time by + // ensureCliBinaryAvailable. Skip the API-key gate for them. + if (providerUsesCliAuth(provider)) { + return command; + } + const apiKeyEnvKey = getProviderApiKeyEnvKey(provider); const hasProviderKey = Boolean(process.env[apiKeyEnvKey]); diff --git a/test/startup.test.ts b/test/startup.test.ts index 2725a0b2..c4907a5e 100644 --- a/test/startup.test.ts +++ b/test/startup.test.ts @@ -134,6 +134,28 @@ describe("resolveStartupCommand", () => { } }); + test("allows non-interactive runs for CLI-auth providers without a key", async () => { + process.env.OPENWIKI_PROVIDER = "claude-code"; + const repo = await createRepoWithOpenWiki(); + + // A CLI-auth provider authenticates via its own login (validated at + // runtime), so the API-key gate must not block --print runs even when the + // update is NOT a clean no-op (source changed) and a message is supplied. + await writeFile( + path.join(repo, "README.md"), + "# Test Repo\nChanged\n", + "utf8", + ); + + const command = updatePrintCommand({ userMessage: "refresh docs" }); + const result = await resolveStartupCommand(command, { + cwd: repo, + isStdinTTY: false, + }); + + expect(result).toBe(command); + }); + test("still requires credentials when an update message is supplied", async () => { const repo = await createRepoWithOpenWiki(); const head = await git(repo, ["rev-parse", "HEAD"]); From 803d022562ec05909142e29a0d17b7bdaf2fd501 Mon Sep 17 00:00:00 2001 From: Sangyeon Cho Date: Sun, 12 Jul 2026 18:02:52 +0900 Subject: [PATCH 12/17] fix: scope cli-auth exemption to the api-key gate only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cli-auth exemption in resolveStartupCommand was an early `return command`, which exited the whole gate function — so CLI-auth providers in the non-interactive path also skipped the shared empty-message validation below. A whitespace-only message (`--update -m " " --print`) sailed through for claude-code/codex-cli while being rejected for api-key providers, and made cli-auth behavior inconsistent between TTY and non-TTY modes. Wrap only the api-key gate block in `if (!providerUsesCliAuth(provider))` so CLI-auth providers still flow through the empty-message validation. Co-Authored-By: Claude Fable 5 --- src/startup.ts | 41 ++++++++++++++++++++--------------------- test/startup.test.ts | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/startup.ts b/src/startup.ts index af7d7a47..7085be76 100644 --- a/src/startup.ts +++ b/src/startup.ts @@ -41,30 +41,29 @@ export async function resolveStartupCommand( // CLI-auth providers (claude-code, codex-cli) have no API key: they sign in // through their own CLI login, which is validated at run time by - // ensureCliBinaryAvailable. Skip the API-key gate for them. - if (providerUsesCliAuth(provider)) { - return command; - } + // ensureCliBinaryAvailable. Skip only the API-key gate for them, so they + // still flow through the shared validations below (e.g. empty message). + if (!providerUsesCliAuth(provider)) { + const apiKeyEnvKey = getProviderApiKeyEnvKey(provider); + const hasProviderKey = Boolean(process.env[apiKeyEnvKey]); - const apiKeyEnvKey = getProviderApiKeyEnvKey(provider); - const hasProviderKey = Boolean(process.env[apiKeyEnvKey]); + if (!hasProviderKey) { + if ( + command.print && + (await canSkipCleanUpdateBeforeCredentials( + command, + options.cwd ?? process.cwd(), + )) + ) { + return command; + } - if (!hasProviderKey) { - if ( - command.print && - (await canSkipCleanUpdateBeforeCredentials( - command, - options.cwd ?? process.cwd(), - )) - ) { - return command; + return { + kind: "error", + exitCode: 1, + message: `${apiKeyEnvKey} is required for non-interactive runs. Run openwiki in an interactive terminal to save credentials.`, + }; } - - return { - kind: "error", - exitCode: 1, - message: `${apiKeyEnvKey} is required for non-interactive runs. Run openwiki in an interactive terminal to save credentials.`, - }; } } diff --git a/test/startup.test.ts b/test/startup.test.ts index c4907a5e..14ac7d41 100644 --- a/test/startup.test.ts +++ b/test/startup.test.ts @@ -156,6 +156,26 @@ describe("resolveStartupCommand", () => { expect(result).toBe(command); }); + test("still rejects whitespace-only messages for CLI-auth providers", async () => { + process.env.OPENWIKI_PROVIDER = "claude-code"; + const repo = await createRepoWithOpenWiki(); + + // The CLI-auth exemption covers only the API-key gate; the empty-message + // validation must still apply, exactly as it does for api-key providers. + const result = await resolveStartupCommand( + updatePrintCommand({ userMessage: " " }), + { + cwd: repo, + isStdinTTY: false, + }, + ); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toBe("User message cannot be empty."); + } + }); + test("still requires credentials when an update message is supplied", async () => { const repo = await createRepoWithOpenWiki(); const head = await git(repo, ["rev-parse", "HEAD"]); From 86372a7f6796e267f8e7bc29f7eef765919a5e82 Mon Sep 17 00:00:00 2001 From: Sangyeon Cho Date: Sun, 12 Jul 2026 18:23:28 +0900 Subject: [PATCH 13/17] feat: warn on out-of-wiki writes after repository cli runs The API backend structurally blocks non-doc writes, but the CLI path only relies on the prompt plus allowedTools/sandbox, and the post-run wiki snapshot cannot detect source-file mutations. After a non-chat repository run, inspect git status --porcelain (via execFile, never a shell) and emit a WARNING text event naming any changed paths outside openwiki/. The run is never failed; non-git working directories are skipped with a debug event. Co-Authored-By: Claude Fable 5 --- src/agent/cli-runner/index.ts | 115 ++++++++++++++++++++++++++++ test/cli-runner-write-guard.test.ts | 57 ++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 test/cli-runner-write-guard.test.ts diff --git a/src/agent/cli-runner/index.ts b/src/agent/cli-runner/index.ts index d7b3ac68..5539516a 100644 --- a/src/agent/cli-runner/index.ts +++ b/src/agent/cli-runner/index.ts @@ -122,9 +122,124 @@ export async function runOpenWikiCliAgent( emitDebug(options, "cli.metadata=written"); } + // The API backend structurally blocks non-doc writes, but the CLI path only + // relies on the prompt plus allowedTools/sandbox, and the wiki snapshot above + // cannot see source-file mutations. Repository runs must only touch openwiki/; + // warn (never fail) if the CLI changed anything else. Local-wiki mode writes + // the wiki dir itself, so there is nothing to guard. + if (command !== "chat" && outputMode === "repository") { + await warnOnOutOfWikiWrites(cwd, options); + } + return { command, model }; } +const WIKI_DIR_PREFIX = "openwiki/"; + +const OUT_OF_WIKI_WARNING_LIMIT = 10; + +/** + * Parses `git status --porcelain` (v1) output and returns changed paths that + * fall OUTSIDE the generated wiki directory (openwiki/). Handles the rename + * form `R old -> new` (both sides are checked, so moving a source file into + * openwiki/ is still flagged) and C-quoted paths (core.quotePath). Duplicate + * paths are collapsed, preserving first-seen order. + */ +export function findUnexpectedChanges(porcelainOutput: string): string[] { + const unexpected: string[] = []; + + for (const rawLine of porcelainOutput.split("\n")) { + if (rawLine.trim().length === 0) { + continue; + } + + for (const filePath of extractPorcelainPaths(rawLine)) { + if ( + !filePath.startsWith(WIKI_DIR_PREFIX) && + !unexpected.includes(filePath) + ) { + unexpected.push(filePath); + } + } + } + + return unexpected; +} + +function extractPorcelainPaths(line: string): string[] { + // Porcelain v1 lines are "XY " or "XY -> ": two status + // columns plus a separating space, so the path section starts at index 3. + const pathSection = line.slice(3); + // Rename/copy entries encode both paths as "old -> new". A path containing + // an unquoted " -> " would be C-quoted first, so a plain split is safe. + const tokens = pathSection.includes(" -> ") + ? pathSection.split(" -> ") + : [pathSection]; + + return tokens + .map((token) => unquotePorcelainPath(token)) + .filter((token) => token.length > 0); +} + +function unquotePorcelainPath(token: string): string { + const trimmed = token.trim(); + + if ( + !trimmed.startsWith('"') || + !trimmed.endsWith('"') || + trimmed.length < 2 + ) { + return trimmed; + } + + // C-style quoting: decode the common escapes. Any octal byte escapes are + // left as-is; the openwiki/ prefix check only needs the leading, never- + // escaped path segment. + return trimmed + .slice(1, -1) + .replace(/\\([\\"])/g, "$1") + .replace(/\\t/g, "\t") + .replace(/\\n/g, "\n"); +} + +async function warnOnOutOfWikiWrites( + cwd: string, + options: OpenWikiRunOptions, +): Promise { + let porcelain: string; + + try { + const { stdout } = await execFileAsync("git", ["status", "--porcelain"], { + cwd, + timeout: 10_000, + }); + porcelain = stdout; + } catch (error) { + // Not a git repo, git missing, or git failed: nothing to guard against. + emitDebug(options, `cli.write-guard skipped (${getErrorMessage(error)})`); + return; + } + + const unexpected = findUnexpectedChanges(porcelain); + + if (unexpected.length === 0) { + return; + } + + const shown = unexpected.slice(0, OUT_OF_WIKI_WARNING_LIMIT); + const overflow = unexpected.length - shown.length; + const list = shown.map((filePath) => ` - ${filePath}`).join("\n"); + const suffix = overflow > 0 ? `\n ...and ${overflow} more` : ""; + + options.onEvent?.({ + type: "text", + text: + "WARNING: the CLI agent changed files outside the openwiki/ wiki directory. " + + "OpenWiki repository runs should only write generated pages under openwiki/. " + + `Review these unexpected changes:\n${list}${suffix}`, + }); +} + export async function executeCliRun( adapter: CliEngineAdapter, spec: CliRunSpec, diff --git a/test/cli-runner-write-guard.test.ts b/test/cli-runner-write-guard.test.ts new file mode 100644 index 00000000..472161b4 --- /dev/null +++ b/test/cli-runner-write-guard.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "vitest"; +import { findUnexpectedChanges } from "../src/agent/cli-runner/index.ts"; + +describe("findUnexpectedChanges", () => { + test("returns nothing for a clean working tree", () => { + expect(findUnexpectedChanges("")).toEqual([]); + expect(findUnexpectedChanges("\n")).toEqual([]); + }); + + test("ignores changes confined to the openwiki/ wiki directory", () => { + const porcelain = [ + " M openwiki/quickstart.md", + "?? openwiki/architecture/overview.md", + "A openwiki/_plan.md", + ].join("\n"); + + expect(findUnexpectedChanges(porcelain)).toEqual([]); + }); + + test("flags a source file change outside openwiki/", () => { + const porcelain = [" M openwiki/quickstart.md", " M src/index.ts"].join( + "\n", + ); + + expect(findUnexpectedChanges(porcelain)).toEqual(["src/index.ts"]); + }); + + test("flags a rename that moves a source file into openwiki/", () => { + // Porcelain rename form: "R old -> new". The origin path is a source + // file, so the move is an out-of-wiki mutation even though the + // destination lives under openwiki/. + const porcelain = "R src/notes.ts -> openwiki/notes.md"; + + expect(findUnexpectedChanges(porcelain)).toEqual(["src/notes.ts"]); + }); + + test("does not flag a rename fully inside openwiki/", () => { + const porcelain = "R openwiki/a.md -> openwiki/b.md"; + + expect(findUnexpectedChanges(porcelain)).toEqual([]); + }); + + test("handles C-quoted paths (core.quotePath)", () => { + const porcelain = [ + '?? "src/weird\\"name.ts"', + '?? "openwiki/weird\\"name.md"', + ].join("\n"); + + expect(findUnexpectedChanges(porcelain)).toEqual(['src/weird"name.ts']); + }); + + test("deduplicates repeated out-of-wiki paths", () => { + const porcelain = [" M src/index.ts", "MM src/index.ts"].join("\n"); + + expect(findUnexpectedChanges(porcelain)).toEqual(["src/index.ts"]); + }); +}); From 756c718a55fdf52af3840403311d0c28cc13e158 Mon Sep 17 00:00:00 2001 From: Sangyeon Cho Date: Sun, 12 Jul 2026 18:24:54 +0900 Subject: [PATCH 14/17] fix: render the local-wiki synthesis block in the cli system prompt getCliOutputPromptConfig already computed a cwd-rewritten synthesis block, but createCliSystemPrompt never rendered it, so local-wiki CLI runs lost the knowledge-synthesis discipline that the base createSystemPrompt includes. Splice it in after the documentation-goal section for parity. Repository mode leaves the value empty, so the trailing separator collapses and no stray blank section is emitted. Co-Authored-By: Claude Fable 5 --- src/agent/cli-runner/prompt.ts | 8 +++++++- test/cli-runner-prompt.test.ts | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/agent/cli-runner/prompt.ts b/src/agent/cli-runner/prompt.ts index 1dd55a90..93d1cc81 100644 --- a/src/agent/cli-runner/prompt.ts +++ b/src/agent/cli-runner/prompt.ts @@ -81,6 +81,12 @@ export function createCliSystemPrompt( engine: OpenWikiProvider, ): string { const output = getCliOutputPromptConfig(outputMode); + // Parity with the base createSystemPrompt, which renders this block. It is + // populated only for local-wiki mode; repository mode leaves it "" so the + // trailing separator collapses and no stray blank section is emitted. + const localWikiSynthesis = output.localWikiSynthesisInstruction + ? `${output.localWikiSynthesisInstruction}\n\n` + : ""; return ` You are OpenWiki, an expert technical writer, software architect, and product analyst. @@ -98,7 +104,7 @@ Run discipline: ${createDocumentationGoalSections(output)} -${createStewardshipSections(output)} +${localWikiSynthesis}${createStewardshipSections(output)} ${createSecuritySection(output)} diff --git a/test/cli-runner-prompt.test.ts b/test/cli-runner-prompt.test.ts index 0b12c43d..bd070a21 100644 --- a/test/cli-runner-prompt.test.ts +++ b/test/cli-runner-prompt.test.ts @@ -127,6 +127,25 @@ describe("createCliSystemPrompt", () => { expect(prompt).toContain("rm -f _plan.md"); expect(prompt).toContain("such as quickstart.md"); }); + + test("local-wiki system prompt renders the synthesis discipline block", () => { + const prompt = createCliSystemPrompt("init", "local-wiki", "claude-code"); + + expect(prompt).toContain("Local knowledge synthesis discipline"); + expect(prompt).toContain("open-questions.md"); + // The rendered block must use cwd-relative canonical paths, never the + // virtual leading-slash form. + expect(prompt).not.toMatch(/[\s(]\/open-questions\.md/); + }); + + test("repository system prompt omits the empty synthesis block cleanly", () => { + // Repository mode has an empty synthesis value, so splicing it in must not + // introduce a stray blank section (a run of three or more newlines). + const prompt = createCliSystemPrompt("init", "repository", "claude-code"); + + expect(prompt).not.toContain("Local knowledge synthesis discipline"); + expect(prompt).not.toMatch(/\n\n\n/); + }); }); describe("createCliUserPrompt", () => { From d3e6b29c3db00007891959450719217de31d51f4 Mon Sep 17 00:00:00 2001 From: Sangyeon Cho Date: Sun, 12 Jul 2026 18:26:59 +0900 Subject: [PATCH 15/17] feat: add cli auth login hints and clear the sigkill timer on settle When a CLI run exits nonzero with auth-flavored stderr (login/unauthorized/ credential/api key), append an engine-specific sign-in hint to the thrown error ("claude /login" / "codex login") so users know how to recover. Also store the inner SIGKILL setTimeout handle and clear it in settle so a normal exit after SIGTERM does not leave a dangling timer. Co-Authored-By: Claude Fable 5 --- src/agent/cli-runner/index.ts | 43 +++++++++++++++++++++++------ test/cli-runner-execute.test.ts | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/agent/cli-runner/index.ts b/src/agent/cli-runner/index.ts index 5539516a..d1309545 100644 --- a/src/agent/cli-runner/index.ts +++ b/src/agent/cli-runner/index.ts @@ -291,10 +291,13 @@ async function executeCliRunOnce( let timedOut = false; const toolNames = new Map(); + let killTimer: NodeJS.Timeout | null = null; + const timer = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); - setTimeout(() => child.kill("SIGKILL"), 5_000).unref(); + killTimer = setTimeout(() => child.kill("SIGKILL"), 5_000); + killTimer.unref(); }, timeoutMs); const settle = (error: Error | null) => { @@ -305,6 +308,10 @@ async function executeCliRunOnce( settled = true; clearTimeout(timer); + if (killTimer) { + clearTimeout(killTimer); + } + if (error) { reject(error); } else { @@ -356,13 +363,14 @@ async function executeCliRunOnce( ), ); } else if (code !== 0) { - settle( - new Error( - `${spawnCommand} exited with exit code ${code ?? "unknown"}.${ - stderrTail ? `\nstderr:\n${stderrTail}` : "" - }`, - ), - ); + const base = `${spawnCommand} exited with exit code ${code ?? "unknown"}.${ + stderrTail ? `\nstderr:\n${stderrTail}` : "" + }`; + const hint = AUTH_ERROR_PATTERN.test(stderrTail) + ? authLoginHint(adapter.engine) + : null; + + settle(new Error(hint ? `${base}\n${hint}` : base)); } else if (resultError) { settle(new Error(`CLI agent failed: ${resultError}`)); } else { @@ -383,6 +391,25 @@ async function executeCliRunOnce( }); } +const AUTH_ERROR_PATTERN = + /\b(log ?in|logged ?out|unauthorized|authentication|credential|api key)\b/i; + +/** + * Returns the engine-specific "sign in" hint appended to auth-flavored CLI + * failures, or null for engines without a known login command. + */ +function authLoginHint(engine: OpenWikiProvider): string | null { + if (engine === "claude-code") { + return "If you are not signed in, run: claude /login"; + } + + if (engine === "codex-cli") { + return "If you are not signed in, run: codex login"; + } + + return null; +} + function emitDebug(options: OpenWikiRunOptions, message: string): void { if (options.debug === true) { options.onEvent?.({ type: "debug", message }); diff --git a/test/cli-runner-execute.test.ts b/test/cli-runner-execute.test.ts index b9906199..313dc5aa 100644 --- a/test/cli-runner-execute.test.ts +++ b/test/cli-runner-execute.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { describe, expect, test } from "vitest"; import { executeCliRun } from "../src/agent/cli-runner/index.ts"; import { claudeAdapter } from "../src/agent/cli-runner/claude.ts"; +import { codexAdapter } from "../src/agent/cli-runner/codex.ts"; import type { OpenWikiRunEvent } from "../src/agent/types.ts"; import type { CliRunSpec } from "../src/agent/cli-runner/types.ts"; @@ -17,6 +18,26 @@ const SPEC: CliRunSpec = { userPrompt: "USER", }; +/** + * Creates an executable node script that writes the given stderr text and + * exits nonzero, ignoring stdin. + */ +async function makeFailingStubCli( + stderr: string, + exitCode = 2, +): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), "openwiki-stub-cli-")); + const scriptPath = path.join(dir, "stub-cli"); + await writeFile( + scriptPath, + `#!/usr/bin/env node\nprocess.stderr.write(${JSON.stringify(stderr)});\nprocess.exit(${exitCode});\n`, + "utf8", + ); + await chmod(scriptPath, 0o755); + + return scriptPath; +} + /** * Creates an executable node script that ignores argv, echoes the given * stdout lines, and exits with the given code. @@ -166,6 +187,33 @@ describe("executeCliRun", () => { ); }); + test("appends a claude login hint on an auth-flavored failure", async () => { + const stub = await makeFailingStubCli("Error: Please log in to continue."); + + await expect(executeCliRun(claudeAdapter, SPEC, {}, stub)).rejects.toThrow( + /claude \/login/, + ); + }); + + test("appends a codex login hint on an auth-flavored failure", async () => { + const stub = await makeFailingStubCli("unauthorized: credentials expired"); + + await expect(executeCliRun(codexAdapter, SPEC, {}, stub)).rejects.toThrow( + /codex login/, + ); + }); + + test("does not add a login hint for non-auth failures", async () => { + const stub = await makeFailingStubCli("write error: disk full"); + + await expect(executeCliRun(claudeAdapter, SPEC, {}, stub)).rejects.toThrow( + /disk full/, + ); + await expect( + executeCliRun(claudeAdapter, SPEC, {}, stub), + ).rejects.not.toThrow(/not signed in/); + }); + test("retries once without resume when a resumed run fails", async () => { const failingStub = await makeStubCli([], 1); const events: OpenWikiRunEvent[] = []; From f9832346ab225425ad3b1765ab912c36deac6c4c Mon Sep 17 00:00:00 2001 From: Sangyeon Cho Date: Sun, 12 Jul 2026 18:28:24 +0900 Subject: [PATCH 16/17] fix: harden session file mode and fix cli-provider wizard article Pass { mode: 0o600 } to writeFile when creating the CLI session cache (keeping the follow-up chmod for the pre-existing-file case) so a freshly created cache is never briefly world-readable. Also make getProviderArticle return "a" for claude-code and codex-cli so the wizard reads "a Claude Code (CLI) model" and "a Codex (CLI) model". Co-Authored-By: Claude Fable 5 --- src/agent/cli-runner/sessions.ts | 7 ++++++- src/credentials.tsx | 9 +++++++-- test/credentials.test.ts | 20 +++++++++++++++++++- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/agent/cli-runner/sessions.ts b/src/agent/cli-runner/sessions.ts index b3484864..d43c4bfa 100644 --- a/src/agent/cli-runner/sessions.ts +++ b/src/agent/cli-runner/sessions.ts @@ -44,7 +44,12 @@ export async function saveCliSession( const filePath = cliSessionsPath(baseDir); await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); - await writeFile(filePath, `${JSON.stringify(sessions, null, 2)}\n`, "utf8"); + await writeFile(filePath, `${JSON.stringify(sessions, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + // Also chmod: writeFile's mode only applies when creating a new file, so an + // existing (possibly wider) file keeps its permissions without this. await chmod(filePath, 0o600); } diff --git a/src/credentials.tsx b/src/credentials.tsx index 3a3e741c..5bc1f1f4 100644 --- a/src/credentials.tsx +++ b/src/credentials.tsx @@ -3529,8 +3529,13 @@ function getInputDisplayWidth(stdoutColumns: number | undefined): number { return Math.max(24, Math.min(96, stdoutColumns - 16)); } -function getProviderArticle(provider: OpenWikiProvider): "a" | "an" { - return provider === "baseten" || provider === "fireworks" ? "a" : "an"; +export function getProviderArticle(provider: OpenWikiProvider): "a" | "an" { + return provider === "baseten" || + provider === "fireworks" || + provider === "claude-code" || + provider === "codex-cli" + ? "a" + : "an"; } function getTemplateGoal(templateId: string | undefined): string { diff --git a/test/credentials.test.ts b/test/credentials.test.ts index d5ef68df..41fe715d 100644 --- a/test/credentials.test.ts +++ b/test/credentials.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, test } from "vitest"; -import { needsCredentialSetup } from "../src/credentials.tsx"; +import { + getProviderArticle, + needsCredentialSetup, +} from "../src/credentials.tsx"; const ENV_KEYS = [ "LANGSMITH_API_KEY", @@ -34,3 +37,18 @@ describe("needsCredentialSetup", () => { expect(needsCredentialSetup()).toBe(true); }); }); + +describe("getProviderArticle", () => { + test("uses 'a' for the consonant-sounding CLI provider labels", () => { + // Labels are "Claude Code (CLI)" and "Codex (CLI)": "a Claude...", "a Codex...". + expect(getProviderArticle("claude-code")).toBe("a"); + expect(getProviderArticle("codex-cli")).toBe("a"); + }); + + test("keeps 'a'/'an' for existing providers", () => { + expect(getProviderArticle("baseten")).toBe("a"); + expect(getProviderArticle("fireworks")).toBe("a"); + expect(getProviderArticle("openai")).toBe("an"); + expect(getProviderArticle("anthropic")).toBe("an"); + }); +}); From c42d07a6bb139f8062e577f16a4f2292ce824567 Mon Sep 17 00:00:00 2001 From: Sangyeon Cho Date: Sun, 12 Jul 2026 18:37:10 +0900 Subject: [PATCH 17/17] fix: baseline the out-of-wiki write guard against pre-run dirty trees Update runs deliberately proceed on dirty trees, so a post-run-only porcelain scan attributed pre-existing changes to the CLI agent and fired the warning on every dirty-tree run. Capture a git status --porcelain baseline before the CLI run (repository non-chat runs only, same skip-on-git-error semantics) and warn only on the delta: out-of-wiki paths present after the run but not in the baseline. findUnexpectedChanges is now (baselinePorcelain, porcelainOutput), still pure and warn-only. Also gate the porcelain rename split on rename/copy status letters and correct the C-quoting comment: git only quotes control chars, quotes, backslashes, and non-ASCII, so a literal " -> " filename stays unquoted; the status gate keeps such paths intact on ordinary lines. Co-Authored-By: Claude Fable 5 --- src/agent/cli-runner/index.ts | 90 +++++++++++++++++++++-------- test/cli-runner-write-guard.test.ts | 49 +++++++++++++--- 2 files changed, 105 insertions(+), 34 deletions(-) diff --git a/src/agent/cli-runner/index.ts b/src/agent/cli-runner/index.ts index d1309545..01bf6a5d 100644 --- a/src/agent/cli-runner/index.ts +++ b/src/agent/cli-runner/index.ts @@ -90,6 +90,13 @@ export async function runOpenWikiCliAgent( command === "chat" ? null : await createOpenWikiContentSnapshot(cwd, outputMode); + // Baseline for the out-of-wiki write guard: update runs deliberately + // proceed on dirty trees, so pre-existing changes must not be attributed + // to the CLI agent. null (not a git repo / git failed) disables the guard. + const writeGuardBaseline = + command !== "chat" && outputMode === "repository" + ? await captureGitPorcelain(cwd, options) + : null; emitDebug(options, `cli.engine=${run.provider} model=${run.modelId}`); @@ -125,10 +132,11 @@ export async function runOpenWikiCliAgent( // The API backend structurally blocks non-doc writes, but the CLI path only // relies on the prompt plus allowedTools/sandbox, and the wiki snapshot above // cannot see source-file mutations. Repository runs must only touch openwiki/; - // warn (never fail) if the CLI changed anything else. Local-wiki mode writes - // the wiki dir itself, so there is nothing to guard. - if (command !== "chat" && outputMode === "repository") { - await warnOnOutOfWikiWrites(cwd, options); + // warn (never fail) if the CLI changed anything else beyond the pre-run + // baseline. Local-wiki mode writes the wiki dir itself, so there is nothing + // to guard. + if (writeGuardBaseline !== null) { + await warnOnOutOfWikiWrites(cwd, writeGuardBaseline, options); } return { command, model }; @@ -139,14 +147,28 @@ const WIKI_DIR_PREFIX = "openwiki/"; const OUT_OF_WIKI_WARNING_LIMIT = 10; /** - * Parses `git status --porcelain` (v1) output and returns changed paths that - * fall OUTSIDE the generated wiki directory (openwiki/). Handles the rename - * form `R old -> new` (both sides are checked, so moving a source file into - * openwiki/ is still flagged) and C-quoted paths (core.quotePath). Duplicate - * paths are collapsed, preserving first-seen order. + * Compares two `git status --porcelain` (v1) captures and returns changed + * paths OUTSIDE the generated wiki directory (openwiki/) that appear in the + * post-run output but were not already dirty in the pre-run baseline, so + * pre-existing dirty-tree changes are never attributed to the CLI agent. + * Handles the rename form `R old -> new` (both sides are checked, so moving + * a source file into openwiki/ is still flagged) and C-quoted paths + * (core.quotePath). Duplicate paths are collapsed, preserving first-seen + * order. */ -export function findUnexpectedChanges(porcelainOutput: string): string[] { - const unexpected: string[] = []; +export function findUnexpectedChanges( + baselinePorcelain: string, + porcelainOutput: string, +): string[] { + const baseline = new Set(collectOutOfWikiPaths(baselinePorcelain)); + + return collectOutOfWikiPaths(porcelainOutput).filter( + (filePath) => !baseline.has(filePath), + ); +} + +function collectOutOfWikiPaths(porcelainOutput: string): string[] { + const outOfWiki: string[] = []; for (const rawLine of porcelainOutput.split("\n")) { if (rawLine.trim().length === 0) { @@ -156,25 +178,31 @@ export function findUnexpectedChanges(porcelainOutput: string): string[] { for (const filePath of extractPorcelainPaths(rawLine)) { if ( !filePath.startsWith(WIKI_DIR_PREFIX) && - !unexpected.includes(filePath) + !outOfWiki.includes(filePath) ) { - unexpected.push(filePath); + outOfWiki.push(filePath); } } } - return unexpected; + return outOfWiki; } function extractPorcelainPaths(line: string): string[] { // Porcelain v1 lines are "XY " or "XY -> ": two status // columns plus a separating space, so the path section starts at index 3. + const status = line.slice(0, 2); const pathSection = line.slice(3); - // Rename/copy entries encode both paths as "old -> new". A path containing - // an unquoted " -> " would be C-quoted first, so a plain split is safe. - const tokens = pathSection.includes(" -> ") - ? pathSection.split(" -> ") - : [pathSection]; + // Rename/copy entries (X or Y is R/C) encode both paths as "old -> new". + // Git only C-quotes paths containing control characters, '"', '\', or + // non-ASCII bytes, so a filename containing a literal " -> " stays + // unquoted; gating the split on rename/copy status keeps such paths intact + // on ordinary lines. A rename whose own paths contain " -> " can still + // mis-split, which at worst garbles the warning text. + const tokens = + /[RC]/.test(status) && pathSection.includes(" -> ") + ? pathSection.split(" -> ") + : [pathSection]; return tokens .map((token) => unquotePorcelainPath(token)) @@ -202,25 +230,37 @@ function unquotePorcelainPath(token: string): string { .replace(/\\n/g, "\n"); } -async function warnOnOutOfWikiWrites( +async function captureGitPorcelain( cwd: string, options: OpenWikiRunOptions, -): Promise { - let porcelain: string; - +): Promise { try { const { stdout } = await execFileAsync("git", ["status", "--porcelain"], { cwd, timeout: 10_000, }); - porcelain = stdout; + + return stdout; } catch (error) { // Not a git repo, git missing, or git failed: nothing to guard against. emitDebug(options, `cli.write-guard skipped (${getErrorMessage(error)})`); + + return null; + } +} + +async function warnOnOutOfWikiWrites( + cwd: string, + baselinePorcelain: string, + options: OpenWikiRunOptions, +): Promise { + const porcelain = await captureGitPorcelain(cwd, options); + + if (porcelain === null) { return; } - const unexpected = findUnexpectedChanges(porcelain); + const unexpected = findUnexpectedChanges(baselinePorcelain, porcelain); if (unexpected.length === 0) { return; diff --git a/test/cli-runner-write-guard.test.ts b/test/cli-runner-write-guard.test.ts index 472161b4..1f124f5c 100644 --- a/test/cli-runner-write-guard.test.ts +++ b/test/cli-runner-write-guard.test.ts @@ -3,8 +3,8 @@ import { findUnexpectedChanges } from "../src/agent/cli-runner/index.ts"; describe("findUnexpectedChanges", () => { test("returns nothing for a clean working tree", () => { - expect(findUnexpectedChanges("")).toEqual([]); - expect(findUnexpectedChanges("\n")).toEqual([]); + expect(findUnexpectedChanges("", "")).toEqual([]); + expect(findUnexpectedChanges("", "\n")).toEqual([]); }); test("ignores changes confined to the openwiki/ wiki directory", () => { @@ -14,15 +14,37 @@ describe("findUnexpectedChanges", () => { "A openwiki/_plan.md", ].join("\n"); - expect(findUnexpectedChanges(porcelain)).toEqual([]); + expect(findUnexpectedChanges("", porcelain)).toEqual([]); }); - test("flags a source file change outside openwiki/", () => { + test("flags a source file change outside openwiki/ on a clean baseline", () => { const porcelain = [" M openwiki/quickstart.md", " M src/index.ts"].join( "\n", ); - expect(findUnexpectedChanges(porcelain)).toEqual(["src/index.ts"]); + expect(findUnexpectedChanges("", porcelain)).toEqual(["src/index.ts"]); + }); + + test("does not re-attribute pre-existing dirty-tree changes to the agent", () => { + // Update runs deliberately proceed on dirty trees: anything already + // changed before the run must not be blamed on the CLI agent. + const baseline = [" M src/index.ts", "?? notes.txt"].join("\n"); + const after = [ + " M src/index.ts", + "?? notes.txt", + " M openwiki/quickstart.md", + ].join("\n"); + + expect(findUnexpectedChanges(baseline, after)).toEqual([]); + }); + + test("flags only the new out-of-wiki path on a dirty baseline", () => { + const baseline = " M src/index.ts"; + const after = [" M src/index.ts", " M src/agent/types.ts"].join("\n"); + + expect(findUnexpectedChanges(baseline, after)).toEqual([ + "src/agent/types.ts", + ]); }); test("flags a rename that moves a source file into openwiki/", () => { @@ -31,13 +53,22 @@ describe("findUnexpectedChanges", () => { // destination lives under openwiki/. const porcelain = "R src/notes.ts -> openwiki/notes.md"; - expect(findUnexpectedChanges(porcelain)).toEqual(["src/notes.ts"]); + expect(findUnexpectedChanges("", porcelain)).toEqual(["src/notes.ts"]); }); test("does not flag a rename fully inside openwiki/", () => { const porcelain = "R openwiki/a.md -> openwiki/b.md"; - expect(findUnexpectedChanges(porcelain)).toEqual([]); + expect(findUnexpectedChanges("", porcelain)).toEqual([]); + }); + + test("does not split a literal ' -> ' filename on non-rename lines", () => { + // Git only C-quotes control chars, '"', '\\', and non-ASCII, so a path + // containing a literal " -> " stays unquoted. Only rename/copy status + // lines encode two paths. + const porcelain = "?? src/a -> b.ts"; + + expect(findUnexpectedChanges("", porcelain)).toEqual(["src/a -> b.ts"]); }); test("handles C-quoted paths (core.quotePath)", () => { @@ -46,12 +77,12 @@ describe("findUnexpectedChanges", () => { '?? "openwiki/weird\\"name.md"', ].join("\n"); - expect(findUnexpectedChanges(porcelain)).toEqual(['src/weird"name.ts']); + expect(findUnexpectedChanges("", porcelain)).toEqual(['src/weird"name.ts']); }); test("deduplicates repeated out-of-wiki paths", () => { const porcelain = [" M src/index.ts", "MM src/index.ts"].join("\n"); - expect(findUnexpectedChanges(porcelain)).toEqual(["src/index.ts"]); + expect(findUnexpectedChanges("", porcelain)).toEqual(["src/index.ts"]); }); });