@zhijiewang/openharness 2.20.0 → 2.22.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.
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, loadMcpPrompts, 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,36 @@ 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.")
210
+ .option("--fallback-model <model>", "One-shot fallback model used when the primary fails with a retriable error (429/5xx/network/timeout). Format: provider/model or just model. REPLACES .oh/config.yaml fallbackProviders for this run. Mirrors Claude Code's --fallback-model.")
211
+ .option("--init", "Run the interactive `oh init` setup wizard before starting the command. Useful for first-run on a fresh project.")
212
+ .option("--init-only", "Run `oh init` and exit, without proceeding to the run/session.")
213
+ .option("--permission-prompt-tool <mcp_tool>", 'Delegate per-tool permission decisions to a configured MCP tool (e.g. "mcp__myperm__check"). The tool is invoked when a tool needs approval and no permission hook decided. Mirrors Claude Code\'s --permission-prompt-tool.')
155
214
  .action(async (promptArg, opts) => {
215
+ configureDebug({
216
+ categories: opts.debug,
217
+ ...(opts.debugFile ? { file: opts.debugFile } : {}),
218
+ });
219
+ const bare = opts.bare === true;
220
+ debug("startup", "oh run", { bare, model: opts.model });
221
+ // --init / --init-only run the setup wizard before (or instead of) the
222
+ // actual run. --init-only exits after the wizard; --init falls through.
223
+ if (opts.init === true || opts.initOnly === true) {
224
+ await runInitWizard({ exitOnDone: opts.initOnly === true });
225
+ }
156
226
  // Read from stdin if prompt is "-" or omitted and stdin is not a TTY
157
227
  let prompt;
158
228
  if (!promptArg || promptArg === "-" || !process.stdin.isTTY) {
@@ -187,10 +257,16 @@ program
187
257
  overrides.apiKey = savedConfig.apiKey;
188
258
  if (savedConfig?.baseUrl)
189
259
  overrides.baseUrl = savedConfig.baseUrl;
190
- const { provider, model } = await createProvider(effectiveModel, Object.keys(overrides).length ? overrides : undefined);
260
+ const { provider, model } = await createProvider(effectiveModel, Object.keys(overrides).length ? overrides : undefined, opts.fallbackModel ? { fallbackModel: opts.fallbackModel } : {});
191
261
  const { query } = await import("./query.js");
192
- // Tool filtering
193
- let tools = getAllTools();
262
+ // Tool list = built-ins + MCP server tools (project config + --mcp-config).
263
+ // Previously oh run skipped MCP entirely, which silently broke the SDK
264
+ // `tools=[...]` feature (the SDK injects mcpServers into a temp config but
265
+ // the CLI never read it back). `--bare` opts back out — built-ins only.
266
+ const mcpLoadOpts = buildMcpLoadOpts(opts);
267
+ const mcpTools = bare ? [] : await loadMcpTools(mcpLoadOpts);
268
+ debug("mcp", "loaded", { count: mcpTools.length, bare });
269
+ let tools = [...getAllTools(), ...mcpTools];
194
270
  if (opts.allowedTools) {
195
271
  const allowed = new Set(opts.allowedTools.split(",").map((s) => s.trim()));
196
272
  tools = tools.filter((t) => allowed.has(t.name));
@@ -199,13 +275,22 @@ program
199
275
  const disallowed = new Set(opts.disallowedTools.split(",").map((s) => s.trim()));
200
276
  tools = tools.filter((t) => !disallowed.has(t.name));
201
277
  }
202
- // System prompt
278
+ process.on("exit", () => disconnectMcpClients());
279
+ // System prompt — file variants take precedence over inline string variants
280
+ // so callers can override-from-file without removing a stale --system-prompt
281
+ // they were previously passing.
203
282
  let systemPrompt;
204
- if (opts.systemPrompt) {
283
+ if (opts.systemPromptFile) {
284
+ systemPrompt = readSystemPromptFile(opts.systemPromptFile, "--system-prompt-file");
285
+ }
286
+ else if (opts.systemPrompt) {
205
287
  systemPrompt = opts.systemPrompt;
206
288
  }
207
289
  else {
208
- systemPrompt = buildSystemPrompt(model);
290
+ systemPrompt = buildSystemPrompt(model, { bare });
291
+ }
292
+ if (opts.appendSystemPromptFile) {
293
+ systemPrompt += `\n\n${readSystemPromptFile(opts.appendSystemPromptFile, "--append-system-prompt-file")}`;
209
294
  }
210
295
  if (opts.appendSystemPrompt) {
211
296
  systemPrompt += `\n\n${opts.appendSystemPrompt}`;
@@ -218,6 +303,7 @@ program
218
303
  maxTurns: parseInt(opts.maxTurns, 10),
219
304
  model,
220
305
  ...(opts.maxBudgetUsd !== undefined ? { maxCost: parseMaxBudgetUsdOrExit(opts.maxBudgetUsd) } : {}),
306
+ ...(opts.permissionPromptTool ? { permissionPromptTool: opts.permissionPromptTool } : {}),
221
307
  };
222
308
  const outputFormat = opts.json ? "json" : (opts.outputFormat ?? "text");
223
309
  let fullOutput = "";
@@ -233,6 +319,8 @@ program
233
319
  // --resume <id> on a later run. Without this, every fresh `oh run` was
234
320
  // a programmatic dead-end for resumption (issue #60).
235
321
  const { createSession, loadSession, saveSession } = await import("./harness/session.js");
322
+ // Commander rewrites --no-session-persistence to opts.sessionPersistence === false.
323
+ const persistSession = opts.sessionPersistence !== false;
236
324
  let priorMessages;
237
325
  let sessionId;
238
326
  let sessionRecord;
@@ -250,7 +338,8 @@ program
250
338
  else {
251
339
  sessionRecord = createSession(provider.name, model);
252
340
  sessionId = sessionRecord.id;
253
- saveSession(sessionRecord);
341
+ if (persistSession)
342
+ saveSession(sessionRecord);
254
343
  }
255
344
  if (outputFormat === "stream-json") {
256
345
  // Emit a session_start event so SDK callers can capture the id for
@@ -352,16 +441,18 @@ program
352
441
  // they're per-tool ephemerals; the assistant's final text is what
353
442
  // matters for context resumption. Mirrors the REPL's save-on-exit pattern
354
443
  // (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 */
444
+ if (persistSession) {
445
+ try {
446
+ const { createUserMessage, createAssistantMessage } = await import("./types/message.js");
447
+ const newMessages = [...(priorMessages ?? []), createUserMessage(prompt)];
448
+ if (fullOutput)
449
+ newMessages.push(createAssistantMessage(fullOutput));
450
+ sessionRecord.messages = newMessages;
451
+ saveSession(sessionRecord);
452
+ }
453
+ catch {
454
+ /* persistence is best-effort — never fail the user's run on a save error */
455
+ }
365
456
  }
366
457
  });
367
458
  // ── `oh session`: long-lived stateful session for the Python SDK ──
@@ -376,10 +467,32 @@ program
376
467
  .option("--disallowed-tools <tools>", "Comma-separated disallowed tool names")
377
468
  .option("--max-turns <n>", "Maximum turns per prompt", "20")
378
469
  .option("--system-prompt <prompt>", "Override the system prompt")
470
+ .option("--system-prompt-file <path>", "Read the system prompt from a file (overrides --system-prompt)")
471
+ .option("--append-system-prompt <text>", "Append text to the system prompt")
472
+ .option("--append-system-prompt-file <path>", "Append the contents of a file to the system prompt")
379
473
  .option("--resume <id>", "Resume a saved session (seeds the conversation with its prior message history)")
380
474
  .option("--setting-sources <sources>", "Comma-separated list of setting sources to merge (mirrors Claude Code's setting_sources).")
381
475
  .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.")
476
+ .option("--no-session-persistence", "Skip writing the session to disk under ~/.oh/sessions/. Useful for ephemeral SDK clients that don't need resume.")
477
+ .option("--mcp-config <path>", 'Load MCP servers from a JSON file (in addition to .oh/config.yaml). File format: {"mcpServers": [...]} or a bare array.')
478
+ .option("--strict-mcp-config", "With --mcp-config, ignore .oh/config.yaml mcpServers — use only the file's servers.")
479
+ .option("--bare", "Skip optional startup work (project detection, plugins, memory, skills, MCP). System prompt is just the tool-use baseline.")
480
+ .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.")
481
+ .option("--debug-file <path>", "When --debug is set, append debug lines to this file instead of stderr.")
482
+ .option("--fallback-model <model>", "One-shot fallback model used when the primary fails with a retriable error. Format: provider/model or just model. REPLACES .oh/config.yaml fallbackProviders for this run.")
483
+ .option("--init", "Run the interactive setup wizard before starting the session.")
484
+ .option("--init-only", "Run `oh init` and exit, without proceeding to the session.")
485
+ .option("--permission-prompt-tool <mcp_tool>", 'Delegate per-tool permission decisions to a configured MCP tool (e.g. "mcp__myperm__check"). Invoked when a tool needs approval and no permission hook decided.')
382
486
  .action(async (opts) => {
487
+ configureDebug({
488
+ categories: opts.debug,
489
+ ...(opts.debugFile ? { file: opts.debugFile } : {}),
490
+ });
491
+ const bare = opts.bare === true;
492
+ debug("startup", "oh session", { bare, model: opts.model });
493
+ if (opts.init === true || opts.initOnly === true) {
494
+ await runInitWizard({ exitOnDone: opts.initOnly === true });
495
+ }
383
496
  const settingSources = parseSettingSources(opts.settingSources);
384
497
  const savedConfig = readOhConfig(undefined, settingSources);
385
498
  const permissionMode = (opts.permissionMode ??
@@ -392,10 +505,17 @@ program
392
505
  overrides.apiKey = savedConfig.apiKey;
393
506
  if (savedConfig?.baseUrl)
394
507
  overrides.baseUrl = savedConfig.baseUrl;
395
- const { provider, model } = await createProvider(effectiveModel, Object.keys(overrides).length ? overrides : undefined);
508
+ const { provider, model } = await createProvider(effectiveModel, Object.keys(overrides).length ? overrides : undefined, opts.fallbackModel ? { fallbackModel: opts.fallbackModel } : {});
396
509
  const { query } = await import("./query.js");
397
510
  const { createAssistantMessage, createToolResultMessage, createUserMessage } = await import("./types/message.js");
398
- let tools = getAllTools();
511
+ // Tool list = built-ins + MCP server tools (project config + --mcp-config).
512
+ // Same fix as `oh run` — `oh session` previously skipped MCP entirely,
513
+ // which silently broke the SDK `tools=[...]` feature for stateful clients.
514
+ // `--bare` opts back out — built-ins only.
515
+ const mcpLoadOpts = buildMcpLoadOpts(opts);
516
+ const mcpTools = bare ? [] : await loadMcpTools(mcpLoadOpts);
517
+ debug("mcp", "loaded", { count: mcpTools.length, bare });
518
+ let tools = [...getAllTools(), ...mcpTools];
399
519
  if (opts.allowedTools) {
400
520
  const allowed = new Set(opts.allowedTools.split(",").map((s) => s.trim()));
401
521
  tools = tools.filter((t) => allowed.has(t.name));
@@ -404,7 +524,23 @@ program
404
524
  const disallowed = new Set(opts.disallowedTools.split(",").map((s) => s.trim()));
405
525
  tools = tools.filter((t) => !disallowed.has(t.name));
406
526
  }
407
- const systemPrompt = opts.systemPrompt ?? buildSystemPrompt(model);
527
+ process.on("exit", () => disconnectMcpClients());
528
+ let systemPrompt;
529
+ if (opts.systemPromptFile) {
530
+ systemPrompt = readSystemPromptFile(opts.systemPromptFile, "--system-prompt-file");
531
+ }
532
+ else if (opts.systemPrompt) {
533
+ systemPrompt = opts.systemPrompt;
534
+ }
535
+ else {
536
+ systemPrompt = buildSystemPrompt(model, { bare });
537
+ }
538
+ if (opts.appendSystemPromptFile) {
539
+ systemPrompt += `\n\n${readSystemPromptFile(opts.appendSystemPromptFile, "--append-system-prompt-file")}`;
540
+ }
541
+ if (opts.appendSystemPrompt) {
542
+ systemPrompt += `\n\n${opts.appendSystemPrompt}`;
543
+ }
408
544
  const config = {
409
545
  provider,
410
546
  tools,
@@ -413,6 +549,7 @@ program
413
549
  maxTurns: parseInt(opts.maxTurns, 10),
414
550
  model,
415
551
  ...(opts.maxBudgetUsd !== undefined ? { maxCost: parseMaxBudgetUsdOrExit(opts.maxBudgetUsd) } : {}),
552
+ ...(opts.permissionPromptTool ? { permissionPromptTool: opts.permissionPromptTool } : {}),
416
553
  };
417
554
  // Conversation history, shared across all prompts for this process.
418
555
  // Seeded from a prior session when --resume <id> is passed; otherwise a
@@ -420,6 +557,8 @@ program
420
557
  // event for later resume (issue #60).
421
558
  const conversation = [];
422
559
  const { createSession, loadSession, saveSession } = await import("./harness/session.js");
560
+ // Commander rewrites --no-session-persistence to opts.sessionPersistence === false.
561
+ const persistSession = opts.sessionPersistence !== false;
423
562
  let sessionId;
424
563
  let sessionRecord;
425
564
  if (opts.resume) {
@@ -436,7 +575,8 @@ program
436
575
  else {
437
576
  sessionRecord = createSession(provider.name, model);
438
577
  sessionId = sessionRecord.id;
439
- saveSession(sessionRecord);
578
+ if (persistSession)
579
+ saveSession(sessionRecord);
440
580
  }
441
581
  let turnCounter = 0;
442
582
  // Will be set to the current prompt id before each turn so hook_decision
@@ -549,12 +689,15 @@ program
549
689
  }
550
690
  // Persist after every completed turn so a later --resume picks up the
551
691
  // 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 */
692
+ // Skipped entirely when --no-session-persistence was passed.
693
+ if (persistSession) {
694
+ try {
695
+ sessionRecord.messages = conversation.slice();
696
+ saveSession(sessionRecord);
697
+ }
698
+ catch {
699
+ /* save errors don't propagate to the client */
700
+ }
558
701
  }
559
702
  }
560
703
  });
@@ -578,7 +721,22 @@ program
578
721
  .option("--json-schema <schema>", "Constrain output to match a JSON schema (headless mode)")
579
722
  .option("--input-format <format>", "Input format: text (default) or stream-json (NDJSON on stdin)")
580
723
  .option("--replay-user-messages", "Re-emit user messages on stdout (requires stream-json output)")
724
+ .option("--bare", "Skip optional startup work (project detection, plugins, memory, skills, MCP). System prompt is just the tool-use baseline.")
725
+ .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.")
726
+ .option("--debug-file <path>", "When --debug is set, append debug lines to this file instead of stderr.")
727
+ .option("--fallback-model <model>", "One-shot fallback model used when the primary fails with a retriable error. Format: provider/model or just model. REPLACES .oh/config.yaml fallbackProviders for this run.")
728
+ .option("--init", "Run the interactive setup wizard before starting the chat session.")
729
+ .option("--init-only", "Run `oh init` and exit, without proceeding to the chat session.")
581
730
  .action(async (opts) => {
731
+ configureDebug({
732
+ categories: opts.debug,
733
+ ...(opts.debugFile ? { file: opts.debugFile } : {}),
734
+ });
735
+ const bare = opts.bare === true;
736
+ debug("startup", "oh chat", { bare, model: opts.model, print: !!opts.print });
737
+ if (opts.init === true || opts.initOnly === true) {
738
+ await runInitWizard({ exitOnDone: opts.initOnly === true });
739
+ }
582
740
  // Load saved config as defaults (env vars + CLI flags override)
583
741
  const savedConfig = readOhConfig();
584
742
  const effectiveModel = opts.model ?? savedConfig?.model;
@@ -603,7 +761,7 @@ program
603
761
  if (fresh?.baseUrl)
604
762
  overrides.baseUrl = fresh.baseUrl;
605
763
  const targetModel = fresh?.model ?? effectiveModel;
606
- return createProvider(targetModel, Object.keys(overrides).length ? overrides : undefined);
764
+ return createProvider(targetModel, Object.keys(overrides).length ? overrides : undefined, opts.fallbackModel ? { fallbackModel: opts.fallbackModel } : {});
607
765
  };
608
766
  try {
609
767
  const result = await tryCreateProvider();
@@ -648,25 +806,31 @@ program
648
806
  process.exit(0);
649
807
  }
650
808
  }
651
- const mcpTools = await loadMcpTools();
652
- const mcpNames = connectedMcpServers();
653
- if (mcpNames.length > 0) {
654
- console.log(`[mcp] Connected: ${mcpNames.join(", ")}`);
655
- }
656
- // Surface MCP-server prompts (`prompts/list`) as `/server:prompt` slash
657
- // commands. Errors are swallowed inside loadMcpPrompts — servers that
658
- // don't implement the prompts capability return [] without throwing.
659
- try {
660
- const { registerMcpPromptCommands } = await import("./commands/index.js");
661
- const prompts = await loadMcpPrompts();
662
- registerMcpPromptCommands(prompts);
663
- if (prompts.length > 0) {
664
- console.log(`[mcp] Prompts: ${prompts.map((p) => `/${p.qualifiedName}`).join(", ")}`);
809
+ // `--bare` skips MCP entirely (servers, prompts, instructions). The
810
+ // built-in tool set is still loaded — bare is about reducing optional
811
+ // startup work, not stripping the agent's tool surface.
812
+ const mcpTools = bare ? [] : await loadMcpTools();
813
+ if (!bare) {
814
+ const mcpNames = connectedMcpServers();
815
+ if (mcpNames.length > 0) {
816
+ console.log(`[mcp] Connected: ${mcpNames.join(", ")}`);
817
+ }
818
+ // Surface MCP-server prompts (`prompts/list`) as `/server:prompt` slash
819
+ // commands. Errors are swallowed inside loadMcpPrompts — servers that
820
+ // don't implement the prompts capability return [] without throwing.
821
+ try {
822
+ const { registerMcpPromptCommands } = await import("./commands/index.js");
823
+ const prompts = await loadMcpPrompts();
824
+ registerMcpPromptCommands(prompts);
825
+ if (prompts.length > 0) {
826
+ console.log(`[mcp] Prompts: ${prompts.map((p) => `/${p.qualifiedName}`).join(", ")}`);
827
+ }
828
+ }
829
+ catch {
830
+ /* prompt registration is best-effort; never block the REPL */
665
831
  }
666
832
  }
667
- catch {
668
- /* prompt registration is best-effort; never block the REPL */
669
- }
833
+ debug("mcp", "loaded", { count: mcpTools.length, bare });
670
834
  const tools = [...getAllTools(), ...mcpTools];
671
835
  process.on("exit", () => disconnectMcpClients());
672
836
  // Compute working directory and git branch
@@ -728,7 +892,7 @@ program
728
892
  const qConfig = {
729
893
  provider,
730
894
  tools,
731
- systemPrompt: buildSystemPrompt(resolvedModel),
895
+ systemPrompt: buildSystemPrompt(resolvedModel, { bare }),
732
896
  permissionMode: effectivePermMode,
733
897
  maxTurns: 20,
734
898
  model: resolvedModel,
@@ -914,11 +1078,17 @@ program
914
1078
  }
915
1079
  console.log();
916
1080
  });
917
- // ── init ──
918
- program
919
- .command("init")
920
- .description("Initialize OpenHarness for the current project (interactive setup wizard)")
921
- .action(async () => {
1081
+ /**
1082
+ * Run the interactive setup wizard. Used by both the `oh init` subcommand and
1083
+ * the `--init` / `--init-only` flag added to chat / run / session (audit B5).
1084
+ *
1085
+ * `exitOnDone` controls the wizard's `onDone` behavior:
1086
+ * - true → wizard exits the process when the user finishes (the standalone
1087
+ * `oh init` command path)
1088
+ * - false → wizard resolves and the caller continues (the `--init` flag path,
1089
+ * where the wizard is just a setup step before running the command)
1090
+ */
1091
+ async function runInitWizard(opts) {
922
1092
  const { default: InitWizard } = await import("./components/InitWizard.js");
923
1093
  const rulesPath = createRulesFile();
924
1094
  const ctx = detectProject();
@@ -931,8 +1101,144 @@ program
931
1101
  }
932
1102
  console.log(` Rules file: ${rulesPath}`);
933
1103
  console.log();
934
- const { waitUntilExit } = render(_jsx(InitWizard, { onDone: () => process.exit(0) }));
1104
+ const { waitUntilExit } = render(_jsx(InitWizard, { onDone: () => (opts.exitOnDone ? process.exit(0) : undefined) }));
935
1105
  await waitUntilExit();
1106
+ }
1107
+ // ── init ──
1108
+ program
1109
+ .command("init")
1110
+ .description("Initialize OpenHarness for the current project (interactive setup wizard)")
1111
+ .action(async () => {
1112
+ await runInitWizard({ exitOnDone: true });
1113
+ });
1114
+ // ── auth (audit B6) — provider-agnostic credential management ──
1115
+ //
1116
+ // `oh auth login [provider] --key <value>` — set API key for a provider
1117
+ // `oh auth logout [provider]` — clear API key for a provider
1118
+ // `oh auth status` — show which providers have keys
1119
+ //
1120
+ // `provider` defaults to the current `cfg.provider` so a bare `oh auth login`
1121
+ // works for the just-configured project. Mirrors Claude Code's `claude auth`.
1122
+ const authCmd = program.command("auth").description("Manage API keys for any provider (login / logout / status)");
1123
+ // Providers that run locally and don't use API keys — `oh auth login <local>`
1124
+ // is a no-op for these; redirect users to `oh init` which configures the
1125
+ // base URL and downloads / launches the model.
1126
+ const LOCAL_PROVIDERS = new Set(["ollama", "llamacpp", "llama.cpp", "lmstudio", "lm studio"]);
1127
+ authCmd
1128
+ .command("login [provider]")
1129
+ .description("Set the API key for a provider (defaults to the configured provider)")
1130
+ .option("--key <value>", "API key value (omit to read from stdin)")
1131
+ .action(async (providerArg, opts) => {
1132
+ const { setCredential } = await import("./harness/credentials.js");
1133
+ const cfg = readOhConfig();
1134
+ const provider = providerArg ?? cfg?.provider;
1135
+ if (!provider) {
1136
+ process.stderr.write("Error: no provider specified and no default in .oh/config.yaml.\nRun `oh init` first to pick a provider — including local options (Ollama, llama.cpp, LM Studio) that don't need API keys.\n");
1137
+ process.exit(2);
1138
+ }
1139
+ if (LOCAL_PROVIDERS.has(provider.toLowerCase())) {
1140
+ console.log([
1141
+ `${provider} runs locally and doesn't use an API key — nothing to log in.`,
1142
+ "",
1143
+ "To configure your local model and base URL, run:",
1144
+ " oh init",
1145
+ "",
1146
+ "Or skip the wizard and run directly:",
1147
+ ` oh --model ${provider}/<model-name>`,
1148
+ ].join("\n"));
1149
+ return;
1150
+ }
1151
+ let key = opts.key;
1152
+ if (!key) {
1153
+ // TTY: prompt the user for a key (one line, hidden behavior is OS-dependent
1154
+ // — we don't try to mask, callers wanting silent input should pipe).
1155
+ // Non-TTY: read until EOF so `echo $KEY | oh auth login` works.
1156
+ if (process.stdin.isTTY) {
1157
+ const readline = await import("node:readline");
1158
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1159
+ key = await new Promise((resolve) => {
1160
+ rl.question(`Enter API key for ${provider}: `, (answer) => {
1161
+ rl.close();
1162
+ resolve(answer.trim());
1163
+ });
1164
+ });
1165
+ }
1166
+ else {
1167
+ const chunks = [];
1168
+ for await (const chunk of process.stdin)
1169
+ chunks.push(chunk);
1170
+ key = Buffer.concat(chunks).toString("utf8").trim();
1171
+ }
1172
+ }
1173
+ if (!key) {
1174
+ process.stderr.write("Error: no API key provided (pass --key <value> or pipe on stdin).\n");
1175
+ process.exit(2);
1176
+ }
1177
+ setCredential(`${provider}-api-key`, key);
1178
+ console.log(`Stored API key for ${provider} in ~/.oh/credentials.enc.`);
1179
+ });
1180
+ authCmd
1181
+ .command("logout [provider]")
1182
+ .description("Clear the stored API key for a provider")
1183
+ .action(async (providerArg) => {
1184
+ const { deleteCredential, getCredential } = await import("./harness/credentials.js");
1185
+ const cfg = readOhConfig();
1186
+ const provider = providerArg ?? cfg?.provider;
1187
+ if (!provider) {
1188
+ process.stderr.write("Error: no provider specified and no default in .oh/config.yaml.\n");
1189
+ process.exit(2);
1190
+ }
1191
+ const key = `${provider}-api-key`;
1192
+ if (!getCredential(key)) {
1193
+ console.log(`No stored API key for ${provider}.`);
1194
+ return;
1195
+ }
1196
+ deleteCredential(key);
1197
+ console.log(`Cleared stored API key for ${provider}.`);
1198
+ });
1199
+ authCmd
1200
+ .command("status")
1201
+ .description("Show which providers have stored API keys")
1202
+ .action(async () => {
1203
+ const { listCredentials } = await import("./harness/credentials.js");
1204
+ const keys = listCredentials();
1205
+ const providerKeys = keys.filter((k) => k.endsWith("-api-key"));
1206
+ if (providerKeys.length === 0) {
1207
+ console.log("No stored API keys.");
1208
+ console.log("");
1209
+ console.log("To add one (cloud providers): oh auth login <provider>");
1210
+ console.log("To use a local LLM (no key): oh init — picks Ollama / llama.cpp / LM Studio");
1211
+ return;
1212
+ }
1213
+ console.log("Stored API keys:");
1214
+ for (const k of providerKeys) {
1215
+ const provider = k.replace(/-api-key$/, "");
1216
+ console.log(` ${provider}`);
1217
+ }
1218
+ // Also show env-var status — useful when debugging which path resolveApiKey takes.
1219
+ const envProviders = ["anthropic", "openai", "openrouter"].filter((p) => process.env[`${p.toUpperCase()}_API_KEY`]);
1220
+ if (envProviders.length > 0) {
1221
+ console.log("");
1222
+ console.log("Env-var keys (override stored):");
1223
+ for (const p of envProviders)
1224
+ console.log(` ${p} (${p.toUpperCase()}_API_KEY)`);
1225
+ }
1226
+ console.log("");
1227
+ console.log("Local LLMs (Ollama / llama.cpp / LM Studio) need no auth — configure via `oh init`.");
1228
+ });
1229
+ // ── update (audit B7) — provider-agnostic self-update guidance ──
1230
+ program
1231
+ .command("update")
1232
+ .description("Show the right upgrade command for how this CLI was installed")
1233
+ .action(async () => {
1234
+ const { detectInstallMethod, getDefaultMainPath } = await import("./utils/install-method.js");
1235
+ const result = detectInstallMethod(getDefaultMainPath());
1236
+ console.log();
1237
+ console.log(` Current version: ${VERSION}`);
1238
+ console.log(` Install method: ${result.method}`);
1239
+ console.log();
1240
+ console.log(result.message);
1241
+ console.log();
936
1242
  });
937
1243
  // ── sessions ──
938
1244
  program
@@ -1063,60 +1369,7 @@ program
1063
1369
  process.exit(0);
1064
1370
  });
1065
1371
  });
1066
- // ── auth ──
1067
- program
1068
- .command("auth")
1069
- .description("Manage API key credentials")
1070
- .argument("<action>", "login | logout | status")
1071
- .argument("[provider]", "Provider name (anthropic, openai, openrouter)")
1072
- .action(async (action, providerName) => {
1073
- const { setCredential, deleteCredential, listCredentials, getCredential } = await import("./harness/credentials.js");
1074
- if (action === "status") {
1075
- const keys = listCredentials();
1076
- if (keys.length === 0) {
1077
- console.log(" No stored credentials. API keys come from environment variables or config.yaml.");
1078
- return;
1079
- }
1080
- console.log("\n Stored credentials:");
1081
- for (const k of keys) {
1082
- const val = getCredential(k);
1083
- console.log(` ${k}: ${val ? `****${val.slice(-4)}` : "(empty)"}`);
1084
- }
1085
- console.log();
1086
- return;
1087
- }
1088
- if (action === "login") {
1089
- if (!providerName) {
1090
- console.error(" Usage: oh auth login <provider>");
1091
- process.exit(1);
1092
- }
1093
- // Read key from stdin
1094
- process.stdout.write(` Enter API key for ${providerName}: `);
1095
- const chunks = [];
1096
- for await (const chunk of process.stdin) {
1097
- chunks.push(chunk);
1098
- break;
1099
- }
1100
- const key = Buffer.concat(chunks).toString("utf-8").trim();
1101
- if (!key) {
1102
- console.error(" No key provided.");
1103
- process.exit(1);
1104
- }
1105
- setCredential(`${providerName}-api-key`, key);
1106
- console.log(` ✓ API key saved securely for ${providerName}`);
1107
- return;
1108
- }
1109
- if (action === "logout") {
1110
- if (!providerName) {
1111
- console.error(" Usage: oh auth logout <provider>");
1112
- process.exit(1);
1113
- }
1114
- deleteCredential(`${providerName}-api-key`);
1115
- console.log(` ✓ API key removed for ${providerName}`);
1116
- return;
1117
- }
1118
- console.error(` Unknown action: ${action}. Use: login, logout, status`);
1119
- });
1372
+ // (oh auth subcommand is registered above near the init command)
1120
1373
  // ── serve (MCP server) ──
1121
1374
  program
1122
1375
  .command("serve")