@zhijiewang/openharness 2.21.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/harness/api-key-helper.d.ts +32 -0
- package/dist/harness/api-key-helper.js +70 -0
- package/dist/harness/config.d.ts +25 -0
- package/dist/harness/credentials.d.ts +6 -4
- package/dist/harness/credentials.js +15 -4
- package/dist/harness/hooks.d.ts +11 -1
- package/dist/harness/hooks.js +10 -0
- package/dist/main.js +176 -63
- package/dist/mcp/elicitation.d.ts +66 -0
- package/dist/mcp/elicitation.js +88 -0
- 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/utils/install-method.d.ts +42 -0
- package/dist/utils/install-method.js +110 -0
- package/package.json +1 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run the configured `apiKeyHelper` script and return its trimmed stdout as
|
|
3
|
+
* the API key (audit B8). Mirrors Claude Code's `apiKeyHelper`.
|
|
4
|
+
*
|
|
5
|
+
* Invocation:
|
|
6
|
+
* - shell: true (so `helper-script.sh` and pipelines work without an explicit shell)
|
|
7
|
+
* - 5s timeout (helper should be fast — it's invoked at credential-fetch time)
|
|
8
|
+
* - OH_PROVIDER env var set so a single helper can dispatch by provider
|
|
9
|
+
* - stderr captured and surfaced on failure
|
|
10
|
+
*
|
|
11
|
+
* Failure modes — all return undefined (caller falls through to legacy config):
|
|
12
|
+
* - non-zero exit code
|
|
13
|
+
* - timeout
|
|
14
|
+
* - empty stdout
|
|
15
|
+
* - spawn error (helper not found, permission denied, etc.)
|
|
16
|
+
*
|
|
17
|
+
* Failures are logged via `debug("config", ...)` so users can opt into
|
|
18
|
+
* visibility with `--debug config` without polluting normal output.
|
|
19
|
+
*/
|
|
20
|
+
export interface RunApiKeyHelperOptions {
|
|
21
|
+
/** Provider name passed to the helper as `OH_PROVIDER`. */
|
|
22
|
+
provider: string;
|
|
23
|
+
/** Spawn timeout in ms. Defaults to 5_000. */
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Execute `command` via the user's shell with `OH_PROVIDER` set, return the
|
|
28
|
+
* trimmed stdout on success, undefined on any failure. Pure side-effect-only —
|
|
29
|
+
* no caching here; resolveApiKey owns lifetime.
|
|
30
|
+
*/
|
|
31
|
+
export declare function runApiKeyHelper(command: string, opts: RunApiKeyHelperOptions): string | undefined;
|
|
32
|
+
//# sourceMappingURL=api-key-helper.d.ts.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run the configured `apiKeyHelper` script and return its trimmed stdout as
|
|
3
|
+
* the API key (audit B8). Mirrors Claude Code's `apiKeyHelper`.
|
|
4
|
+
*
|
|
5
|
+
* Invocation:
|
|
6
|
+
* - shell: true (so `helper-script.sh` and pipelines work without an explicit shell)
|
|
7
|
+
* - 5s timeout (helper should be fast — it's invoked at credential-fetch time)
|
|
8
|
+
* - OH_PROVIDER env var set so a single helper can dispatch by provider
|
|
9
|
+
* - stderr captured and surfaced on failure
|
|
10
|
+
*
|
|
11
|
+
* Failure modes — all return undefined (caller falls through to legacy config):
|
|
12
|
+
* - non-zero exit code
|
|
13
|
+
* - timeout
|
|
14
|
+
* - empty stdout
|
|
15
|
+
* - spawn error (helper not found, permission denied, etc.)
|
|
16
|
+
*
|
|
17
|
+
* Failures are logged via `debug("config", ...)` so users can opt into
|
|
18
|
+
* visibility with `--debug config` without polluting normal output.
|
|
19
|
+
*/
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
import { debug } from "../utils/debug.js";
|
|
22
|
+
/**
|
|
23
|
+
* Execute `command` via the user's shell with `OH_PROVIDER` set, return the
|
|
24
|
+
* trimmed stdout on success, undefined on any failure. Pure side-effect-only —
|
|
25
|
+
* no caching here; resolveApiKey owns lifetime.
|
|
26
|
+
*/
|
|
27
|
+
export function runApiKeyHelper(command, opts) {
|
|
28
|
+
const timeout = opts.timeoutMs ?? 5_000;
|
|
29
|
+
try {
|
|
30
|
+
const result = spawnSync(command, {
|
|
31
|
+
shell: true,
|
|
32
|
+
timeout,
|
|
33
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
34
|
+
env: { ...process.env, OH_PROVIDER: opts.provider },
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
});
|
|
37
|
+
if (result.error) {
|
|
38
|
+
debug("config", "apiKeyHelper spawn failed", { provider: opts.provider, err: result.error.message });
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
if (result.signal === "SIGTERM") {
|
|
42
|
+
debug("config", "apiKeyHelper timed out", { provider: opts.provider, timeoutMs: timeout });
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
if (result.status !== 0) {
|
|
46
|
+
const stderr = (result.stderr ?? "").toString().trim().slice(0, 500);
|
|
47
|
+
debug("config", "apiKeyHelper non-zero exit", {
|
|
48
|
+
provider: opts.provider,
|
|
49
|
+
exit: result.status,
|
|
50
|
+
stderr,
|
|
51
|
+
});
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const out = (result.stdout ?? "").toString().trim();
|
|
55
|
+
if (!out) {
|
|
56
|
+
debug("config", "apiKeyHelper produced empty stdout", { provider: opts.provider });
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
debug("config", "apiKeyHelper resolved", { provider: opts.provider, length: out.length });
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
debug("config", "apiKeyHelper threw", {
|
|
64
|
+
provider: opts.provider,
|
|
65
|
+
err: err instanceof Error ? err.message : String(err),
|
|
66
|
+
});
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=api-key-helper.js.map
|
package/dist/harness/config.d.ts
CHANGED
|
@@ -80,6 +80,19 @@ export type HooksConfig = {
|
|
|
80
80
|
worktreeCreate?: HookDef[];
|
|
81
81
|
/** Fires after ExitWorktreeTool successfully removes a git worktree. */
|
|
82
82
|
worktreeRemove?: HookDef[];
|
|
83
|
+
/**
|
|
84
|
+
* Fires when an MCP server issues an `elicitation/create` request — before
|
|
85
|
+
* any decision is made. Hook can return `permissionDecision: "allow"` to
|
|
86
|
+
* accept (sends `{action: "accept", content: {}}` to the server) or `"deny"`
|
|
87
|
+
* to decline. No decision falls through to the interactive handler (REPL)
|
|
88
|
+
* or, if absent, to a fail-safe `decline`.
|
|
89
|
+
*/
|
|
90
|
+
elicitation?: HookDef[];
|
|
91
|
+
/**
|
|
92
|
+
* Fires after the elicitation response has been decided — symmetric to
|
|
93
|
+
* `elicitation`. Useful for audit trails that want the request/response pair.
|
|
94
|
+
*/
|
|
95
|
+
elicitationResult?: HookDef[];
|
|
83
96
|
/** Fires once per system-prompt build after CLAUDE.md / global-rules / project RULES.md / user profile have been concatenated. Useful for audit trails. */
|
|
84
97
|
instructionsLoaded?: HookDef[];
|
|
85
98
|
};
|
|
@@ -124,6 +137,18 @@ export type OhConfig = {
|
|
|
124
137
|
* Claude Code's `disableAllHooks` setting.
|
|
125
138
|
*/
|
|
126
139
|
disableAllHooks?: boolean;
|
|
140
|
+
/**
|
|
141
|
+
* Script invoked at credential-fetch time to produce an API key on stdout.
|
|
142
|
+
* Avoids storing keys in plaintext config or the encrypted store. Inserted
|
|
143
|
+
* between the encrypted-store and legacy-config steps in `resolveApiKey`.
|
|
144
|
+
* Mirrors Claude Code's `apiKeyHelper`.
|
|
145
|
+
*
|
|
146
|
+
* The configured command runs through the user's shell with a 5s timeout;
|
|
147
|
+
* stderr is captured and surfaced on failure. The provider name is passed
|
|
148
|
+
* via the `OH_PROVIDER` env var so a single helper can dispatch by provider
|
|
149
|
+
* (`if [ "$OH_PROVIDER" = "anthropic" ]; then ... fi`).
|
|
150
|
+
*/
|
|
151
|
+
apiKeyHelper?: string;
|
|
127
152
|
toolPermissions?: ToolPermissionRule[];
|
|
128
153
|
statusLineFormat?: string;
|
|
129
154
|
/** Verification loops — auto-run lint/typecheck after file edits */
|
|
@@ -15,10 +15,12 @@ export declare function deleteCredential(key: string): void;
|
|
|
15
15
|
/** List credential keys (not values) */
|
|
16
16
|
export declare function listCredentials(): string[];
|
|
17
17
|
/**
|
|
18
|
-
* Get API key for a provider, checking:
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* Get API key for a provider, checking in priority order:
|
|
19
|
+
* 1. Environment variable
|
|
20
|
+
* 2. Encrypted credential store
|
|
21
|
+
* 3. `apiKeyHelper` config script (audit B8) — runs the configured command
|
|
22
|
+
* with `OH_PROVIDER` set; trimmed stdout is the key. Failures fall through.
|
|
23
|
+
* 4. Config file (legacy plaintext, with migration into the encrypted store)
|
|
22
24
|
*/
|
|
23
25
|
export declare function resolveApiKey(provider: string, configApiKey?: string): string | undefined;
|
|
24
26
|
//# sourceMappingURL=credentials.d.ts.map
|
|
@@ -10,6 +10,8 @@ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:
|
|
|
10
10
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { homedir, hostname, userInfo } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
|
+
import { runApiKeyHelper } from "./api-key-helper.js";
|
|
14
|
+
import { readOhConfig } from "./config.js";
|
|
13
15
|
const CRED_DIR = join(homedir(), ".oh");
|
|
14
16
|
const CRED_PATH = join(CRED_DIR, "credentials.enc");
|
|
15
17
|
const ALGORITHM = "aes-256-gcm";
|
|
@@ -74,10 +76,12 @@ export function listCredentials() {
|
|
|
74
76
|
return Object.keys(loadStore());
|
|
75
77
|
}
|
|
76
78
|
/**
|
|
77
|
-
* Get API key for a provider, checking:
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
79
|
+
* Get API key for a provider, checking in priority order:
|
|
80
|
+
* 1. Environment variable
|
|
81
|
+
* 2. Encrypted credential store
|
|
82
|
+
* 3. `apiKeyHelper` config script (audit B8) — runs the configured command
|
|
83
|
+
* with `OH_PROVIDER` set; trimmed stdout is the key. Failures fall through.
|
|
84
|
+
* 4. Config file (legacy plaintext, with migration into the encrypted store)
|
|
81
85
|
*/
|
|
82
86
|
export function resolveApiKey(provider, configApiKey) {
|
|
83
87
|
// Environment variable names by provider
|
|
@@ -93,6 +97,13 @@ export function resolveApiKey(provider, configApiKey) {
|
|
|
93
97
|
const stored = getCredential(`${provider}-api-key`);
|
|
94
98
|
if (stored)
|
|
95
99
|
return stored;
|
|
100
|
+
// apiKeyHelper script — let users plug in 1Password / pass / vault / etc.
|
|
101
|
+
const cfg = readOhConfig();
|
|
102
|
+
if (cfg?.apiKeyHelper) {
|
|
103
|
+
const fromHelper = runApiKeyHelper(cfg.apiKeyHelper, { provider });
|
|
104
|
+
if (fromHelper)
|
|
105
|
+
return fromHelper;
|
|
106
|
+
}
|
|
96
107
|
// Legacy config (migrate on use)
|
|
97
108
|
if (configApiKey) {
|
|
98
109
|
// Auto-migrate to encrypted store
|
package/dist/harness/hooks.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - prompt: LLM yes/no check via provider.complete()
|
|
11
11
|
*/
|
|
12
12
|
import type { HookDef, HooksConfig } from "./config.js";
|
|
13
|
-
export type HookEvent = "sessionStart" | "sessionEnd" | "preToolUse" | "postToolUse" | "postToolUseFailure" | "postToolBatch" | "userPromptSubmit" | "userPromptExpansion" | "permissionRequest" | "permissionDenied" | "fileChanged" | "cwdChanged" | "subagentStart" | "subagentStop" | "preCompact" | "postCompact" | "configChange" | "notification" | "turnStart" | "turnStop" | "taskCreated" | "taskCompleted" | "worktreeCreate" | "worktreeRemove" | "instructionsLoaded";
|
|
13
|
+
export type HookEvent = "sessionStart" | "sessionEnd" | "preToolUse" | "postToolUse" | "postToolUseFailure" | "postToolBatch" | "userPromptSubmit" | "userPromptExpansion" | "permissionRequest" | "permissionDenied" | "fileChanged" | "cwdChanged" | "subagentStart" | "subagentStop" | "preCompact" | "postCompact" | "configChange" | "notification" | "turnStart" | "turnStop" | "taskCreated" | "taskCompleted" | "worktreeCreate" | "worktreeRemove" | "elicitation" | "elicitationResult" | "instructionsLoaded";
|
|
14
14
|
export type HookContext = {
|
|
15
15
|
toolName?: string;
|
|
16
16
|
toolArgs?: string;
|
|
@@ -66,6 +66,16 @@ export type HookContext = {
|
|
|
66
66
|
worktreeParent?: string;
|
|
67
67
|
/** For worktreeRemove: whether `force: true` was passed to skip the dirty-state check */
|
|
68
68
|
worktreeForced?: string;
|
|
69
|
+
/** For elicitation/elicitationResult: the MCP server that issued the elicitation request */
|
|
70
|
+
elicitationServer?: string;
|
|
71
|
+
/** For elicitation/elicitationResult: human-readable message the server wants to show (capped at 500 chars) */
|
|
72
|
+
elicitationMessage?: string;
|
|
73
|
+
/** For elicitation: JSON-stringified `requestedSchema` from the server (capped at 2000 chars) */
|
|
74
|
+
elicitationSchema?: string;
|
|
75
|
+
/** For elicitationResult: the final action ("accept" | "decline" | "cancel") */
|
|
76
|
+
elicitationAction?: string;
|
|
77
|
+
/** For elicitationResult: JSON-stringified content payload returned to the server (when action="accept") */
|
|
78
|
+
elicitationContent?: string;
|
|
69
79
|
/** For instructionsLoaded: count of rules concatenated (as a string for env-var parity) */
|
|
70
80
|
rulesCount?: string;
|
|
71
81
|
/** For instructionsLoaded: total character length of the loaded rules */
|
package/dist/harness/hooks.js
CHANGED
|
@@ -90,6 +90,16 @@ function buildEnv(event, ctx) {
|
|
|
90
90
|
env.OH_WORKTREE_PARENT = ctx.worktreeParent;
|
|
91
91
|
if (ctx.worktreeForced !== undefined)
|
|
92
92
|
env.OH_WORKTREE_FORCED = ctx.worktreeForced;
|
|
93
|
+
if (ctx.elicitationServer !== undefined)
|
|
94
|
+
env.OH_ELICITATION_SERVER = ctx.elicitationServer;
|
|
95
|
+
if (ctx.elicitationMessage !== undefined)
|
|
96
|
+
env.OH_ELICITATION_MESSAGE = ctx.elicitationMessage;
|
|
97
|
+
if (ctx.elicitationSchema !== undefined)
|
|
98
|
+
env.OH_ELICITATION_SCHEMA = ctx.elicitationSchema;
|
|
99
|
+
if (ctx.elicitationAction !== undefined)
|
|
100
|
+
env.OH_ELICITATION_ACTION = ctx.elicitationAction;
|
|
101
|
+
if (ctx.elicitationContent !== undefined)
|
|
102
|
+
env.OH_ELICITATION_CONTENT = ctx.elicitationContent;
|
|
93
103
|
return env;
|
|
94
104
|
}
|
|
95
105
|
/**
|
package/dist/main.js
CHANGED
|
@@ -207,6 +207,10 @@ program
|
|
|
207
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
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
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.')
|
|
210
214
|
.action(async (promptArg, opts) => {
|
|
211
215
|
configureDebug({
|
|
212
216
|
categories: opts.debug,
|
|
@@ -214,6 +218,11 @@ program
|
|
|
214
218
|
});
|
|
215
219
|
const bare = opts.bare === true;
|
|
216
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
|
+
}
|
|
217
226
|
// Read from stdin if prompt is "-" or omitted and stdin is not a TTY
|
|
218
227
|
let prompt;
|
|
219
228
|
if (!promptArg || promptArg === "-" || !process.stdin.isTTY) {
|
|
@@ -248,7 +257,7 @@ program
|
|
|
248
257
|
overrides.apiKey = savedConfig.apiKey;
|
|
249
258
|
if (savedConfig?.baseUrl)
|
|
250
259
|
overrides.baseUrl = savedConfig.baseUrl;
|
|
251
|
-
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 } : {});
|
|
252
261
|
const { query } = await import("./query.js");
|
|
253
262
|
// Tool list = built-ins + MCP server tools (project config + --mcp-config).
|
|
254
263
|
// Previously oh run skipped MCP entirely, which silently broke the SDK
|
|
@@ -294,6 +303,7 @@ program
|
|
|
294
303
|
maxTurns: parseInt(opts.maxTurns, 10),
|
|
295
304
|
model,
|
|
296
305
|
...(opts.maxBudgetUsd !== undefined ? { maxCost: parseMaxBudgetUsdOrExit(opts.maxBudgetUsd) } : {}),
|
|
306
|
+
...(opts.permissionPromptTool ? { permissionPromptTool: opts.permissionPromptTool } : {}),
|
|
297
307
|
};
|
|
298
308
|
const outputFormat = opts.json ? "json" : (opts.outputFormat ?? "text");
|
|
299
309
|
let fullOutput = "";
|
|
@@ -469,6 +479,10 @@ program
|
|
|
469
479
|
.option("--bare", "Skip optional startup work (project detection, plugins, memory, skills, MCP). System prompt is just the tool-use baseline.")
|
|
470
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.")
|
|
471
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.')
|
|
472
486
|
.action(async (opts) => {
|
|
473
487
|
configureDebug({
|
|
474
488
|
categories: opts.debug,
|
|
@@ -476,6 +490,9 @@ program
|
|
|
476
490
|
});
|
|
477
491
|
const bare = opts.bare === true;
|
|
478
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
|
+
}
|
|
479
496
|
const settingSources = parseSettingSources(opts.settingSources);
|
|
480
497
|
const savedConfig = readOhConfig(undefined, settingSources);
|
|
481
498
|
const permissionMode = (opts.permissionMode ??
|
|
@@ -488,7 +505,7 @@ program
|
|
|
488
505
|
overrides.apiKey = savedConfig.apiKey;
|
|
489
506
|
if (savedConfig?.baseUrl)
|
|
490
507
|
overrides.baseUrl = savedConfig.baseUrl;
|
|
491
|
-
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 } : {});
|
|
492
509
|
const { query } = await import("./query.js");
|
|
493
510
|
const { createAssistantMessage, createToolResultMessage, createUserMessage } = await import("./types/message.js");
|
|
494
511
|
// Tool list = built-ins + MCP server tools (project config + --mcp-config).
|
|
@@ -532,6 +549,7 @@ program
|
|
|
532
549
|
maxTurns: parseInt(opts.maxTurns, 10),
|
|
533
550
|
model,
|
|
534
551
|
...(opts.maxBudgetUsd !== undefined ? { maxCost: parseMaxBudgetUsdOrExit(opts.maxBudgetUsd) } : {}),
|
|
552
|
+
...(opts.permissionPromptTool ? { permissionPromptTool: opts.permissionPromptTool } : {}),
|
|
535
553
|
};
|
|
536
554
|
// Conversation history, shared across all prompts for this process.
|
|
537
555
|
// Seeded from a prior session when --resume <id> is passed; otherwise a
|
|
@@ -706,6 +724,9 @@ program
|
|
|
706
724
|
.option("--bare", "Skip optional startup work (project detection, plugins, memory, skills, MCP). System prompt is just the tool-use baseline.")
|
|
707
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.")
|
|
708
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.")
|
|
709
730
|
.action(async (opts) => {
|
|
710
731
|
configureDebug({
|
|
711
732
|
categories: opts.debug,
|
|
@@ -713,6 +734,9 @@ program
|
|
|
713
734
|
});
|
|
714
735
|
const bare = opts.bare === true;
|
|
715
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
|
+
}
|
|
716
740
|
// Load saved config as defaults (env vars + CLI flags override)
|
|
717
741
|
const savedConfig = readOhConfig();
|
|
718
742
|
const effectiveModel = opts.model ?? savedConfig?.model;
|
|
@@ -737,7 +761,7 @@ program
|
|
|
737
761
|
if (fresh?.baseUrl)
|
|
738
762
|
overrides.baseUrl = fresh.baseUrl;
|
|
739
763
|
const targetModel = fresh?.model ?? effectiveModel;
|
|
740
|
-
return createProvider(targetModel, Object.keys(overrides).length ? overrides : undefined);
|
|
764
|
+
return createProvider(targetModel, Object.keys(overrides).length ? overrides : undefined, opts.fallbackModel ? { fallbackModel: opts.fallbackModel } : {});
|
|
741
765
|
};
|
|
742
766
|
try {
|
|
743
767
|
const result = await tryCreateProvider();
|
|
@@ -1054,11 +1078,17 @@ program
|
|
|
1054
1078
|
}
|
|
1055
1079
|
console.log();
|
|
1056
1080
|
});
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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) {
|
|
1062
1092
|
const { default: InitWizard } = await import("./components/InitWizard.js");
|
|
1063
1093
|
const rulesPath = createRulesFile();
|
|
1064
1094
|
const ctx = detectProject();
|
|
@@ -1071,8 +1101,144 @@ program
|
|
|
1071
1101
|
}
|
|
1072
1102
|
console.log(` Rules file: ${rulesPath}`);
|
|
1073
1103
|
console.log();
|
|
1074
|
-
const { waitUntilExit } = render(_jsx(InitWizard, { onDone: () => process.exit(0) }));
|
|
1104
|
+
const { waitUntilExit } = render(_jsx(InitWizard, { onDone: () => (opts.exitOnDone ? process.exit(0) : undefined) }));
|
|
1075
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();
|
|
1076
1242
|
});
|
|
1077
1243
|
// ── sessions ──
|
|
1078
1244
|
program
|
|
@@ -1203,60 +1369,7 @@ program
|
|
|
1203
1369
|
process.exit(0);
|
|
1204
1370
|
});
|
|
1205
1371
|
});
|
|
1206
|
-
//
|
|
1207
|
-
program
|
|
1208
|
-
.command("auth")
|
|
1209
|
-
.description("Manage API key credentials")
|
|
1210
|
-
.argument("<action>", "login | logout | status")
|
|
1211
|
-
.argument("[provider]", "Provider name (anthropic, openai, openrouter)")
|
|
1212
|
-
.action(async (action, providerName) => {
|
|
1213
|
-
const { setCredential, deleteCredential, listCredentials, getCredential } = await import("./harness/credentials.js");
|
|
1214
|
-
if (action === "status") {
|
|
1215
|
-
const keys = listCredentials();
|
|
1216
|
-
if (keys.length === 0) {
|
|
1217
|
-
console.log(" No stored credentials. API keys come from environment variables or config.yaml.");
|
|
1218
|
-
return;
|
|
1219
|
-
}
|
|
1220
|
-
console.log("\n Stored credentials:");
|
|
1221
|
-
for (const k of keys) {
|
|
1222
|
-
const val = getCredential(k);
|
|
1223
|
-
console.log(` ${k}: ${val ? `****${val.slice(-4)}` : "(empty)"}`);
|
|
1224
|
-
}
|
|
1225
|
-
console.log();
|
|
1226
|
-
return;
|
|
1227
|
-
}
|
|
1228
|
-
if (action === "login") {
|
|
1229
|
-
if (!providerName) {
|
|
1230
|
-
console.error(" Usage: oh auth login <provider>");
|
|
1231
|
-
process.exit(1);
|
|
1232
|
-
}
|
|
1233
|
-
// Read key from stdin
|
|
1234
|
-
process.stdout.write(` Enter API key for ${providerName}: `);
|
|
1235
|
-
const chunks = [];
|
|
1236
|
-
for await (const chunk of process.stdin) {
|
|
1237
|
-
chunks.push(chunk);
|
|
1238
|
-
break;
|
|
1239
|
-
}
|
|
1240
|
-
const key = Buffer.concat(chunks).toString("utf-8").trim();
|
|
1241
|
-
if (!key) {
|
|
1242
|
-
console.error(" No key provided.");
|
|
1243
|
-
process.exit(1);
|
|
1244
|
-
}
|
|
1245
|
-
setCredential(`${providerName}-api-key`, key);
|
|
1246
|
-
console.log(` ✓ API key saved securely for ${providerName}`);
|
|
1247
|
-
return;
|
|
1248
|
-
}
|
|
1249
|
-
if (action === "logout") {
|
|
1250
|
-
if (!providerName) {
|
|
1251
|
-
console.error(" Usage: oh auth logout <provider>");
|
|
1252
|
-
process.exit(1);
|
|
1253
|
-
}
|
|
1254
|
-
deleteCredential(`${providerName}-api-key`);
|
|
1255
|
-
console.log(` ✓ API key removed for ${providerName}`);
|
|
1256
|
-
return;
|
|
1257
|
-
}
|
|
1258
|
-
console.error(` Unknown action: ${action}. Use: login, logout, status`);
|
|
1259
|
-
});
|
|
1372
|
+
// (oh auth subcommand is registered above near the init command)
|
|
1260
1373
|
// ── serve (MCP server) ──
|
|
1261
1374
|
program
|
|
1262
1375
|
.command("serve")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP `elicitation/create` responder (audit B4).
|
|
3
|
+
*
|
|
4
|
+
* MCP servers can ask the client to elicit user input — for confirmations
|
|
5
|
+
* ("are you sure?"), for form fills, or for free-form text. The spec defines
|
|
6
|
+
* three response actions:
|
|
7
|
+
* - `accept` → user agreed; `content` may contain form values
|
|
8
|
+
* - `decline` → user explicitly said no
|
|
9
|
+
* - `cancel` → user dismissed without choosing (e.g. closed the prompt)
|
|
10
|
+
*
|
|
11
|
+
* Default behavior is **fail-safe decline** — when nothing decides, OH
|
|
12
|
+
* returns `{ action: "decline" }`. This keeps OH from accepting actions
|
|
13
|
+
* silently in headless / unattended mode. To accept, configure an
|
|
14
|
+
* `elicitation` hook that returns `permissionDecision: "allow"`, or wire an
|
|
15
|
+
* interactive handler via `setElicitationHandler` (the REPL will plug in
|
|
16
|
+
* when its UX support lands; until then the hook path is the supported
|
|
17
|
+
* extension point).
|
|
18
|
+
*
|
|
19
|
+
* Two hook events fire per elicitation:
|
|
20
|
+
* - `elicitation` — request received, before any decision
|
|
21
|
+
* - `elicitationResult` — final action + content, after decision is made
|
|
22
|
+
*
|
|
23
|
+
* Both carry the server name and message so audit hooks can log the
|
|
24
|
+
* full request/response pair.
|
|
25
|
+
*/
|
|
26
|
+
export type ElicitationAction = "accept" | "decline" | "cancel";
|
|
27
|
+
export interface ElicitationRequest {
|
|
28
|
+
/** Server name — for hook context. Not part of the MCP wire format. */
|
|
29
|
+
serverName: string;
|
|
30
|
+
/** Human-readable message the server wants to show the user. */
|
|
31
|
+
message: string;
|
|
32
|
+
/** JSON Schema describing the structured content the server expects on accept. */
|
|
33
|
+
requestedSchema: unknown;
|
|
34
|
+
}
|
|
35
|
+
export interface ElicitationResponse {
|
|
36
|
+
action: ElicitationAction;
|
|
37
|
+
content?: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Optional interactive handler — called when no hook decided. The REPL is
|
|
41
|
+
* the natural caller; until that lands, leaving this unset means OH falls
|
|
42
|
+
* straight from the hook to the auto-decline default.
|
|
43
|
+
*/
|
|
44
|
+
export type InteractiveElicitationHandler = (req: ElicitationRequest) => Promise<ElicitationResponse>;
|
|
45
|
+
/**
|
|
46
|
+
* Register / replace the interactive elicitation handler. Pass `undefined`
|
|
47
|
+
* to clear (for tests / REPL teardown). Idempotent.
|
|
48
|
+
*/
|
|
49
|
+
export declare function setElicitationHandler(handler: InteractiveElicitationHandler | undefined): void;
|
|
50
|
+
/**
|
|
51
|
+
* Resolve an MCP `elicitation/create` request into an `ElicitationResponse`.
|
|
52
|
+
*
|
|
53
|
+
* Decision priority:
|
|
54
|
+
* 1. `elicitation` hook returns a decision → honor it (allow → accept, deny → decline)
|
|
55
|
+
* 2. Interactive handler is registered → delegate to it
|
|
56
|
+
* 3. Default → `{ action: "decline" }`
|
|
57
|
+
*
|
|
58
|
+
* Always fires the symmetric `elicitationResult` hook last, so audit hooks
|
|
59
|
+
* see the full request/response pair regardless of which branch decided.
|
|
60
|
+
*
|
|
61
|
+
* @internal Exported for tests; transport.ts is the production caller.
|
|
62
|
+
*/
|
|
63
|
+
export declare function resolveElicitation(req: ElicitationRequest): Promise<ElicitationResponse>;
|
|
64
|
+
/** @internal Test-only reset. */
|
|
65
|
+
export declare function _resetElicitationForTest(): void;
|
|
66
|
+
//# sourceMappingURL=elicitation.d.ts.map
|