@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/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/lsp/client.d.ts +9 -0
- package/dist/lsp/client.js +50 -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/tools/DiagnosticsTool/index.js +10 -22
- package/dist/utils/install-method.d.ts +42 -0
- package/dist/utils/install-method.js +110 -0
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -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
|
|
@@ -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
|
package/dist/mcp/transport.js
CHANGED
|
@@ -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
|
-
|
|
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, {
|
|
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([
|