@zhijiewang/openharness 2.19.0 → 2.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,7 @@
10
10
  * - prompt: LLM yes/no check via provider.complete()
11
11
  */
12
12
  import { spawn, spawnSync } from "node:child_process";
13
+ import { debug } from "../utils/debug.js";
13
14
  import { readOhConfig } from "./config.js";
14
15
  let cachedHooks;
15
16
  export function getHooks() {
@@ -22,6 +23,18 @@ export function getHooks() {
22
23
  /** Clear hook cache (call after config changes) */
23
24
  export function invalidateHookCache() {
24
25
  cachedHooks = undefined;
26
+ cachedDisableAllHooks = undefined;
27
+ }
28
+ let cachedDisableAllHooks;
29
+ /**
30
+ * Whether the configured `disableAllHooks` kill switch is set.
31
+ * Cached so the per-emit cost is a single boolean read.
32
+ */
33
+ export function areHooksEnabled() {
34
+ if (cachedDisableAllHooks === undefined) {
35
+ cachedDisableAllHooks = readOhConfig()?.disableAllHooks === true;
36
+ }
37
+ return !cachedDisableAllHooks;
25
38
  }
26
39
  function buildEnv(event, ctx) {
27
40
  const env = {
@@ -71,6 +84,12 @@ function buildEnv(event, ctx) {
71
84
  env.OH_TURN_NUMBER = ctx.turnNumber;
72
85
  if (ctx.turnReason !== undefined)
73
86
  env.OH_TURN_REASON = ctx.turnReason;
87
+ if (ctx.worktreePath !== undefined)
88
+ env.OH_WORKTREE_PATH = ctx.worktreePath;
89
+ if (ctx.worktreeParent !== undefined)
90
+ env.OH_WORKTREE_PARENT = ctx.worktreeParent;
91
+ if (ctx.worktreeForced !== undefined)
92
+ env.OH_WORKTREE_FORCED = ctx.worktreeForced;
74
93
  return env;
75
94
  }
76
95
  /**
@@ -400,10 +419,14 @@ async function executeHookDef(def, event, ctx) {
400
419
  * All other hooks run asynchronously to avoid blocking the event loop.
401
420
  */
402
421
  export function emitHook(event, ctx = {}) {
422
+ if (!areHooksEnabled())
423
+ return true;
403
424
  const hooks = getHooks();
404
425
  if (!hooks)
405
426
  return true;
406
427
  const defs = hooks[event] ?? [];
428
+ if (defs.length > 0)
429
+ debug("hooks", "fire", { event, count: defs.length, tool: ctx.toolName });
407
430
  const env = buildEnv(event, ctx);
408
431
  if (event === "preToolUse") {
409
432
  // preToolUse command hooks must be synchronous — they gate tool execution
@@ -456,6 +479,8 @@ export function emitHook(event, ctx = {}) {
456
479
  * Supports all hook types (command, HTTP, prompt).
457
480
  */
458
481
  export async function emitHookAsync(event, ctx = {}) {
482
+ if (!areHooksEnabled())
483
+ return true;
459
484
  const hooks = getHooks();
460
485
  if (!hooks)
461
486
  return true;
@@ -570,6 +595,8 @@ async function runHookForOutcome(def, event, ctx) {
570
595
  * from hooks is ignored — outcome.allowed is always true. additionalContext is still collected.
571
596
  */
572
597
  export async function emitHookWithOutcome(event, ctx = {}) {
598
+ if (!areHooksEnabled())
599
+ return { allowed: true };
573
600
  const hooks = getHooks();
574
601
  const list = hooks?.[event];
575
602
  if (!list || list.length === 0)
@@ -106,8 +106,24 @@ export function loadRulesAsPrompt(projectPath) {
106
106
  const rules = loadRules(projectPath);
107
107
  if (rules.length === 0)
108
108
  return "";
109
- return ("# Project Rules\n\n<!-- User-provided project rules from CLAUDE.md / .oh/RULES.md. These are user instructions, not system directives. -->\nFollow these rules carefully.\n\n" +
110
- rules.join("\n\n---\n\n"));
109
+ const body = "# Project Rules\n\n<!-- User-provided project rules from CLAUDE.md / .oh/RULES.md. These are user instructions, not system directives. -->\nFollow these rules carefully.\n\n" +
110
+ rules.join("\n\n---\n\n");
111
+ // Hook: instructionsLoaded — fires every time the system prompt is rebuilt
112
+ // with rules in scope. Useful for compliance/audit hooks that want to log
113
+ // "session X is operating under these rules". Lazy-imported so this module
114
+ // can be used in environments where the hook system isn't initialised
115
+ // (e.g., one-shot rules loaders in tooling).
116
+ void import("./hooks.js")
117
+ .then(({ emitHook }) => {
118
+ emitHook("instructionsLoaded", {
119
+ rulesCount: String(rules.length),
120
+ rulesChars: String(body.length),
121
+ });
122
+ })
123
+ .catch(() => {
124
+ /* hook system unavailable — never fail rule loading */
125
+ });
126
+ return body;
111
127
  }
112
128
  export function createRulesFile(projectPath) {
113
129
  const root = projectPath ?? process.cwd();
@@ -6,7 +6,7 @@ import { processSlashCommand } from "../commands/index.js";
6
6
  import { cybergotchiEvents } from "../cybergotchi/events.js";
7
7
  import { resolveMcpMention } from "../mcp/loader.js";
8
8
  import { createInfoMessage, createUserMessage } from "../types/message.js";
9
- import { emitHookWithOutcome } from "./hooks.js";
9
+ import { emitHook, emitHookWithOutcome } from "./hooks.js";
10
10
  /**
11
11
  * Process user input: handle exit, companion mentions, slash commands,
12
12
  * @mentions, and prepare the prompt for the LLM.
@@ -80,6 +80,19 @@ export async function handleUserInput(input, ctx) {
80
80
  if (result.prependToPrompt) {
81
81
  messages = [...messages, createUserMessage(input)];
82
82
  const prependPrompt = result.prependToPrompt;
83
+ // Slash command produced an expanded prompt — fire userPromptExpansion
84
+ // before userPromptSubmit so audit hooks can see the (input → expanded)
85
+ // boundary that's otherwise hidden from observers.
86
+ const slashCommand = trimmed.split(/\s/)[0] ?? trimmed;
87
+ emitHook("userPromptExpansion", {
88
+ slashCommand,
89
+ originalInput: input.slice(0, 1000),
90
+ prompt: prependPrompt.slice(0, 1000),
91
+ sessionId: ctx.sessionId,
92
+ model: ctx.currentModel,
93
+ provider: ctx.providerName,
94
+ permissionMode: ctx.permissionMode,
95
+ });
83
96
  const prependOutcome = await emitHookWithOutcome("userPromptSubmit", {
84
97
  prompt: prependPrompt,
85
98
  sessionId: ctx.sessionId,
package/dist/main.js CHANGED
@@ -23,9 +23,10 @@ import { detectProject, projectContextToPrompt } from "./harness/onboarding.js";
23
23
  import { discoverSkills, skillsToPrompt } from "./harness/plugins.js";
24
24
  import { createRulesFile, loadRules, loadRulesAsPrompt } from "./harness/rules.js";
25
25
  import { listSessions } from "./harness/session.js";
26
- import { connectedMcpServers, disconnectMcpClients, getMcpInstructions, loadMcpTools } from "./mcp/loader.js";
26
+ import { connectedMcpServers, disconnectMcpClients, getMcpInstructions, loadMcpPrompts, loadMcpTools, parseMcpConfigFile, } from "./mcp/loader.js";
27
27
  import { loadOutputStyle } from "./outputStyles/index.js";
28
28
  import { getAllTools } from "./tools.js";
29
+ import { configureDebug, debug } from "./utils/debug.js";
29
30
  import { validateAgainstJsonSchema } from "./utils/json-schema.js";
30
31
  import { parseMaxBudgetUsd } from "./utils/parse-budget.js";
31
32
  const _require = createRequire(import.meta.url);
@@ -75,6 +76,40 @@ You have access to tools for reading, writing, and searching files, running shel
75
76
  - When referencing code, include file_path:line_number.
76
77
  - Do not restate what the user said. Do not add trailing summaries unless asked.
77
78
  - Keep responses short and direct. If you can say it in one sentence, don't use three.`;
79
+ /**
80
+ * Read a system prompt from a file path, or exit 2 with a stderr message.
81
+ * Used by `--system-prompt-file` / `--append-system-prompt-file` so callers
82
+ * can keep prompts as version-controlled files instead of stuffing them on
83
+ * the command line. Trailing newline is stripped (most editors add one).
84
+ */
85
+ function readSystemPromptFile(path, label) {
86
+ try {
87
+ return readFileSync(path, "utf8").replace(/\n$/, "");
88
+ }
89
+ catch (err) {
90
+ const message = err instanceof Error ? err.message : String(err);
91
+ process.stderr.write(`Error: ${label} '${path}' could not be read: ${message}\n`);
92
+ process.exit(2);
93
+ }
94
+ }
95
+ /**
96
+ * Parse `--mcp-config <path>` (and the optional `--strict-mcp-config` flag)
97
+ * into a `LoadMcpOptions` shape ready to pass to `loadMcpTools`. Returns
98
+ * undefined when the user didn't pass `--mcp-config`. Exits 2 with a stderr
99
+ * message on parse / shape errors.
100
+ */
101
+ function buildMcpLoadOpts(opts) {
102
+ if (!opts.mcpConfig)
103
+ return undefined;
104
+ try {
105
+ const extraServers = parseMcpConfigFile(opts.mcpConfig);
106
+ return { extraServers, strict: opts.strictMcpConfig === true };
107
+ }
108
+ catch (err) {
109
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
110
+ process.exit(2);
111
+ }
112
+ }
78
113
  /**
79
114
  * Parse the `--max-budget-usd` CLI argument into a positive USD amount, or
80
115
  * exit 2 with an error message. The pure parser lives in
@@ -89,7 +124,19 @@ function parseMaxBudgetUsdOrExit(raw) {
89
124
  }
90
125
  return result.value;
91
126
  }
92
- function buildSystemPrompt(model) {
127
+ /**
128
+ * Build the assembled system prompt for a session.
129
+ *
130
+ * In `bare` mode (audit A4 — `--bare`) every optional contributor is skipped:
131
+ * no project context, no rules, no user profile, no remembered memories, no
132
+ * skill catalog, no MCP server instructions, no language directive, no output
133
+ * style. The result is exactly `DEFAULT_SYSTEM_PROMPT`. Used for fast SDK /
134
+ * CI invocations where the model just needs the tool-use baseline and the
135
+ * caller will supply its own context.
136
+ */
137
+ function buildSystemPrompt(model, opts = {}) {
138
+ if (opts.bare)
139
+ return DEFAULT_SYSTEM_PROMPT;
93
140
  const cfg = readOhConfig();
94
141
  // Output-style preface (first — sets personality for everything that follows).
95
142
  // Skipped silently for the "default" style (empty prompt).
@@ -146,13 +193,27 @@ program
146
193
  .addOption(new Option("--output-format <format>", "Output format").choices(["json", "text", "stream-json"]).default("text"))
147
194
  .option("--max-turns <n>", "Maximum turns", "20")
148
195
  .option("--system-prompt <prompt>", "Override the system prompt")
196
+ .option("--system-prompt-file <path>", "Read the system prompt from a file (overrides --system-prompt)")
149
197
  .option("--append-system-prompt <text>", "Append text to the system prompt")
198
+ .option("--append-system-prompt-file <path>", "Append the contents of a file to the system prompt")
150
199
  .option("--allowed-tools <tools>", "Comma-separated list of allowed tools")
151
200
  .option("--disallowed-tools <tools>", "Comma-separated list of disallowed tools")
152
201
  .option("--resume <id>", "Resume a saved session (replays its message history before this prompt)")
153
202
  .option("--setting-sources <sources>", "Comma-separated list of setting sources to merge (e.g. 'user,project,local'). Mirrors Claude Code's setting_sources.")
154
203
  .option("--max-budget-usd <amount>", "Hard cap on session cost in USD. The agent halts with reason 'budget_exceeded' once totalCost reaches this amount. Mirrors Claude Code's --max-budget-usd.")
204
+ .option("--no-session-persistence", "Skip writing the session to disk under ~/.oh/sessions/. Useful for ephemeral CI runs that don't need resume.")
205
+ .option("--mcp-config <path>", 'Load MCP servers from a JSON file (in addition to .oh/config.yaml). File format: {"mcpServers": [...]} or a bare array.')
206
+ .option("--strict-mcp-config", "With --mcp-config, ignore .oh/config.yaml mcpServers — use only the file's servers.")
207
+ .option("--bare", "Skip optional startup work (project detection, plugins, memory, skills, MCP). System prompt is just the tool-use baseline. Useful for fast CI / SDK invocations.")
208
+ .option("--debug [categories]", "Enable categorized debug logs to stderr. Pass comma-separated categories (e.g. 'mcp,hooks') or no value for all. Also reads OH_DEBUG.")
209
+ .option("--debug-file <path>", "When --debug is set, append debug lines to this file instead of stderr.")
155
210
  .action(async (promptArg, opts) => {
211
+ configureDebug({
212
+ categories: opts.debug,
213
+ ...(opts.debugFile ? { file: opts.debugFile } : {}),
214
+ });
215
+ const bare = opts.bare === true;
216
+ debug("startup", "oh run", { bare, model: opts.model });
156
217
  // Read from stdin if prompt is "-" or omitted and stdin is not a TTY
157
218
  let prompt;
158
219
  if (!promptArg || promptArg === "-" || !process.stdin.isTTY) {
@@ -189,8 +250,14 @@ program
189
250
  overrides.baseUrl = savedConfig.baseUrl;
190
251
  const { provider, model } = await createProvider(effectiveModel, Object.keys(overrides).length ? overrides : undefined);
191
252
  const { query } = await import("./query.js");
192
- // Tool filtering
193
- let tools = getAllTools();
253
+ // Tool list = built-ins + MCP server tools (project config + --mcp-config).
254
+ // Previously oh run skipped MCP entirely, which silently broke the SDK
255
+ // `tools=[...]` feature (the SDK injects mcpServers into a temp config but
256
+ // the CLI never read it back). `--bare` opts back out — built-ins only.
257
+ const mcpLoadOpts = buildMcpLoadOpts(opts);
258
+ const mcpTools = bare ? [] : await loadMcpTools(mcpLoadOpts);
259
+ debug("mcp", "loaded", { count: mcpTools.length, bare });
260
+ let tools = [...getAllTools(), ...mcpTools];
194
261
  if (opts.allowedTools) {
195
262
  const allowed = new Set(opts.allowedTools.split(",").map((s) => s.trim()));
196
263
  tools = tools.filter((t) => allowed.has(t.name));
@@ -199,13 +266,22 @@ program
199
266
  const disallowed = new Set(opts.disallowedTools.split(",").map((s) => s.trim()));
200
267
  tools = tools.filter((t) => !disallowed.has(t.name));
201
268
  }
202
- // System prompt
269
+ process.on("exit", () => disconnectMcpClients());
270
+ // System prompt — file variants take precedence over inline string variants
271
+ // so callers can override-from-file without removing a stale --system-prompt
272
+ // they were previously passing.
203
273
  let systemPrompt;
204
- if (opts.systemPrompt) {
274
+ if (opts.systemPromptFile) {
275
+ systemPrompt = readSystemPromptFile(opts.systemPromptFile, "--system-prompt-file");
276
+ }
277
+ else if (opts.systemPrompt) {
205
278
  systemPrompt = opts.systemPrompt;
206
279
  }
207
280
  else {
208
- systemPrompt = buildSystemPrompt(model);
281
+ systemPrompt = buildSystemPrompt(model, { bare });
282
+ }
283
+ if (opts.appendSystemPromptFile) {
284
+ systemPrompt += `\n\n${readSystemPromptFile(opts.appendSystemPromptFile, "--append-system-prompt-file")}`;
209
285
  }
210
286
  if (opts.appendSystemPrompt) {
211
287
  systemPrompt += `\n\n${opts.appendSystemPrompt}`;
@@ -233,6 +309,8 @@ program
233
309
  // --resume <id> on a later run. Without this, every fresh `oh run` was
234
310
  // a programmatic dead-end for resumption (issue #60).
235
311
  const { createSession, loadSession, saveSession } = await import("./harness/session.js");
312
+ // Commander rewrites --no-session-persistence to opts.sessionPersistence === false.
313
+ const persistSession = opts.sessionPersistence !== false;
236
314
  let priorMessages;
237
315
  let sessionId;
238
316
  let sessionRecord;
@@ -250,7 +328,8 @@ program
250
328
  else {
251
329
  sessionRecord = createSession(provider.name, model);
252
330
  sessionId = sessionRecord.id;
253
- saveSession(sessionRecord);
331
+ if (persistSession)
332
+ saveSession(sessionRecord);
254
333
  }
255
334
  if (outputFormat === "stream-json") {
256
335
  // Emit a session_start event so SDK callers can capture the id for
@@ -352,16 +431,18 @@ program
352
431
  // they're per-tool ephemerals; the assistant's final text is what
353
432
  // matters for context resumption. Mirrors the REPL's save-on-exit pattern
354
433
  // (src/components/REPL.tsx:120) but at one-shot scope.
355
- try {
356
- const { createUserMessage, createAssistantMessage } = await import("./types/message.js");
357
- const newMessages = [...(priorMessages ?? []), createUserMessage(prompt)];
358
- if (fullOutput)
359
- newMessages.push(createAssistantMessage(fullOutput));
360
- sessionRecord.messages = newMessages;
361
- saveSession(sessionRecord);
362
- }
363
- catch {
364
- /* persistence is best-effort — never fail the user's run on a save error */
434
+ if (persistSession) {
435
+ try {
436
+ const { createUserMessage, createAssistantMessage } = await import("./types/message.js");
437
+ const newMessages = [...(priorMessages ?? []), createUserMessage(prompt)];
438
+ if (fullOutput)
439
+ newMessages.push(createAssistantMessage(fullOutput));
440
+ sessionRecord.messages = newMessages;
441
+ saveSession(sessionRecord);
442
+ }
443
+ catch {
444
+ /* persistence is best-effort — never fail the user's run on a save error */
445
+ }
365
446
  }
366
447
  });
367
448
  // ── `oh session`: long-lived stateful session for the Python SDK ──
@@ -376,10 +457,25 @@ program
376
457
  .option("--disallowed-tools <tools>", "Comma-separated disallowed tool names")
377
458
  .option("--max-turns <n>", "Maximum turns per prompt", "20")
378
459
  .option("--system-prompt <prompt>", "Override the system prompt")
460
+ .option("--system-prompt-file <path>", "Read the system prompt from a file (overrides --system-prompt)")
461
+ .option("--append-system-prompt <text>", "Append text to the system prompt")
462
+ .option("--append-system-prompt-file <path>", "Append the contents of a file to the system prompt")
379
463
  .option("--resume <id>", "Resume a saved session (seeds the conversation with its prior message history)")
380
464
  .option("--setting-sources <sources>", "Comma-separated list of setting sources to merge (mirrors Claude Code's setting_sources).")
381
465
  .option("--max-budget-usd <amount>", "Hard cap on session cost in USD. Each prompt's cost accumulates; the agent halts with reason 'budget_exceeded' once totalCost reaches this amount.")
466
+ .option("--no-session-persistence", "Skip writing the session to disk under ~/.oh/sessions/. Useful for ephemeral SDK clients that don't need resume.")
467
+ .option("--mcp-config <path>", 'Load MCP servers from a JSON file (in addition to .oh/config.yaml). File format: {"mcpServers": [...]} or a bare array.')
468
+ .option("--strict-mcp-config", "With --mcp-config, ignore .oh/config.yaml mcpServers — use only the file's servers.")
469
+ .option("--bare", "Skip optional startup work (project detection, plugins, memory, skills, MCP). System prompt is just the tool-use baseline.")
470
+ .option("--debug [categories]", "Enable categorized debug logs to stderr. Pass comma-separated categories (e.g. 'mcp,hooks') or no value for all. Also reads OH_DEBUG.")
471
+ .option("--debug-file <path>", "When --debug is set, append debug lines to this file instead of stderr.")
382
472
  .action(async (opts) => {
473
+ configureDebug({
474
+ categories: opts.debug,
475
+ ...(opts.debugFile ? { file: opts.debugFile } : {}),
476
+ });
477
+ const bare = opts.bare === true;
478
+ debug("startup", "oh session", { bare, model: opts.model });
383
479
  const settingSources = parseSettingSources(opts.settingSources);
384
480
  const savedConfig = readOhConfig(undefined, settingSources);
385
481
  const permissionMode = (opts.permissionMode ??
@@ -395,7 +491,14 @@ program
395
491
  const { provider, model } = await createProvider(effectiveModel, Object.keys(overrides).length ? overrides : undefined);
396
492
  const { query } = await import("./query.js");
397
493
  const { createAssistantMessage, createToolResultMessage, createUserMessage } = await import("./types/message.js");
398
- let tools = getAllTools();
494
+ // Tool list = built-ins + MCP server tools (project config + --mcp-config).
495
+ // Same fix as `oh run` — `oh session` previously skipped MCP entirely,
496
+ // which silently broke the SDK `tools=[...]` feature for stateful clients.
497
+ // `--bare` opts back out — built-ins only.
498
+ const mcpLoadOpts = buildMcpLoadOpts(opts);
499
+ const mcpTools = bare ? [] : await loadMcpTools(mcpLoadOpts);
500
+ debug("mcp", "loaded", { count: mcpTools.length, bare });
501
+ let tools = [...getAllTools(), ...mcpTools];
399
502
  if (opts.allowedTools) {
400
503
  const allowed = new Set(opts.allowedTools.split(",").map((s) => s.trim()));
401
504
  tools = tools.filter((t) => allowed.has(t.name));
@@ -404,7 +507,23 @@ program
404
507
  const disallowed = new Set(opts.disallowedTools.split(",").map((s) => s.trim()));
405
508
  tools = tools.filter((t) => !disallowed.has(t.name));
406
509
  }
407
- const systemPrompt = opts.systemPrompt ?? buildSystemPrompt(model);
510
+ process.on("exit", () => disconnectMcpClients());
511
+ let systemPrompt;
512
+ if (opts.systemPromptFile) {
513
+ systemPrompt = readSystemPromptFile(opts.systemPromptFile, "--system-prompt-file");
514
+ }
515
+ else if (opts.systemPrompt) {
516
+ systemPrompt = opts.systemPrompt;
517
+ }
518
+ else {
519
+ systemPrompt = buildSystemPrompt(model, { bare });
520
+ }
521
+ if (opts.appendSystemPromptFile) {
522
+ systemPrompt += `\n\n${readSystemPromptFile(opts.appendSystemPromptFile, "--append-system-prompt-file")}`;
523
+ }
524
+ if (opts.appendSystemPrompt) {
525
+ systemPrompt += `\n\n${opts.appendSystemPrompt}`;
526
+ }
408
527
  const config = {
409
528
  provider,
410
529
  tools,
@@ -420,6 +539,8 @@ program
420
539
  // event for later resume (issue #60).
421
540
  const conversation = [];
422
541
  const { createSession, loadSession, saveSession } = await import("./harness/session.js");
542
+ // Commander rewrites --no-session-persistence to opts.sessionPersistence === false.
543
+ const persistSession = opts.sessionPersistence !== false;
423
544
  let sessionId;
424
545
  let sessionRecord;
425
546
  if (opts.resume) {
@@ -436,7 +557,8 @@ program
436
557
  else {
437
558
  sessionRecord = createSession(provider.name, model);
438
559
  sessionId = sessionRecord.id;
439
- saveSession(sessionRecord);
560
+ if (persistSession)
561
+ saveSession(sessionRecord);
440
562
  }
441
563
  let turnCounter = 0;
442
564
  // Will be set to the current prompt id before each turn so hook_decision
@@ -549,12 +671,15 @@ program
549
671
  }
550
672
  // Persist after every completed turn so a later --resume picks up the
551
673
  // history. Best-effort — a save failure shouldn't break the live session.
552
- try {
553
- sessionRecord.messages = conversation.slice();
554
- saveSession(sessionRecord);
555
- }
556
- catch {
557
- /* save errors don't propagate to the client */
674
+ // Skipped entirely when --no-session-persistence was passed.
675
+ if (persistSession) {
676
+ try {
677
+ sessionRecord.messages = conversation.slice();
678
+ saveSession(sessionRecord);
679
+ }
680
+ catch {
681
+ /* save errors don't propagate to the client */
682
+ }
558
683
  }
559
684
  }
560
685
  });
@@ -578,7 +703,16 @@ program
578
703
  .option("--json-schema <schema>", "Constrain output to match a JSON schema (headless mode)")
579
704
  .option("--input-format <format>", "Input format: text (default) or stream-json (NDJSON on stdin)")
580
705
  .option("--replay-user-messages", "Re-emit user messages on stdout (requires stream-json output)")
706
+ .option("--bare", "Skip optional startup work (project detection, plugins, memory, skills, MCP). System prompt is just the tool-use baseline.")
707
+ .option("--debug [categories]", "Enable categorized debug logs to stderr. Pass comma-separated categories (e.g. 'mcp,hooks') or no value for all. Also reads OH_DEBUG.")
708
+ .option("--debug-file <path>", "When --debug is set, append debug lines to this file instead of stderr.")
581
709
  .action(async (opts) => {
710
+ configureDebug({
711
+ categories: opts.debug,
712
+ ...(opts.debugFile ? { file: opts.debugFile } : {}),
713
+ });
714
+ const bare = opts.bare === true;
715
+ debug("startup", "oh chat", { bare, model: opts.model, print: !!opts.print });
582
716
  // Load saved config as defaults (env vars + CLI flags override)
583
717
  const savedConfig = readOhConfig();
584
718
  const effectiveModel = opts.model ?? savedConfig?.model;
@@ -648,11 +782,31 @@ program
648
782
  process.exit(0);
649
783
  }
650
784
  }
651
- const mcpTools = await loadMcpTools();
652
- const mcpNames = connectedMcpServers();
653
- if (mcpNames.length > 0) {
654
- console.log(`[mcp] Connected: ${mcpNames.join(", ")}`);
785
+ // `--bare` skips MCP entirely (servers, prompts, instructions). The
786
+ // built-in tool set is still loaded — bare is about reducing optional
787
+ // startup work, not stripping the agent's tool surface.
788
+ const mcpTools = bare ? [] : await loadMcpTools();
789
+ if (!bare) {
790
+ const mcpNames = connectedMcpServers();
791
+ if (mcpNames.length > 0) {
792
+ console.log(`[mcp] Connected: ${mcpNames.join(", ")}`);
793
+ }
794
+ // Surface MCP-server prompts (`prompts/list`) as `/server:prompt` slash
795
+ // commands. Errors are swallowed inside loadMcpPrompts — servers that
796
+ // don't implement the prompts capability return [] without throwing.
797
+ try {
798
+ const { registerMcpPromptCommands } = await import("./commands/index.js");
799
+ const prompts = await loadMcpPrompts();
800
+ registerMcpPromptCommands(prompts);
801
+ if (prompts.length > 0) {
802
+ console.log(`[mcp] Prompts: ${prompts.map((p) => `/${p.qualifiedName}`).join(", ")}`);
803
+ }
804
+ }
805
+ catch {
806
+ /* prompt registration is best-effort; never block the REPL */
807
+ }
655
808
  }
809
+ debug("mcp", "loaded", { count: mcpTools.length, bare });
656
810
  const tools = [...getAllTools(), ...mcpTools];
657
811
  process.on("exit", () => disconnectMcpClients());
658
812
  // Compute working directory and git branch
@@ -714,7 +868,7 @@ program
714
868
  const qConfig = {
715
869
  provider,
716
870
  tools,
717
- systemPrompt: buildSystemPrompt(resolvedModel),
871
+ systemPrompt: buildSystemPrompt(resolvedModel, { bare }),
718
872
  permissionMode: effectivePermMode,
719
873
  maxTurns: 20,
720
874
  model: resolvedModel,
@@ -31,6 +31,29 @@ export declare class McpClient {
31
31
  description?: string;
32
32
  }>>;
33
33
  readResource(uri: string): Promise<string>;
34
+ /**
35
+ * List the prompts an MCP server exposes. Returns `[]` for servers that
36
+ * don't implement the `prompts/list` capability — this is a normal case
37
+ * (most non-prompt-aware MCP servers throw a method-not-found error).
38
+ *
39
+ * Each prompt may declare named arguments; surfaced via `arguments`.
40
+ */
41
+ listPrompts(): Promise<Array<{
42
+ name: string;
43
+ description?: string;
44
+ arguments?: Array<{
45
+ name: string;
46
+ description?: string;
47
+ required?: boolean;
48
+ }>;
49
+ }>>;
50
+ /**
51
+ * Get the rendered text of an MCP prompt. Server-side templates are
52
+ * applied with the supplied arguments. Multiple message turns are
53
+ * concatenated with double-newline separators — same shape OH uses for
54
+ * other prepended prompts.
55
+ */
56
+ getPrompt(name: string, args?: Record<string, string>): Promise<string>;
34
57
  callTool(name: string, args: Record<string, unknown>): Promise<string>;
35
58
  disconnect(): void;
36
59
  }
@@ -91,6 +91,43 @@ export class McpClient {
91
91
  .map((c) => c.text)
92
92
  .join("\n");
93
93
  }
94
+ /**
95
+ * List the prompts an MCP server exposes. Returns `[]` for servers that
96
+ * don't implement the `prompts/list` capability — this is a normal case
97
+ * (most non-prompt-aware MCP servers throw a method-not-found error).
98
+ *
99
+ * Each prompt may declare named arguments; surfaced via `arguments`.
100
+ */
101
+ async listPrompts() {
102
+ try {
103
+ const res = await this.sdk.listPrompts();
104
+ return (res?.prompts ?? []);
105
+ }
106
+ catch {
107
+ return [];
108
+ }
109
+ }
110
+ /**
111
+ * Get the rendered text of an MCP prompt. Server-side templates are
112
+ * applied with the supplied arguments. Multiple message turns are
113
+ * concatenated with double-newline separators — same shape OH uses for
114
+ * other prepended prompts.
115
+ */
116
+ async getPrompt(name, args = {}) {
117
+ const res = await this.sdk.getPrompt({ name, arguments: args });
118
+ const messages = (res?.messages ?? []);
119
+ const parts = [];
120
+ for (const m of messages) {
121
+ const content = m.content;
122
+ if (typeof content === "string") {
123
+ parts.push(content);
124
+ }
125
+ else if (content && content.type === "text" && typeof content.text === "string") {
126
+ parts.push(content.text);
127
+ }
128
+ }
129
+ return parts.join("\n\n");
130
+ }
94
131
  async callTool(name, args) {
95
132
  // Retry up to 2 times on transport-closed / timeout errors
96
133
  let lastErr = null;
@@ -1,10 +1,57 @@
1
+ import type { McpServerConfig } from "../harness/config.js";
1
2
  import type { Tool } from "../Tool.js";
2
- /** Load MCP tools from .oh/config.yaml mcpServers list. Returns empty array if none configured. */
3
- export declare function loadMcpTools(): Promise<Tool[]>;
3
+ /**
4
+ * Parse a `--mcp-config <path>` file. Format:
5
+ * - `{ "mcpServers": [...] }` — Claude Code convention (preferred)
6
+ * - `[ ... ]` — bare array of server configs (also accepted)
7
+ * - `{ "name": ..., ... }` — single-server object (also accepted)
8
+ *
9
+ * Validation is shape-only: each entry must be an object with a `name`.
10
+ * Connection-time validation happens in `McpClient.connect`. Throws on
11
+ * malformed JSON or unrecognised top-level shape.
12
+ */
13
+ export declare function parseMcpConfigFile(path: string): McpServerConfig[];
14
+ export interface LoadMcpOptions {
15
+ /**
16
+ * MCP servers loaded from sources outside `.oh/config.yaml` — typically
17
+ * a `--mcp-config <path>` file. Merged with the config-file servers
18
+ * unless `strict` is set, in which case these REPLACE the config-file
19
+ * servers entirely.
20
+ */
21
+ extraServers?: import("../harness/config.js").McpServerConfig[];
22
+ /**
23
+ * When `true`, ignore `cfg.mcpServers` and use only `extraServers`.
24
+ * No-op when `extraServers` is undefined (the config-file servers
25
+ * still load). Mirrors Claude Code's `--strict-mcp-config`.
26
+ */
27
+ strict?: boolean;
28
+ }
29
+ /** Load MCP tools from .oh/config.yaml mcpServers list (and/or `--mcp-config` overrides). Returns empty array if none configured. */
30
+ export declare function loadMcpTools(opts?: LoadMcpOptions): Promise<Tool[]>;
4
31
  /** Disconnect all MCP clients (call on exit) */
5
32
  export declare function disconnectMcpClients(): void;
6
33
  /** Names of connected MCP servers */
7
34
  export declare function connectedMcpServers(): string[];
35
+ export type McpPromptHandle = {
36
+ /** `<server>:<prompt>` qualified name — the slash command is `/<server>:<prompt>`. */
37
+ qualifiedName: string;
38
+ description: string;
39
+ /** List of named arguments the prompt template expects. */
40
+ arguments?: Array<{
41
+ name: string;
42
+ description?: string;
43
+ required?: boolean;
44
+ }>;
45
+ /** Render the prompt with the supplied named arguments. */
46
+ render(args?: Record<string, string>): Promise<string>;
47
+ };
48
+ /**
49
+ * Enumerate prompts on every already-connected MCP server. Servers that don't
50
+ * implement the `prompts/list` capability return an empty list (handled
51
+ * inside `client.listPrompts`). Call AFTER `loadMcpTools()` so the client
52
+ * connections are warm.
53
+ */
54
+ export declare function loadMcpPrompts(): Promise<McpPromptHandle[]>;
8
55
  /** Get MCP server instructions to inject into system prompt (sandboxed with origin markers) */
9
56
  export declare function getMcpInstructions(): string[];
10
57
  /** List all available resources across connected MCP servers */