@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/README.md +21 -1
- package/README.zh-CN.md +21 -1
- package/dist/commands/ai.js +10 -0
- package/dist/commands/session.d.ts +18 -1
- package/dist/commands/session.js +82 -2
- package/dist/commands/settings.d.ts +1 -1
- package/dist/commands/settings.js +71 -1
- package/dist/harness/api-key-helper.d.ts +32 -0
- package/dist/harness/api-key-helper.js +70 -0
- package/dist/harness/config.d.ts +38 -0
- package/dist/harness/credentials.d.ts +6 -4
- package/dist/harness/credentials.js +15 -4
- package/dist/harness/hooks.d.ts +22 -1
- package/dist/harness/hooks.js +37 -0
- package/dist/main.js +361 -108
- package/dist/mcp/elicitation.d.ts +66 -0
- package/dist/mcp/elicitation.js +88 -0
- package/dist/mcp/loader.d.ts +29 -2
- package/dist/mcp/loader.js +59 -3
- package/dist/mcp/roots.d.ts +36 -0
- package/dist/mcp/roots.js +56 -0
- package/dist/mcp/transport.js +45 -3
- package/dist/providers/index.d.ts +25 -1
- package/dist/providers/index.js +27 -2
- package/dist/query/index.js +1 -1
- package/dist/query/tools.d.ts +2 -2
- package/dist/query/tools.js +68 -4
- package/dist/query/types.d.ts +10 -0
- package/dist/tools/EnterWorktreeTool/index.js +4 -0
- package/dist/tools/ExitWorktreeTool/index.js +7 -0
- package/dist/utils/debug.d.ts +63 -0
- package/dist/utils/debug.js +122 -0
- package/dist/utils/install-method.d.ts +42 -0
- package/dist/utils/install-method.js +110 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
193
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
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
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
-
//
|
|
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")
|