@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.
@@ -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
@@ -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
- * 1. Environment variable (highest priority)
20
- * 2. Encrypted credential store
21
- * 3. Config file (legacy plaintext, with migration prompt)
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
- * 1. Environment variable (highest priority)
79
- * 2. Encrypted credential store
80
- * 3. Config file (legacy plaintext, with migration prompt)
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
@@ -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 */
@@ -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
- // ── init ──
1058
- program
1059
- .command("init")
1060
- .description("Initialize OpenHarness for the current project (interactive setup wizard)")
1061
- .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) {
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
- // ── auth ──
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