@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.
@@ -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
  /**
@@ -51,7 +51,16 @@ export declare class LspClient {
51
51
  getDefinition(filePath: string, line: number, character: number): Promise<Location[]>;
52
52
  /** Find references */
53
53
  getReferences(filePath: string, line: number, character: number): Promise<Location[]>;
54
+ /**
55
+ * Hover at a position — returns the text content of the LSP hover response,
56
+ * or `null` if the server returned no hover information / doesn't support
57
+ * the `textDocument/hover` capability. The LSP `MarkupContent` envelope is
58
+ * unwrapped so callers see plain text or markdown.
59
+ */
60
+ getHover(filePath: string, line: number, character: number): Promise<string | null>;
54
61
  private guessLanguage;
62
+ /** @internal Exposed for unit tests of the hover-content unwrapper. */
63
+ static unwrapHoverContents(result: unknown): string | null;
55
64
  disconnect(): void;
56
65
  }
57
66
  export {};
@@ -4,6 +4,38 @@
4
4
  */
5
5
  import { spawn } from "node:child_process";
6
6
  import { readFileSync } from "node:fs";
7
+ /**
8
+ * Unwrap a `textDocument/hover` result into a plain string. LSP allows three
9
+ * `contents` shapes: a bare string, a `{ kind, value }` envelope, or an
10
+ * array of either. Returns null when nothing is hoverable. Pure — exposed
11
+ * via `LspClient.unwrapHoverContents` for unit tests.
12
+ */
13
+ function unwrapHoverContents(result) {
14
+ if (!result || typeof result !== "object")
15
+ return null;
16
+ const r = result;
17
+ if (!r.contents)
18
+ return null;
19
+ const c = r.contents;
20
+ if (typeof c === "string")
21
+ return c;
22
+ if (Array.isArray(c)) {
23
+ const parts = c.map((item) => {
24
+ if (typeof item === "string")
25
+ return item;
26
+ if (item && typeof item === "object" && typeof item.value === "string") {
27
+ return item.value;
28
+ }
29
+ return "";
30
+ });
31
+ const joined = parts.filter(Boolean).join("\n");
32
+ return joined || null;
33
+ }
34
+ if (typeof c === "object" && typeof c.value === "string") {
35
+ return c.value;
36
+ }
37
+ return null;
38
+ }
7
39
  export class LspClient {
8
40
  proc;
9
41
  nextId = 1;
@@ -150,6 +182,20 @@ export class LspClient {
150
182
  });
151
183
  return result ?? [];
152
184
  }
185
+ /**
186
+ * Hover at a position — returns the text content of the LSP hover response,
187
+ * or `null` if the server returned no hover information / doesn't support
188
+ * the `textDocument/hover` capability. The LSP `MarkupContent` envelope is
189
+ * unwrapped so callers see plain text or markdown.
190
+ */
191
+ async getHover(filePath, line, character) {
192
+ const uri = `file://${filePath.replace(/\\/g, "/")}`;
193
+ const result = await this.send("textDocument/hover", {
194
+ textDocument: { uri },
195
+ position: { line, character },
196
+ });
197
+ return unwrapHoverContents(result);
198
+ }
153
199
  guessLanguage(path) {
154
200
  if (path.endsWith(".ts") || path.endsWith(".tsx"))
155
201
  return "typescript";
@@ -165,6 +211,10 @@ export class LspClient {
165
211
  return "java";
166
212
  return "plaintext";
167
213
  }
214
+ /** @internal Exposed for unit tests of the hover-content unwrapper. */
215
+ static unwrapHoverContents(result) {
216
+ return unwrapHoverContents(result);
217
+ }
168
218
  disconnect() {
169
219
  this.send("shutdown", {})
170
220
  .then(() => {