@zhijiewang/openharness 2.21.0 → 2.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -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
@@ -0,0 +1,88 @@
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
+ import { emitHook, emitHookWithOutcome } from "../harness/hooks.js";
27
+ let interactiveHandler;
28
+ /**
29
+ * Register / replace the interactive elicitation handler. Pass `undefined`
30
+ * to clear (for tests / REPL teardown). Idempotent.
31
+ */
32
+ export function setElicitationHandler(handler) {
33
+ interactiveHandler = handler;
34
+ }
35
+ /**
36
+ * Resolve an MCP `elicitation/create` request into an `ElicitationResponse`.
37
+ *
38
+ * Decision priority:
39
+ * 1. `elicitation` hook returns a decision → honor it (allow → accept, deny → decline)
40
+ * 2. Interactive handler is registered → delegate to it
41
+ * 3. Default → `{ action: "decline" }`
42
+ *
43
+ * Always fires the symmetric `elicitationResult` hook last, so audit hooks
44
+ * see the full request/response pair regardless of which branch decided.
45
+ *
46
+ * @internal Exported for tests; transport.ts is the production caller.
47
+ */
48
+ export async function resolveElicitation(req) {
49
+ const hookCtx = {
50
+ elicitationServer: req.serverName,
51
+ elicitationMessage: req.message.slice(0, 500),
52
+ // Schema can be large; cap at 2 KB so hooks don't OOM env vars.
53
+ elicitationSchema: JSON.stringify(req.requestedSchema).slice(0, 2_000),
54
+ };
55
+ let response;
56
+ const hookOutcome = await emitHookWithOutcome("elicitation", hookCtx);
57
+ if (hookOutcome.permissionDecision === "allow") {
58
+ response = { action: "accept", content: {} };
59
+ }
60
+ else if (hookOutcome.permissionDecision === "deny" || !hookOutcome.allowed) {
61
+ response = { action: "decline" };
62
+ }
63
+ else if (interactiveHandler) {
64
+ try {
65
+ response = await interactiveHandler(req);
66
+ }
67
+ catch {
68
+ // Interactive handler crashed — fail-safe decline rather than swallow.
69
+ response = { action: "cancel" };
70
+ }
71
+ }
72
+ else {
73
+ // Headless default — never accept silently.
74
+ response = { action: "decline" };
75
+ }
76
+ emitHook("elicitationResult", {
77
+ elicitationServer: req.serverName,
78
+ elicitationMessage: req.message.slice(0, 500),
79
+ elicitationAction: response.action,
80
+ elicitationContent: response.content ? JSON.stringify(response.content).slice(0, 2_000) : undefined,
81
+ });
82
+ return response;
83
+ }
84
+ /** @internal Test-only reset. */
85
+ export function _resetElicitationForTest() {
86
+ interactiveHandler = undefined;
87
+ }
88
+ //# sourceMappingURL=elicitation.js.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * MCP `roots/list` responder (audit B3).
3
+ *
4
+ * The MCP spec lets a server ask the client "which file system roots are in
5
+ * scope?" via the `roots/list` request. This module owns OH's answer.
6
+ *
7
+ * Roots are computed at request time (no caching) so a `cd` inside the REPL
8
+ * or a future `--add-dir` flag flip is reflected immediately. The set is:
9
+ * - process.cwd() — always included
10
+ * - any directories supplied via `setExtraRoots()` — for `--add-dir` /
11
+ * `/add-dir` integrations once they're properly wired (audit A7 deferred).
12
+ *
13
+ * Pure module with one mutable Set; the SDK handler in `transport.ts` calls
14
+ * `getRoots()` at request time. Exported `setExtraRoots` lets later wiring
15
+ * extend the set without restarting the MCP connection.
16
+ */
17
+ export interface McpRoot {
18
+ uri: string;
19
+ name?: string;
20
+ }
21
+ /**
22
+ * Build the current root list. Always includes the process cwd. Extra roots
23
+ * (added via `setExtraRoots`) are deduplicated against the cwd. Each root is
24
+ * a `file://` URI per the MCP spec; `name` is the basename for readability.
25
+ */
26
+ export declare function getRoots(): McpRoot[];
27
+ /**
28
+ * Replace the extra-roots set. Empty array clears it. Idempotent — passing
29
+ * the same set twice is a no-op for downstream observers.
30
+ *
31
+ * @internal Public for tests + future `--add-dir` wiring.
32
+ */
33
+ export declare function setExtraRoots(paths: readonly string[]): void;
34
+ /** @internal Test-only reset. */
35
+ export declare function _resetRootsForTest(): void;
36
+ //# sourceMappingURL=roots.d.ts.map
@@ -0,0 +1,56 @@
1
+ /**
2
+ * MCP `roots/list` responder (audit B3).
3
+ *
4
+ * The MCP spec lets a server ask the client "which file system roots are in
5
+ * scope?" via the `roots/list` request. This module owns OH's answer.
6
+ *
7
+ * Roots are computed at request time (no caching) so a `cd` inside the REPL
8
+ * or a future `--add-dir` flag flip is reflected immediately. The set is:
9
+ * - process.cwd() — always included
10
+ * - any directories supplied via `setExtraRoots()` — for `--add-dir` /
11
+ * `/add-dir` integrations once they're properly wired (audit A7 deferred).
12
+ *
13
+ * Pure module with one mutable Set; the SDK handler in `transport.ts` calls
14
+ * `getRoots()` at request time. Exported `setExtraRoots` lets later wiring
15
+ * extend the set without restarting the MCP connection.
16
+ */
17
+ import { pathToFileURL } from "node:url";
18
+ const extraRoots = new Set();
19
+ /**
20
+ * Build the current root list. Always includes the process cwd. Extra roots
21
+ * (added via `setExtraRoots`) are deduplicated against the cwd. Each root is
22
+ * a `file://` URI per the MCP spec; `name` is the basename for readability.
23
+ */
24
+ export function getRoots() {
25
+ const seen = new Set();
26
+ const out = [];
27
+ const push = (path) => {
28
+ if (!path || seen.has(path))
29
+ return;
30
+ seen.add(path);
31
+ const uri = pathToFileURL(path).toString();
32
+ const segments = path.split(/[\\/]/).filter(Boolean);
33
+ const name = segments[segments.length - 1] ?? path;
34
+ out.push({ uri, name });
35
+ };
36
+ push(process.cwd());
37
+ for (const p of extraRoots)
38
+ push(p);
39
+ return out;
40
+ }
41
+ /**
42
+ * Replace the extra-roots set. Empty array clears it. Idempotent — passing
43
+ * the same set twice is a no-op for downstream observers.
44
+ *
45
+ * @internal Public for tests + future `--add-dir` wiring.
46
+ */
47
+ export function setExtraRoots(paths) {
48
+ extraRoots.clear();
49
+ for (const p of paths)
50
+ extraRoots.add(p);
51
+ }
52
+ /** @internal Test-only reset. */
53
+ export function _resetRootsForTest() {
54
+ extraRoots.clear();
55
+ }
56
+ //# sourceMappingURL=roots.js.map
@@ -4,6 +4,9 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
4
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
5
5
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
6
6
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7
+ import { ElicitRequestSchema, ListRootsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
8
+ import { resolveElicitation } from "./elicitation.js";
9
+ import { getRoots } from "./roots.js";
7
10
  const pkg = createRequire(import.meta.url)("../../package.json");
8
11
  export class RemoteAuthRequiredError extends Error {
9
12
  serverName;
@@ -136,7 +139,30 @@ function hasAwaitCallback(p) {
136
139
  */
137
140
  export async function buildClient(cfg, opts = {}) {
138
141
  const transport = await buildTransport(cfg, opts);
139
- const client = new Client(CLIENT_INFO, { capabilities: {} });
142
+ // Advertise the `roots` capability (audit B3) so MCP servers know they
143
+ // can ask OH which file system roots are in scope, and the `elicitation`
144
+ // capability (audit B4) so they can request user input. listChanged on
145
+ // roots is false — OH doesn't push notifications when the cwd changes;
146
+ // servers re-query on demand.
147
+ const client = new Client(CLIENT_INFO, {
148
+ capabilities: { roots: { listChanged: false }, elicitation: {} },
149
+ });
150
+ client.setRequestHandler(ListRootsRequestSchema, () => ({ roots: getRoots() }));
151
+ // Elicitation handler — only the form-mode (requestedSchema) variant is
152
+ // supported. URL-mode elicitations decline by default — we don't open
153
+ // browsers from the MCP path. Cast `as never` lets the SDK's wide union
154
+ // accept our narrower response shape.
155
+ client.setRequestHandler(ElicitRequestSchema, async (request) => {
156
+ const params = request.params;
157
+ if (params.requestedSchema === undefined) {
158
+ return { action: "decline" };
159
+ }
160
+ return (await resolveElicitation({
161
+ serverName: cfg.name,
162
+ message: params.message,
163
+ requestedSchema: params.requestedSchema,
164
+ }));
165
+ });
140
166
  const timeoutMs = cfg.timeout ?? DEFAULT_TIMEOUT_MS;
141
167
  async function tryConnect() {
142
168
  let timer = null;
@@ -175,9 +201,25 @@ export async function buildClient(cfg, opts = {}) {
175
201
  catch {
176
202
  // best-effort
177
203
  }
178
- // Build a fresh transport + client for the authenticated retry
204
+ // Build a fresh transport + client for the authenticated retry — same
205
+ // capabilities + handlers as the initial client (audit B3 roots,
206
+ // audit B4 elicitation).
179
207
  const freshTransport = await buildTransport(cfg, opts);
180
- const freshClient = new Client(CLIENT_INFO, { capabilities: {} });
208
+ const freshClient = new Client(CLIENT_INFO, {
209
+ capabilities: { roots: { listChanged: false }, elicitation: {} },
210
+ });
211
+ freshClient.setRequestHandler(ListRootsRequestSchema, () => ({ roots: getRoots() }));
212
+ freshClient.setRequestHandler(ElicitRequestSchema, async (request) => {
213
+ const params = request.params;
214
+ if (params.requestedSchema === undefined) {
215
+ return { action: "decline" };
216
+ }
217
+ return (await resolveElicitation({
218
+ serverName: cfg.name,
219
+ message: params.message,
220
+ requestedSchema: params.requestedSchema,
221
+ }));
222
+ });
181
223
  let freshTimer = null;
182
224
  try {
183
225
  await Promise.race([