@zhijiewang/openharness 2.13.0 → 2.14.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.
@@ -7,6 +7,33 @@ import { dirname, join, resolve } from "node:path";
7
7
  import { getContextWindow } from "../harness/cost.js";
8
8
  import { createSession, listSessions, loadSession, saveSession } from "../harness/session.js";
9
9
  import { compressMessages } from "../query/index.js";
10
+ function formatMessagesAsMarkdown(messages) {
11
+ const blocks = [];
12
+ for (const m of messages) {
13
+ if (m.role === "user") {
14
+ blocks.push(`## User\n\n${m.content}`);
15
+ }
16
+ else if (m.role === "assistant") {
17
+ const parts = [];
18
+ if (m.content)
19
+ parts.push(m.content);
20
+ if (m.toolCalls?.length) {
21
+ for (const tc of m.toolCalls) {
22
+ parts.push(`**Tool call:** \`${tc.toolName}(${JSON.stringify(tc.arguments)})\``);
23
+ }
24
+ }
25
+ blocks.push(`## Assistant\n\n${parts.join("\n\n")}`);
26
+ }
27
+ else if (m.role === "tool") {
28
+ for (const tr of m.toolResults ?? []) {
29
+ const label = tr.isError ? "Tool error" : "Tool result";
30
+ blocks.push(`**${label}:**\n\n\`\`\`\n${tr.output}\n\`\`\``);
31
+ }
32
+ }
33
+ // system / info messages are skipped — they're OH-internal UX, not conversation
34
+ }
35
+ return blocks.join("\n\n");
36
+ }
10
37
  function setPinned(args, ctx, pinned) {
11
38
  const idx = parseInt(args.trim(), 10);
12
39
  if (Number.isNaN(idx) || idx < 1 || idx > ctx.messages.length) {
@@ -59,20 +86,19 @@ export function registerSessionCommands(register) {
59
86
  compactedMessages: compacted,
60
87
  };
61
88
  });
62
- register("export", "Export conversation to file", (_args, ctx) => {
63
- const lines = ctx.messages
64
- .filter((m) => m.role === "user" || m.role === "assistant")
65
- .map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`)
66
- .join("\n\n");
67
- const filename = `.oh/export-${ctx.sessionId}.md`;
89
+ register("export", "Export conversation to file (args: 'json' for JSON format)", (args, ctx) => {
90
+ const asJson = args.trim().toLowerCase() === "json";
91
+ const ext = asJson ? "json" : "md";
92
+ const filename = `.oh/export-${ctx.sessionId}.${ext}`;
93
+ const body = asJson ? JSON.stringify(ctx.messages, null, 2) : formatMessagesAsMarkdown(ctx.messages);
68
94
  try {
69
95
  mkdirSync(dirname(filename), { recursive: true });
70
96
  const { writeFileSync } = require("node:fs");
71
- writeFileSync(filename, lines);
72
- return { output: `Exported to ${filename}`, handled: true };
97
+ writeFileSync(filename, body);
98
+ return { output: `Exported ${ctx.messages.length} messages to ${filename}`, handled: true };
73
99
  }
74
100
  catch {
75
- return { output: `Export failed. Content:\n\n${lines.slice(0, 500)}`, handled: true };
101
+ return { output: `Export failed. Content:\n\n${body.slice(0, 500)}`, handled: true };
76
102
  }
77
103
  });
78
104
  register("history", "List recent sessions or search across them", (args) => {
@@ -106,7 +132,8 @@ export function registerSessionCommands(register) {
106
132
  const lines = sessions.map((s) => {
107
133
  const date = new Date(s.updatedAt).toLocaleDateString();
108
134
  const cost = s.cost > 0 ? ` $${s.cost.toFixed(4)}` : "";
109
- return ` ${s.id} ${date} ${String(s.messages).padStart(3)} msgs ${(s.model || "?").slice(0, 24)}${cost}`;
135
+ const parent = s.parentSessionId ? ` ⤴ forked from ${s.parentSessionId}` : "";
136
+ return ` ${s.id} ${date} ${String(s.messages).padStart(3)} msgs ${(s.model || "?").slice(0, 24)}${cost}${parent}`;
110
137
  });
111
138
  return { output: `Recent sessions (use /resume <id> to continue):\n${lines.join("\n")}`, handled: true };
112
139
  });
@@ -127,11 +154,11 @@ export function registerSessionCommands(register) {
127
154
  }
128
155
  });
129
156
  register("fork", "Fork current session (create a branch you can resume later)", (_args, ctx) => {
130
- const forked = createSession("", "");
157
+ const forked = createSession(ctx.providerName, ctx.model, { parentSessionId: ctx.sessionId });
131
158
  forked.messages = [...ctx.messages];
132
159
  saveSession(forked);
133
160
  return {
134
- output: `Session forked as ${forked.id}. Resume later with: oh --resume ${forked.id}`,
161
+ output: `Session forked as ${forked.id} (from ${ctx.sessionId}). Resume later with: oh --resume ${forked.id}`,
135
162
  handled: true,
136
163
  };
137
164
  });
@@ -107,6 +107,10 @@ export type OhConfig = {
107
107
  apiKey?: string;
108
108
  baseUrl?: string;
109
109
  }>;
110
+ /** MCP OAuth token storage backend. Default: "auto" — keychain when available, filesystem otherwise. */
111
+ credentials?: {
112
+ storage?: "filesystem" | "auto";
113
+ };
110
114
  /** Auto-commit after each file-modifying tool execution */
111
115
  gitCommitPerTool?: boolean;
112
116
  /** Effort level for LLM reasoning depth */
@@ -13,6 +13,8 @@ export type Session = {
13
13
  gitBranch?: string;
14
14
  workingDir?: string;
15
15
  tools?: string[];
16
+ /** For forked sessions: the session this one was forked from. */
17
+ parentSessionId?: string;
16
18
  /** Hibernate state — saved on exit for wake reconstruction */
17
19
  hibernate?: {
18
20
  summary?: string;
@@ -26,6 +28,7 @@ export declare function createSession(provider: string, model: string, extras?:
26
28
  gitBranch?: string;
27
29
  workingDir?: string;
28
30
  tools?: string[];
31
+ parentSessionId?: string;
29
32
  }): Session;
30
33
  export declare function saveSession(session: Session, dir?: string): string;
31
34
  export declare function loadSession(id: string, dir?: string): Session;
@@ -35,6 +38,7 @@ export declare function listSessions(dir?: string): Array<{
35
38
  messages: number;
36
39
  cost: number;
37
40
  updatedAt: number;
41
+ parentSessionId?: string;
38
42
  }>;
39
43
  /** Returns the ID of the most recently updated session, or null if none exist. */
40
44
  export declare function getLastSessionId(dir?: string): string | null;
@@ -18,6 +18,7 @@ export function createSession(provider, model, extras) {
18
18
  ...(extras?.gitBranch ? { gitBranch: extras.gitBranch } : {}),
19
19
  ...(extras?.workingDir ? { workingDir: extras.workingDir } : {}),
20
20
  ...(extras?.tools ? { tools: extras.tools } : {}),
21
+ ...(extras?.parentSessionId ? { parentSessionId: extras.parentSessionId } : {}),
21
22
  };
22
23
  }
23
24
  let _evicting = false;
@@ -73,6 +74,7 @@ export function listSessions(dir) {
73
74
  messages: data.messages?.length ?? 0,
74
75
  cost: data.totalCost ?? 0,
75
76
  updatedAt: data.updatedAt ?? 0,
77
+ ...(data.parentSessionId ? { parentSessionId: data.parentSessionId } : {}),
76
78
  };
77
79
  }
78
80
  catch {
package/dist/main.js CHANGED
@@ -276,38 +276,62 @@ program
276
276
  : opts.permissionMode !== "ask"
277
277
  ? opts.permissionMode
278
278
  : (savedConfig?.permissionMode ?? "ask");
279
- // Auto-detect provider or prompt for setup
279
+ // Auto-detect provider or launch the setup wizard
280
280
  let provider;
281
281
  let resolvedModel;
282
- try {
282
+ const tryCreateProvider = async () => {
283
283
  const { createProvider } = await import("./providers/index.js");
284
284
  const overrides = {};
285
- if (savedConfig?.apiKey)
286
- overrides.apiKey = savedConfig.apiKey;
287
- if (savedConfig?.baseUrl)
288
- overrides.baseUrl = savedConfig.baseUrl;
289
- const result = await createProvider(effectiveModel, Object.keys(overrides).length ? overrides : undefined);
285
+ const fresh = readOhConfig();
286
+ if (fresh?.apiKey)
287
+ overrides.apiKey = fresh.apiKey;
288
+ if (fresh?.baseUrl)
289
+ overrides.baseUrl = fresh.baseUrl;
290
+ const targetModel = fresh?.model ?? effectiveModel;
291
+ return createProvider(targetModel, Object.keys(overrides).length ? overrides : undefined);
292
+ };
293
+ try {
294
+ const result = await tryCreateProvider();
290
295
  provider = result.provider;
291
296
  resolvedModel = result.model;
292
297
  }
293
298
  catch (_err) {
294
- // First-run experience: guide the user
295
- console.log();
296
- console.log(" Welcome to OpenHarness!");
297
- console.log();
298
- console.log(" To get started, choose a provider:");
299
- console.log();
300
- console.log(" Local (free, no API key):");
301
- console.log(" npx openharness --model ollama/llama3");
302
- console.log(" npx openharness --model ollama/qwen2.5:7b-instruct");
303
- console.log();
304
- console.log(" Cloud (needs API key in env var):");
305
- console.log(" OPENAI_API_KEY=sk-... npx openharness --model gpt-4o");
306
- console.log(" ANTHROPIC_API_KEY=sk-ant-... npx openharness --model claude-sonnet-4-6");
307
- console.log();
308
- console.log(" Make sure Ollama is running: ollama serve");
309
- console.log();
310
- process.exit(0);
299
+ // First-run: launch the interactive wizard in TTY mode; fall back to
300
+ // static help text for non-TTY (CI, piped stdin, etc.).
301
+ if (process.stdout.isTTY && process.stdin.isTTY) {
302
+ const { default: InitWizard } = await import("./components/InitWizard.js");
303
+ const { waitUntilExit } = render(_jsx(InitWizard, { onDone: () => { } }));
304
+ await waitUntilExit();
305
+ try {
306
+ const result = await tryCreateProvider();
307
+ provider = result.provider;
308
+ resolvedModel = result.model;
309
+ }
310
+ catch {
311
+ console.log();
312
+ console.log(" Setup incomplete. Run 'oh init' to try again, or set a provider via --model.");
313
+ console.log();
314
+ process.exit(0);
315
+ }
316
+ }
317
+ else {
318
+ console.log();
319
+ console.log(" Welcome to OpenHarness!");
320
+ console.log();
321
+ console.log(" To get started, choose a provider:");
322
+ console.log();
323
+ console.log(" Local (free, no API key):");
324
+ console.log(" npx openharness --model ollama/llama3");
325
+ console.log(" npx openharness --model ollama/qwen2.5:7b-instruct");
326
+ console.log();
327
+ console.log(" Cloud (needs API key in env var):");
328
+ console.log(" OPENAI_API_KEY=sk-... npx openharness --model gpt-4o");
329
+ console.log(" ANTHROPIC_API_KEY=sk-ant-... npx openharness --model claude-sonnet-4-6");
330
+ console.log();
331
+ console.log(" Make sure Ollama is running: ollama serve");
332
+ console.log();
333
+ process.exit(0);
334
+ }
311
335
  }
312
336
  const mcpTools = await loadMcpTools();
313
337
  const mcpNames = connectedMcpServers();
@@ -0,0 +1,16 @@
1
+ /**
2
+ * OS keychain backend for MCP OAuth tokens.
3
+ *
4
+ * Wraps @napi-rs/keyring (optional dependency). All functions catch every error
5
+ * and return an "unavailable" sentinel so the orchestrator in oauth-storage.ts
6
+ * can fall back to the filesystem store without any user-visible disruption.
7
+ */
8
+ import type { OhCredentials } from "./oauth-storage-fs.js";
9
+ /** Clear the cached module reference. For tests only. */
10
+ export declare function _resetForTesting(): void;
11
+ /** True iff @napi-rs/keyring loaded successfully AND the platform has an Entry class. */
12
+ export declare function keychainAvailable(): boolean;
13
+ export declare function saveCredentialsKeychain(name: string, creds: OhCredentials): boolean;
14
+ export declare function loadCredentialsKeychain(name: string): OhCredentials | undefined;
15
+ export declare function deleteCredentialsKeychain(name: string): boolean;
16
+ //# sourceMappingURL=oauth-keychain.d.ts.map
@@ -0,0 +1,70 @@
1
+ /**
2
+ * OS keychain backend for MCP OAuth tokens.
3
+ *
4
+ * Wraps @napi-rs/keyring (optional dependency). All functions catch every error
5
+ * and return an "unavailable" sentinel so the orchestrator in oauth-storage.ts
6
+ * can fall back to the filesystem store without any user-visible disruption.
7
+ */
8
+ import { createRequire } from "node:module";
9
+ const SERVICE = "openharness-mcp";
10
+ const nodeRequire = createRequire(import.meta.url);
11
+ let entryCtorCache;
12
+ function getEntryCtor() {
13
+ if (entryCtorCache !== undefined)
14
+ return entryCtorCache;
15
+ try {
16
+ const mod = nodeRequire("@napi-rs/keyring");
17
+ entryCtorCache = mod.Entry;
18
+ }
19
+ catch {
20
+ entryCtorCache = null;
21
+ }
22
+ return entryCtorCache;
23
+ }
24
+ /** Clear the cached module reference. For tests only. */
25
+ export function _resetForTesting() {
26
+ entryCtorCache = undefined;
27
+ }
28
+ /** True iff @napi-rs/keyring loaded successfully AND the platform has an Entry class. */
29
+ export function keychainAvailable() {
30
+ return getEntryCtor() !== null;
31
+ }
32
+ export function saveCredentialsKeychain(name, creds) {
33
+ const Ctor = getEntryCtor();
34
+ if (!Ctor)
35
+ return false;
36
+ try {
37
+ new Ctor(SERVICE, name).setPassword(JSON.stringify(creds));
38
+ return true;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ export function loadCredentialsKeychain(name) {
45
+ const Ctor = getEntryCtor();
46
+ if (!Ctor)
47
+ return undefined;
48
+ try {
49
+ const raw = new Ctor(SERVICE, name).getPassword();
50
+ if (!raw)
51
+ return undefined;
52
+ return JSON.parse(raw);
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ }
58
+ export function deleteCredentialsKeychain(name) {
59
+ const Ctor = getEntryCtor();
60
+ if (!Ctor)
61
+ return false;
62
+ try {
63
+ new Ctor(SERVICE, name).deletePassword();
64
+ return true;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
70
+ //# sourceMappingURL=oauth-keychain.js.map
@@ -0,0 +1,23 @@
1
+ export type OhCredentials = {
2
+ issuerUrl: string;
3
+ clientInformation: {
4
+ client_id: string;
5
+ client_secret?: string;
6
+ } & Record<string, unknown>;
7
+ tokens: {
8
+ access_token: string;
9
+ refresh_token?: string;
10
+ expires_at?: number;
11
+ token_type?: string;
12
+ scope?: string;
13
+ };
14
+ codeVerifier?: string;
15
+ updatedAt: string;
16
+ };
17
+ /** Atomically write credentials for one server. Creates the directory with 0o700 on first use. */
18
+ export declare function saveCredentials(storageDir: string, name: string, creds: OhCredentials): Promise<void>;
19
+ /** Load credentials. Returns undefined on missing file OR corrupt JSON. Warns on world/group-readable mode. */
20
+ export declare function loadCredentials(storageDir: string, name: string): Promise<OhCredentials | undefined>;
21
+ /** Idempotent delete — ENOENT is swallowed. */
22
+ export declare function deleteCredentials(storageDir: string, name: string): Promise<void>;
23
+ //# sourceMappingURL=oauth-storage-fs.d.ts.map
@@ -0,0 +1,58 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ function pathFor(storageDir, name) {
4
+ return join(storageDir, `${name}.json`);
5
+ }
6
+ /** Atomically write credentials for one server. Creates the directory with 0o700 on first use. */
7
+ export async function saveCredentials(storageDir, name, creds) {
8
+ const filePath = pathFor(storageDir, name);
9
+ const tmpPath = `${filePath}.tmp`;
10
+ await fs.mkdir(dirname(filePath), { recursive: true, mode: 0o700 });
11
+ const body = JSON.stringify(creds, null, 2);
12
+ await fs.writeFile(tmpPath, body, { mode: 0o600 });
13
+ await fs.rename(tmpPath, filePath);
14
+ }
15
+ /** Load credentials. Returns undefined on missing file OR corrupt JSON. Warns on world/group-readable mode. */
16
+ export async function loadCredentials(storageDir, name) {
17
+ const filePath = pathFor(storageDir, name);
18
+ let raw;
19
+ try {
20
+ raw = await fs.readFile(filePath, "utf8");
21
+ }
22
+ catch (err) {
23
+ if (err.code === "ENOENT")
24
+ return undefined;
25
+ throw err;
26
+ }
27
+ try {
28
+ if (process.platform !== "win32") {
29
+ const s = await fs.stat(filePath);
30
+ if ((s.mode & 0o077) !== 0) {
31
+ console.warn(`[mcp] credentials file for '${name}' is world/group-readable; run 'chmod 600 ${filePath}'`);
32
+ }
33
+ }
34
+ }
35
+ catch {
36
+ // stat failure is non-fatal for load
37
+ }
38
+ try {
39
+ return JSON.parse(raw);
40
+ }
41
+ catch {
42
+ console.warn(`[mcp] credentials file for '${name}' is corrupt; ignoring`);
43
+ return undefined;
44
+ }
45
+ }
46
+ /** Idempotent delete — ENOENT is swallowed. */
47
+ export async function deleteCredentials(storageDir, name) {
48
+ const filePath = pathFor(storageDir, name);
49
+ try {
50
+ await fs.unlink(filePath);
51
+ }
52
+ catch (err) {
53
+ if (err.code === "ENOENT")
54
+ return;
55
+ throw err;
56
+ }
57
+ }
58
+ //# sourceMappingURL=oauth-storage-fs.js.map
@@ -1,23 +1,28 @@
1
- export type OhCredentials = {
2
- issuerUrl: string;
3
- clientInformation: {
4
- client_id: string;
5
- client_secret?: string;
6
- } & Record<string, unknown>;
7
- tokens: {
8
- access_token: string;
9
- refresh_token?: string;
10
- expires_at?: number;
11
- token_type?: string;
12
- scope?: string;
13
- };
14
- codeVerifier?: string;
15
- updatedAt: string;
16
- };
17
- /** Atomically write credentials for one server. Creates the directory with 0o700 on first use. */
1
+ /**
2
+ * OAuth token storage orchestrator.
3
+ *
4
+ * Prefers the OS keychain via `oauth-keychain.ts` when available and not
5
+ * opted out via `credentials.storage: "filesystem"`. Falls back to the
6
+ * filesystem store in `oauth-storage-fs.ts` on any keychain failure.
7
+ *
8
+ * Public API unchanged: callers in oauth.ts and commands/mcp-auth.ts
9
+ * continue to import `saveCredentials` / `loadCredentials` /
10
+ * `deleteCredentials` / `OhCredentials` from this module.
11
+ */
12
+ export type { OhCredentials } from "./oauth-storage-fs.js";
13
+ import type { OhCredentials } from "./oauth-storage-fs.js";
14
+ /**
15
+ * Save credentials. Tries keychain first when available; falls back to
16
+ * filesystem on any keychain failure.
17
+ */
18
18
  export declare function saveCredentials(storageDir: string, name: string, creds: OhCredentials): Promise<void>;
19
- /** Load credentials. Returns undefined on missing file OR corrupt JSON. Warns on world/group-readable mode. */
19
+ /**
20
+ * Load credentials. Checks keychain first (when available), then filesystem.
21
+ * If both have entries for the same name, keychain wins.
22
+ */
20
23
  export declare function loadCredentials(storageDir: string, name: string): Promise<OhCredentials | undefined>;
21
- /** Idempotent delete — ENOENT is swallowed. */
24
+ /**
25
+ * Delete credentials from BOTH keychain and filesystem. Idempotent.
26
+ */
22
27
  export declare function deleteCredentials(storageDir: string, name: string): Promise<void>;
23
28
  //# sourceMappingURL=oauth-storage.d.ts.map
@@ -1,58 +1,55 @@
1
- import { promises as fs } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- function pathFor(storageDir, name) {
4
- return join(storageDir, `${name}.json`);
1
+ /**
2
+ * OAuth token storage orchestrator.
3
+ *
4
+ * Prefers the OS keychain via `oauth-keychain.ts` when available and not
5
+ * opted out via `credentials.storage: "filesystem"`. Falls back to the
6
+ * filesystem store in `oauth-storage-fs.ts` on any keychain failure.
7
+ *
8
+ * Public API unchanged: callers in oauth.ts and commands/mcp-auth.ts
9
+ * continue to import `saveCredentials` / `loadCredentials` /
10
+ * `deleteCredentials` / `OhCredentials` from this module.
11
+ */
12
+ import { readOhConfig } from "../harness/config.js";
13
+ import { deleteCredentialsKeychain, keychainAvailable, loadCredentialsKeychain, saveCredentialsKeychain, } from "./oauth-keychain.js";
14
+ import { deleteCredentials as deleteFs, loadCredentials as loadFs, saveCredentials as saveFs, } from "./oauth-storage-fs.js";
15
+ function shouldUseKeychain() {
16
+ // Explicit opt-out via env var (used by the test runner to isolate tests
17
+ // from the real OS keychain). Accepts "disabled", "false", "0", or "off".
18
+ const envOpt = (process.env.OH_KEYCHAIN ?? "").toLowerCase();
19
+ if (envOpt === "disabled" || envOpt === "false" || envOpt === "0" || envOpt === "off")
20
+ return false;
21
+ const cfg = readOhConfig();
22
+ if (cfg?.credentials?.storage === "filesystem")
23
+ return false;
24
+ return keychainAvailable();
5
25
  }
6
- /** Atomically write credentials for one server. Creates the directory with 0o700 on first use. */
26
+ /**
27
+ * Save credentials. Tries keychain first when available; falls back to
28
+ * filesystem on any keychain failure.
29
+ */
7
30
  export async function saveCredentials(storageDir, name, creds) {
8
- const filePath = pathFor(storageDir, name);
9
- const tmpPath = `${filePath}.tmp`;
10
- await fs.mkdir(dirname(filePath), { recursive: true, mode: 0o700 });
11
- const body = JSON.stringify(creds, null, 2);
12
- await fs.writeFile(tmpPath, body, { mode: 0o600 });
13
- await fs.rename(tmpPath, filePath);
31
+ if (shouldUseKeychain() && saveCredentialsKeychain(name, creds))
32
+ return;
33
+ await saveFs(storageDir, name, creds);
14
34
  }
15
- /** Load credentials. Returns undefined on missing file OR corrupt JSON. Warns on world/group-readable mode. */
35
+ /**
36
+ * Load credentials. Checks keychain first (when available), then filesystem.
37
+ * If both have entries for the same name, keychain wins.
38
+ */
16
39
  export async function loadCredentials(storageDir, name) {
17
- const filePath = pathFor(storageDir, name);
18
- let raw;
19
- try {
20
- raw = await fs.readFile(filePath, "utf8");
21
- }
22
- catch (err) {
23
- if (err.code === "ENOENT")
24
- return undefined;
25
- throw err;
26
- }
27
- try {
28
- if (process.platform !== "win32") {
29
- const s = await fs.stat(filePath);
30
- if ((s.mode & 0o077) !== 0) {
31
- console.warn(`[mcp] credentials file for '${name}' is world/group-readable; run 'chmod 600 ${filePath}'`);
32
- }
33
- }
34
- }
35
- catch {
36
- // stat failure is non-fatal for load
37
- }
38
- try {
39
- return JSON.parse(raw);
40
- }
41
- catch {
42
- console.warn(`[mcp] credentials file for '${name}' is corrupt; ignoring`);
43
- return undefined;
40
+ if (shouldUseKeychain()) {
41
+ const fromKc = loadCredentialsKeychain(name);
42
+ if (fromKc)
43
+ return fromKc;
44
44
  }
45
+ return loadFs(storageDir, name);
45
46
  }
46
- /** Idempotent delete — ENOENT is swallowed. */
47
+ /**
48
+ * Delete credentials from BOTH keychain and filesystem. Idempotent.
49
+ */
47
50
  export async function deleteCredentials(storageDir, name) {
48
- const filePath = pathFor(storageDir, name);
49
- try {
50
- await fs.unlink(filePath);
51
- }
52
- catch (err) {
53
- if (err.code === "ENOENT")
54
- return;
55
- throw err;
56
- }
51
+ if (keychainAvailable())
52
+ deleteCredentialsKeychain(name);
53
+ await deleteFs(storageDir, name);
57
54
  }
58
55
  //# sourceMappingURL=oauth-storage.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -84,5 +84,8 @@
84
84
  "bugs": {
85
85
  "url": "https://github.com/zhijiewong/openharness/issues"
86
86
  },
87
- "homepage": "https://github.com/zhijiewong/openharness#readme"
87
+ "homepage": "https://github.com/zhijiewong/openharness#readme",
88
+ "optionalDependencies": {
89
+ "@napi-rs/keyring": "^1.2.0"
90
+ }
88
91
  }