From 8c6a10517fc6bf8a846ae30535bc38e7b3259dd0 Mon Sep 17 00:00:00 2001 From: gnanam1990 Date: Thu, 2 Apr 2026 09:05:00 +0530 Subject: [PATCH 01/35] fix: show correct version in startup screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StartupScreen.ts was reading the version via globalThis['MACRO_DISPLAY_VERSION'] which is never populated — the Bun bundler inlines it as MACRO.DISPLAY_VERSION (dot notation), not as a globalThis key. Result: startup screen always showed the hardcoded fallback 'v0.1.4' regardless of the installed version. Fix: use MACRO.DISPLAY_VERSION ?? MACRO.VERSION directly, consistent with cli.tsx, main.tsx, and logoV2Utils.ts. Fixes #95 Co-Authored-By: Claude Sonnet 4.6 --- src/components/StartupScreen.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/StartupScreen.ts b/src/components/StartupScreen.ts index 602b3d08..6fd347a4 100644 --- a/src/components/StartupScreen.ts +++ b/src/components/StartupScreen.ts @@ -5,6 +5,8 @@ * Addresses: https://github.com/Gitlawb/openclaude/issues/55 */ +declare const MACRO: { VERSION: string; DISPLAY_VERSION?: string } + const ESC = '\x1b[' const RESET = `${ESC}0m` const DIM = `${ESC}2m` @@ -172,7 +174,7 @@ export function printStartupScreen(): void { out.push(boxRow(sRow, W, sLen)) out.push(`${rgb(...BORDER)}\u255a${'\u2550'.repeat(W - 2)}\u255d${RESET}`) - out.push(` ${DIM}${rgb(...DIMCOL)}openclaude v${(globalThis as Record)['MACRO_DISPLAY_VERSION'] ?? '0.1.4'}${RESET}`) + out.push(` ${DIM}${rgb(...DIMCOL)}openclaude v${MACRO.DISPLAY_VERSION ?? MACRO.VERSION}${RESET}`) out.push('') process.stdout.write(out.join('\n') + '\n') From 47b19c9a00f4c45d70b96c83204133fc2d7d0130 Mon Sep 17 00:00:00 2001 From: gnanam1990 Date: Thu, 2 Apr 2026 09:11:12 +0530 Subject: [PATCH 02/35] fix: style version number in startup screen accent orange Apply the existing ACCENT colour (rgb 240 148 100) to the version string so it stands out against the dim label, matching the warm orange used throughout the startup screen for stars and status text. Requested in #95. Co-Authored-By: Claude Sonnet 4.6 --- src/components/StartupScreen.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/StartupScreen.ts b/src/components/StartupScreen.ts index 6fd347a4..ded4f457 100644 --- a/src/components/StartupScreen.ts +++ b/src/components/StartupScreen.ts @@ -174,7 +174,7 @@ export function printStartupScreen(): void { out.push(boxRow(sRow, W, sLen)) out.push(`${rgb(...BORDER)}\u255a${'\u2550'.repeat(W - 2)}\u255d${RESET}`) - out.push(` ${DIM}${rgb(...DIMCOL)}openclaude v${MACRO.DISPLAY_VERSION ?? MACRO.VERSION}${RESET}`) + out.push(` ${DIM}${rgb(...DIMCOL)}openclaude ${RESET}${rgb(...ACCENT)}v${MACRO.DISPLAY_VERSION ?? MACRO.VERSION}${RESET}`) out.push('') process.stdout.write(out.join('\n') + '\n') From f3ebd7d256e7454658cc65b5f29d3b8d0226d80d Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 2 Apr 2026 12:01:01 +0800 Subject: [PATCH 03/35] fix: convert max_tokens to max_completion_tokens for Azure OpenAI Azure OpenAI API rejects the max_tokens parameter and requires max_completion_tokens instead. This change ensures the conversion is robust by validating that max_tokens is a positive number before using it, preventing edge cases like null or "null" string values from being incorrectly sent. Co-Authored-By: Claude Opus 4.6 --- src/services/api/openaiShim.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 9b77d07e..9a500490 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -656,10 +656,20 @@ class OpenAIShimMessages { messages: openaiMessages, stream: params.stream ?? false, } - if (params.max_tokens !== undefined) { - body.max_completion_tokens = params.max_tokens - } else if ((params as Record).max_completion_tokens !== undefined) { - body.max_completion_tokens = (params as Record).max_completion_tokens + // Convert max_tokens to max_completion_tokens for OpenAI API compatibility. + // Azure OpenAI requires max_completion_tokens and does not accept max_tokens. + // Ensure max_tokens is a valid positive number before using it. + const maxTokensValue = typeof params.max_tokens === 'number' && params.max_tokens > 0 + ? params.max_tokens + : undefined + const maxCompletionTokensValue = typeof (params as Record).max_completion_tokens === 'number' + ? (params as Record).max_completion_tokens as number + : undefined + + if (maxTokensValue !== undefined) { + body.max_completion_tokens = maxTokensValue + } else if (maxCompletionTokensValue !== undefined) { + body.max_completion_tokens = maxCompletionTokensValue } if (params.stream) { From 5f75f67a270bbfcfa6ae452a0cac5cc0e5c2b6da Mon Sep 17 00:00:00 2001 From: Mikey <5217366+BrainSlugs83@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:29:42 -0700 Subject: [PATCH 04/35] security: pin all dependencies to exact versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes caret (^) ranges from all 74 dependencies in package.json, locking each to the exact version resolved in bun.lock. Motivation: the axios supply chain attack of March 31 2026 demonstrated that caret ranges are a live attack vector. axios@^1.14.0 would have resolved to the trojanized 1.14.1 (bundled plain-crypto-js RAT, C2 sfrclak.com). Both 1.14.1 and 0.30.4 were unpublished within 24h. Key pins: axios ^1.14.0 → 1.14.0 (trojanized 1.14.1 blocked) undici ^7.3.0 → 7.24.6 (7 CVEs between 7.3 and 7.24) yaml ^2.7.0 → 2.8.3 (CVE-2026-33532 fix) ajv ^8.17.0 → 8.18.0 (ReDoS fix) lodash-es ^4.17.21 → 4.17.23 (prototype pollution fix) zod ^3.24.0 → 3.25.76 (large range locked) All 74 deps verified: integrity hashes match npm registry, no known supply chain incidents, no postinstall scripts in lockfile. --- bun.lock | 148 +++++++++++++++++++++++++-------------------------- package.json | 148 +++++++++++++++++++++++++-------------------------- 2 files changed, 148 insertions(+), 148 deletions(-) diff --git a/bun.lock b/bun.lock index 7051fc49..ce4a898c 100644 --- a/bun.lock +++ b/bun.lock @@ -5,82 +5,82 @@ "": { "name": "openclaude", "dependencies": { - "@alcalzone/ansi-tokenize": "^0.3.0", - "@anthropic-ai/bedrock-sdk": "^0.26.0", - "@anthropic-ai/foundry-sdk": "^0.2.0", - "@anthropic-ai/sandbox-runtime": "^0.0.46", - "@anthropic-ai/sdk": "^0.81.0", - "@anthropic-ai/vertex-sdk": "^0.14.0", - "@commander-js/extra-typings": "^12.0.0", - "@growthbook/growthbook": "^1.3.0", - "@modelcontextprotocol/sdk": "^1.12.0", - "@opentelemetry/api": "^1.9.1", - "@opentelemetry/api-logs": "^0.214.0", - "@opentelemetry/core": "^2.6.1", - "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", - "@opentelemetry/exporter-trace-otlp-grpc": "^0.57.0", - "@opentelemetry/resources": "^2.6.1", - "@opentelemetry/sdk-logs": "^0.214.0", - "@opentelemetry/sdk-metrics": "^2.6.1", - "@opentelemetry/sdk-trace-base": "^2.6.1", - "@opentelemetry/sdk-trace-node": "^2.6.1", - "@opentelemetry/semantic-conventions": "^1.40.0", - "ajv": "^8.17.0", - "auto-bind": "^5.0.1", - "axios": "^1.14.0", - "bidi-js": "^1.0.3", - "chalk": "^5.4.0", - "chokidar": "^4.0.0", - "cli-boxes": "^3.0.0", - "cli-highlight": "^2.1.0", - "code-excerpt": "^4.0.0", - "commander": "^12.0.0", - "diff": "^7.0.0", - "emoji-regex": "^10.4.0", - "env-paths": "^3.0.0", - "execa": "^9.5.0", - "fflate": "^0.8.2", - "figures": "^6.1.0", - "fuse.js": "^7.1.0", - "get-east-asian-width": "^1.3.0", - "google-auth-library": "^9.15.0", - "https-proxy-agent": "^7.0.6", - "ignore": "^7.0.0", - "indent-string": "^5.0.0", - "jsonc-parser": "^3.3.1", - "lodash-es": "^4.17.21", - "lru-cache": "^11.0.0", - "marked": "^15.0.0", - "p-map": "^7.0.3", - "picomatch": "^4.0.0", - "proper-lockfile": "^4.1.2", - "qrcode": "^1.5.4", - "react": "^19.2.4", - "react-compiler-runtime": "^1.0.0", - "react-reconciler": "^0.33.0", - "semver": "^7.6.3", - "shell-quote": "^1.8.2", - "signal-exit": "^4.1.0", - "stack-utils": "^2.0.6", - "strip-ansi": "^7.1.0", - "supports-hyperlinks": "^3.1.0", - "tree-kill": "^1.2.2", - "turndown": "^7.2.0", - "type-fest": "^4.30.0", - "undici": "^7.3.0", - "usehooks-ts": "^3.1.1", - "vscode-languageserver-protocol": "^3.17.5", - "wrap-ansi": "^9.0.0", - "ws": "^8.18.0", - "xss": "^1.0.15", - "yaml": "^2.7.0", - "zod": "^3.24.0", + "@alcalzone/ansi-tokenize": "0.3.0", + "@anthropic-ai/bedrock-sdk": "0.26.4", + "@anthropic-ai/foundry-sdk": "0.2.3", + "@anthropic-ai/sandbox-runtime": "0.0.46", + "@anthropic-ai/sdk": "0.81.0", + "@anthropic-ai/vertex-sdk": "0.14.4", + "@commander-js/extra-typings": "12.1.0", + "@growthbook/growthbook": "1.6.5", + "@modelcontextprotocol/sdk": "1.29.0", + "@opentelemetry/api": "1.9.1", + "@opentelemetry/api-logs": "0.214.0", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/exporter-logs-otlp-http": "0.214.0", + "@opentelemetry/exporter-trace-otlp-grpc": "0.57.2", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-logs": "0.214.0", + "@opentelemetry/sdk-metrics": "2.6.1", + "@opentelemetry/sdk-trace-base": "2.6.1", + "@opentelemetry/sdk-trace-node": "2.6.1", + "@opentelemetry/semantic-conventions": "1.40.0", + "ajv": "8.18.0", + "auto-bind": "5.0.1", + "axios": "1.14.0", + "bidi-js": "1.0.3", + "chalk": "5.6.2", + "chokidar": "4.0.3", + "cli-boxes": "3.0.0", + "cli-highlight": "2.1.11", + "code-excerpt": "4.0.0", + "commander": "12.1.0", + "diff": "7.0.0", + "emoji-regex": "10.6.0", + "env-paths": "3.0.0", + "execa": "9.6.1", + "fflate": "0.8.2", + "figures": "6.1.0", + "fuse.js": "7.1.0", + "get-east-asian-width": "1.5.0", + "google-auth-library": "9.15.1", + "https-proxy-agent": "7.0.6", + "ignore": "7.0.5", + "indent-string": "5.0.0", + "jsonc-parser": "3.3.1", + "lodash-es": "4.17.23", + "lru-cache": "11.2.7", + "marked": "15.0.12", + "p-map": "7.0.4", + "picomatch": "4.0.4", + "proper-lockfile": "4.1.2", + "qrcode": "1.5.4", + "react": "19.2.4", + "react-compiler-runtime": "1.0.0", + "react-reconciler": "0.33.0", + "semver": "7.7.4", + "shell-quote": "1.8.3", + "signal-exit": "4.1.0", + "stack-utils": "2.0.6", + "strip-ansi": "7.2.0", + "supports-hyperlinks": "3.2.0", + "tree-kill": "1.2.2", + "turndown": "7.2.2", + "type-fest": "4.41.0", + "undici": "7.24.6", + "usehooks-ts": "3.1.1", + "vscode-languageserver-protocol": "3.17.5", + "wrap-ansi": "9.0.2", + "ws": "8.20.0", + "xss": "1.0.15", + "yaml": "2.8.3", + "zod": "3.25.76", }, "devDependencies": { - "@types/bun": "^1.2.0", - "@types/node": "^25.5.0", - "@types/react": "^19.2.14", - "typescript": "^5.7.0", + "@types/bun": "1.3.11", + "@types/node": "25.5.0", + "@types/react": "19.2.14", + "typescript": "5.9.3", }, }, }, diff --git a/package.json b/package.json index c70b2bed..5f5351b8 100644 --- a/package.json +++ b/package.json @@ -42,82 +42,82 @@ "prepack": "npm run build" }, "dependencies": { - "@alcalzone/ansi-tokenize": "^0.3.0", - "@anthropic-ai/bedrock-sdk": "^0.26.0", - "@anthropic-ai/foundry-sdk": "^0.2.0", - "@anthropic-ai/sandbox-runtime": "^0.0.46", - "@anthropic-ai/sdk": "^0.81.0", - "@anthropic-ai/vertex-sdk": "^0.14.0", - "@commander-js/extra-typings": "^12.0.0", - "@growthbook/growthbook": "^1.3.0", - "@modelcontextprotocol/sdk": "^1.12.0", - "@opentelemetry/api": "^1.9.1", - "@opentelemetry/api-logs": "^0.214.0", - "@opentelemetry/core": "^2.6.1", - "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", - "@opentelemetry/exporter-trace-otlp-grpc": "^0.57.0", - "@opentelemetry/resources": "^2.6.1", - "@opentelemetry/sdk-logs": "^0.214.0", - "@opentelemetry/sdk-metrics": "^2.6.1", - "@opentelemetry/sdk-trace-base": "^2.6.1", - "@opentelemetry/sdk-trace-node": "^2.6.1", - "@opentelemetry/semantic-conventions": "^1.40.0", - "ajv": "^8.17.0", - "auto-bind": "^5.0.1", - "axios": "^1.14.0", - "bidi-js": "^1.0.3", - "chalk": "^5.4.0", - "chokidar": "^4.0.0", - "cli-boxes": "^3.0.0", - "cli-highlight": "^2.1.0", - "code-excerpt": "^4.0.0", - "commander": "^12.0.0", - "diff": "^7.0.0", - "emoji-regex": "^10.4.0", - "env-paths": "^3.0.0", - "execa": "^9.5.0", - "fflate": "^0.8.2", - "figures": "^6.1.0", - "fuse.js": "^7.1.0", - "get-east-asian-width": "^1.3.0", - "google-auth-library": "^9.15.0", - "https-proxy-agent": "^7.0.6", - "ignore": "^7.0.0", - "indent-string": "^5.0.0", - "jsonc-parser": "^3.3.1", - "lodash-es": "^4.17.21", - "lru-cache": "^11.0.0", - "marked": "^15.0.0", - "p-map": "^7.0.3", - "picomatch": "^4.0.0", - "proper-lockfile": "^4.1.2", - "qrcode": "^1.5.4", - "react": "^19.2.4", - "react-compiler-runtime": "^1.0.0", - "react-reconciler": "^0.33.0", - "semver": "^7.6.3", - "shell-quote": "^1.8.2", - "signal-exit": "^4.1.0", - "stack-utils": "^2.0.6", - "strip-ansi": "^7.1.0", - "supports-hyperlinks": "^3.1.0", - "tree-kill": "^1.2.2", - "turndown": "^7.2.0", - "type-fest": "^4.30.0", - "undici": "^7.3.0", - "usehooks-ts": "^3.1.1", - "vscode-languageserver-protocol": "^3.17.5", - "wrap-ansi": "^9.0.0", - "ws": "^8.18.0", - "xss": "^1.0.15", - "yaml": "^2.7.0", - "zod": "^3.24.0" + "@alcalzone/ansi-tokenize": "0.3.0", + "@anthropic-ai/bedrock-sdk": "0.26.4", + "@anthropic-ai/foundry-sdk": "0.2.3", + "@anthropic-ai/sandbox-runtime": "0.0.46", + "@anthropic-ai/sdk": "0.81.0", + "@anthropic-ai/vertex-sdk": "0.14.4", + "@commander-js/extra-typings": "12.1.0", + "@growthbook/growthbook": "1.6.5", + "@modelcontextprotocol/sdk": "1.29.0", + "@opentelemetry/api": "1.9.1", + "@opentelemetry/api-logs": "0.214.0", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/exporter-logs-otlp-http": "0.214.0", + "@opentelemetry/exporter-trace-otlp-grpc": "0.57.2", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-logs": "0.214.0", + "@opentelemetry/sdk-metrics": "2.6.1", + "@opentelemetry/sdk-trace-base": "2.6.1", + "@opentelemetry/sdk-trace-node": "2.6.1", + "@opentelemetry/semantic-conventions": "1.40.0", + "ajv": "8.18.0", + "auto-bind": "5.0.1", + "axios": "1.14.0", + "bidi-js": "1.0.3", + "chalk": "5.6.2", + "chokidar": "4.0.3", + "cli-boxes": "3.0.0", + "cli-highlight": "2.1.11", + "code-excerpt": "4.0.0", + "commander": "12.1.0", + "diff": "7.0.0", + "emoji-regex": "10.6.0", + "env-paths": "3.0.0", + "execa": "9.6.1", + "fflate": "0.8.2", + "figures": "6.1.0", + "fuse.js": "7.1.0", + "get-east-asian-width": "1.5.0", + "google-auth-library": "9.15.1", + "https-proxy-agent": "7.0.6", + "ignore": "7.0.5", + "indent-string": "5.0.0", + "jsonc-parser": "3.3.1", + "lodash-es": "4.17.23", + "lru-cache": "11.2.7", + "marked": "15.0.12", + "p-map": "7.0.4", + "picomatch": "4.0.4", + "proper-lockfile": "4.1.2", + "qrcode": "1.5.4", + "react": "19.2.4", + "react-compiler-runtime": "1.0.0", + "react-reconciler": "0.33.0", + "semver": "7.7.4", + "shell-quote": "1.8.3", + "signal-exit": "4.1.0", + "stack-utils": "2.0.6", + "strip-ansi": "7.2.0", + "supports-hyperlinks": "3.2.0", + "tree-kill": "1.2.2", + "turndown": "7.2.2", + "type-fest": "4.41.0", + "undici": "7.24.6", + "usehooks-ts": "3.1.1", + "vscode-languageserver-protocol": "3.17.5", + "wrap-ansi": "9.0.2", + "ws": "8.20.0", + "xss": "1.0.15", + "yaml": "2.8.3", + "zod": "3.25.76" }, "devDependencies": { - "@types/bun": "^1.2.0", - "@types/node": "^25.5.0", - "@types/react": "^19.2.14", - "typescript": "^5.7.0" + "@types/bun": "1.3.11", + "@types/node": "25.5.0", + "@types/react": "19.2.14", + "typescript": "5.9.3" }, "engines": { "node": ">=20.0.0" From ffbc1f8f6e37d3a0f84a76d6ebecf7301676f7e5 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 2 Apr 2026 10:05:16 +0530 Subject: [PATCH 05/35] fix: support CSI-u printable input on Windows --- src/ink/parse-keypress.test.ts | 49 ++++++++++++++++++++++++++++++++++ src/ink/parse-keypress.ts | 28 ++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 src/ink/parse-keypress.test.ts diff --git a/src/ink/parse-keypress.test.ts b/src/ink/parse-keypress.test.ts new file mode 100644 index 00000000..23035286 --- /dev/null +++ b/src/ink/parse-keypress.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from 'bun:test' + +import { + INITIAL_STATE, + parseMultipleKeypresses, + type ParsedKey, +} from './parse-keypress.ts' +import { InputEvent } from './events/input-event.ts' + +function parseInputEvent(sequence: string): InputEvent { + const [items] = parseMultipleKeypresses(INITIAL_STATE, sequence) + + expect(items).toHaveLength(1) + + const item = items[0] + expect(item?.kind).toBe('key') + + return new InputEvent(item as ParsedKey) +} + +test('treats CSI-u modifier 0 as unmodified printable input', () => { + const event = parseInputEvent('\x1b[47;0u') + + expect(event.input).toBe('/') + expect(event.key.ctrl).toBe(false) + expect(event.key.meta).toBe(false) + expect(event.key.shift).toBe(false) + expect(event.key.super).toBe(false) +}) + +test('preserves printable Unicode CSI-u input', () => { + const event = parseInputEvent('\x1b[231u') + + expect(event.input).toBe('ç') + expect(event.key.ctrl).toBe(false) + expect(event.key.meta).toBe(false) + expect(event.key.shift).toBe(false) + expect(event.key.super).toBe(false) +}) + +test('preserves printable Unicode CSI-u input with explicit modifier 0', () => { + const event = parseInputEvent('\x1b[231;0u') + + expect(event.input).toBe('ç') + expect(event.key.ctrl).toBe(false) + expect(event.key.meta).toBe(false) + expect(event.key.shift).toBe(false) + expect(event.key.super).toBe(false) +}) diff --git a/src/ink/parse-keypress.ts b/src/ink/parse-keypress.ts index a7e43adc..ac4162fd 100644 --- a/src/ink/parse-keypress.ts +++ b/src/ink/parse-keypress.ts @@ -468,7 +468,10 @@ function decodeModifier(modifier: number): { ctrl: boolean super: boolean } { - const m = modifier - 1 + // Some Windows VT stacks use 0 instead of 1 for an unmodified CSI-u key. + // Clamp to the protocol default so plain printable keys don't look like + // ctrl+meta+shift+super all at once. + const m = Math.max(modifier, 1) - 1 return { shift: !!(m & 1), meta: !!(m & 2), @@ -477,6 +480,14 @@ function decodeModifier(modifier: number): { } } +function isPrivateUseCodepoint(codepoint: number): boolean { + return ( + (codepoint >= 0xe000 && codepoint <= 0xf8ff) || + (codepoint >= 0xf0000 && codepoint <= 0xffffd) || + (codepoint >= 0x100000 && codepoint <= 0x10fffd) + ) +} + /** * Map keycode to key name for modifyOtherKeys/CSI u sequences. * Handles both ASCII keycodes and Kitty keyboard protocol functional keys. @@ -536,6 +547,21 @@ function keycodeToName(keycode: number): string | undefined { if (keycode >= 32 && keycode <= 126) { return String.fromCharCode(keycode).toLowerCase() } + + // CSI-u can carry printable Unicode codepoints directly on some + // Windows terminals and keyboard layouts. Keep kitty's private-use + // functional key range excluded so special keys still stay non-text. + if ( + keycode > 0x1f && + keycode !== 0x7f && + (keycode < 0x80 || keycode > 0x9f) && + keycode <= 0x10ffff && + (keycode < 0xd800 || keycode > 0xdfff) && + !isPrivateUseCodepoint(keycode) + ) { + return String.fromCodePoint(keycode) + } + return undefined } } From 4918caa22b9a7f52d5b0dc18b4c6718dd23e775f Mon Sep 17 00:00:00 2001 From: Dark Yagami Date: Thu, 2 Apr 2026 10:18:27 +0530 Subject: [PATCH 06/35] Update resume command in gracefulShutdown message --- src/utils/gracefulShutdown.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/gracefulShutdown.ts b/src/utils/gracefulShutdown.ts index 03e6233e..4e003000 100644 --- a/src/utils/gracefulShutdown.ts +++ b/src/utils/gracefulShutdown.ts @@ -173,7 +173,7 @@ function printResumeHint(): void { writeSync( 1, chalk.dim( - `\nResume this session with:\nclaude --resume ${resumeArg}\n`, + `\nResume this session with:\nopenclaude --resume ${resumeArg}\n`, ), ) resumeHintPrinted = true From 2bade922ef92915891d94ad80627676a37b33956 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 2 Apr 2026 10:19:36 +0530 Subject: [PATCH 07/35] fix: add clearer ripgrep install guidance --- README.md | 4 +++ src/utils/ripgrep.test.ts | 27 ++++++++++++++++++ src/utils/ripgrep.ts | 60 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 src/utils/ripgrep.test.ts diff --git a/README.md b/README.md index 5c94ed80..b7c058a9 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ All of Claude Code's tools work — bash, file read/write/edit, grep, glob, agen npm install -g @gitlawb/openclaude ``` +If you install via npm and later see `ripgrep not found`, install ripgrep +system-wide and confirm `rg --version` works in the same terminal before +starting OpenClaude. + ### Option B: From source (requires Bun) Use Bun `1.3.11` or newer for source builds on Windows. Older Bun versions such as `1.3.4` can fail with a large batch of unresolved module errors during `bun run build`. diff --git a/src/utils/ripgrep.test.ts b/src/utils/ripgrep.test.ts new file mode 100644 index 00000000..6a17d753 --- /dev/null +++ b/src/utils/ripgrep.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from 'bun:test' + +import { wrapRipgrepUnavailableError } from './ripgrep.ts' + +test('wrapRipgrepUnavailableError explains missing packaged fallback', () => { + const error = wrapRipgrepUnavailableError( + { code: 'ENOENT', message: 'spawn rg ENOENT' }, + { mode: 'builtin', command: 'C:\\fake\\vendor\\ripgrep\\rg.exe' }, + 'win32', + ) + + expect(error.name).toBe('RipgrepUnavailableError') + expect(error.code).toBe('ENOENT') + expect(error.message).toContain('packaged ripgrep fallback') + expect(error.message).toContain('winget install BurntSushi.ripgrep.MSVC') +}) + +test('wrapRipgrepUnavailableError explains missing system ripgrep', () => { + const error = wrapRipgrepUnavailableError( + { code: 'ENOENT', message: 'spawn rg ENOENT' }, + { mode: 'system', command: 'rg' }, + 'linux', + ) + + expect(error.message).toContain('system ripgrep binary was not found on PATH') + expect(error.message).toContain('apt install ripgrep') +}) diff --git a/src/utils/ripgrep.ts b/src/utils/ripgrep.ts index 683da051..4bd95894 100644 --- a/src/utils/ripgrep.ts +++ b/src/utils/ripgrep.ts @@ -28,6 +28,8 @@ type RipgrepConfig = { argv0?: string } +type RipgrepErrorLike = Pick + const getRipgrepConfig = memoize((): RipgrepConfig => { const userWantsSystemRipgrep = isEnvDefinedFalsy( process.env.USE_BUILTIN_RIPGREP, @@ -105,6 +107,52 @@ export class RipgrepTimeoutError extends Error { } } +export class RipgrepUnavailableError extends Error { + code?: string | number + + constructor( + message: string, + public readonly config: Pick, + code?: string | number, + ) { + super(message) + this.name = 'RipgrepUnavailableError' + this.code = code + } +} + +function getRipgrepInstallHint(platform = process.platform): string { + switch (platform) { + case 'win32': + return 'Install ripgrep and confirm `rg --version` works in the same terminal. Windows: `winget install BurntSushi.ripgrep.MSVC` or `choco install ripgrep`.' + case 'darwin': + return 'Install ripgrep and confirm `rg --version` works in the same terminal. macOS: `brew install ripgrep`.' + default: + return 'Install ripgrep and confirm `rg --version` works in the same terminal. Linux: use your distro package manager, for example `apt install ripgrep`.' + } +} + +export function wrapRipgrepUnavailableError( + error: RipgrepErrorLike, + config = getRipgrepConfig(), + platform = process.platform, +): RipgrepUnavailableError { + const modeExplanation = + config.mode === 'builtin' + ? 'This install could not locate its packaged ripgrep fallback.' + : config.mode === 'system' + ? 'A working system ripgrep binary was not found on PATH.' + : 'The embedded ripgrep binary could not be started.' + + const originalMessage = error.message ? ` Original error: ${error.message}` : '' + + return new RipgrepUnavailableError( + `ripgrep (rg) is required for file search but could not be started. ${modeExplanation} ${getRipgrepInstallHint(platform)}${originalMessage}`, + config, + error.code, + ) +} + function ripGrepRaw( args: string[], target: string, @@ -275,7 +323,9 @@ async function ripGrepFileCount( child.on('error', err => { if (settled) return settled = true - reject(err) + reject( + err.code === 'ENOENT' ? wrapRipgrepUnavailableError(err) : err, + ) }) }) } @@ -337,7 +387,9 @@ export async function ripGrepStream( child.on('error', err => { if (settled) return settled = true - reject(err) + reject( + err.code === 'ENOENT' ? wrapRipgrepUnavailableError(err) : err, + ) }) }) } @@ -383,7 +435,9 @@ export async function ripGrep( // These should be surfaced to the user rather than silently returning empty results const CRITICAL_ERROR_CODES = ['ENOENT', 'EACCES', 'EPERM'] if (CRITICAL_ERROR_CODES.includes(error.code as string)) { - reject(error) + reject( + error.code === 'ENOENT' ? wrapRipgrepUnavailableError(error) : error, + ) return } From 0746802b6a08c00053a28b3519f7946995b239ad Mon Sep 17 00:00:00 2001 From: Mikey <5217366+BrainSlugs83@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:29:12 -0700 Subject: [PATCH 08/35] security: kill GrowthBook phone-home and auto-updater at build time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Bun build plugin that replaces analytics/telemetry modules with no-op stubs at compile time. Primary targets (NOT killed by PR #94 or the feature() shim): - GrowthBook: phones home to api.anthropic.com on every launch, sending account UUID, org UUID, email, device ID, subscription type. Refreshes every 6 hours. Now returns defaults without making any network call. - Auto-updater: contacts storage.googleapis.com and npm registry on launch to check for new versions. Now returns null/no-op. Defense-in-depth (already gated by PR #94 or feature flags, but now the code itself is replaced with empty functions): - Datadog, 1P event logging, BigQuery metrics, Perfetto tracing, session tracing, plugin fetch telemetry, transcript sharing. Deliberately NOT stubbed: - Plugin marketplace (downloads.claude.ai) — needed for /plugin - User-configurable OTel (CLAUDE_CODE_ENABLE_TELEMETRY) — opt-in Implementation: separate plugin file (scripts/no-telemetry-plugin.ts) with a 2-line hook in build.ts. The plugin file does not exist upstream so it cannot cause merge conflicts. --- scripts/build.ts | 2 + scripts/no-telemetry-plugin.ts | 221 +++++++++++++++++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 scripts/no-telemetry-plugin.ts diff --git a/scripts/build.ts b/scripts/build.ts index 0a00f2c9..137fadb6 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -9,6 +9,7 @@ */ import { readFileSync } from 'fs' +import { noTelemetryPlugin } from './no-telemetry-plugin' const pkg = JSON.parse(readFileSync('./package.json', 'utf-8')) const version = pkg.version @@ -64,6 +65,7 @@ const result = await Bun.build({ 'MACRO.NATIVE_PACKAGE_URL': 'undefined', }, plugins: [ + noTelemetryPlugin, { name: 'bun-bundle-shim', setup(build) { diff --git a/scripts/no-telemetry-plugin.ts b/scripts/no-telemetry-plugin.ts new file mode 100644 index 00000000..dc83b50f --- /dev/null +++ b/scripts/no-telemetry-plugin.ts @@ -0,0 +1,221 @@ +/** + * No-Telemetry Build Plugin for OpenClaude + * + * Replaces all analytics, telemetry, and phone-home modules with no-op stubs + * at compile time. Zero runtime cost, zero network calls to Anthropic. + * + * This file is NOT tracked upstream — merge conflicts are impossible. + * Only build.ts needs a one-line import + one-line array entry. + * + * Kills: + * - GrowthBook remote feature flags (api.anthropic.com) + * - Datadog event intake + * - 1P event logging (api.anthropic.com/api/event_logging/batch) + * - BigQuery metrics exporter (api.anthropic.com/api/claude_code/metrics) + * - Perfetto / OpenTelemetry session tracing + * - Auto-updater (storage.googleapis.com, npm registry) + * - Plugin fetch telemetry + * - Transcript / feedback sharing + */ + +import type { BunPlugin } from 'bun' + +// Module path (relative to src/, without extension) → stub source +const stubs: Record = { + + // ─── Analytics core ───────────────────────────────────────────── + + 'services/analytics/index': ` +export function stripProtoFields(metadata) { return metadata; } +export function attachAnalyticsSink() {} +export function logEvent() {} +export async function logEventAsync() {} +export function _resetForTesting() {} +`, + + 'services/analytics/growthbook': ` +const noop = () => {}; +export function onGrowthBookRefresh() { return noop; } +export function hasGrowthBookEnvOverride() { return false; } +export function getAllGrowthBookFeatures() { return {}; } +export function getGrowthBookConfigOverrides() { return {}; } +export function setGrowthBookConfigOverride() {} +export function clearGrowthBookConfigOverrides() {} +export function getApiBaseUrlHost() { return undefined; } +export const initializeGrowthBook = async () => null; +export async function getFeatureValue_DEPRECATED(feature, defaultValue) { return defaultValue; } +export function getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue) { return defaultValue; } +export function getFeatureValue_CACHED_WITH_REFRESH(feature, defaultValue) { return defaultValue; } +export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE() { return false; } +export async function checkSecurityRestrictionGate() { return false; } +export async function checkGate_CACHED_OR_BLOCKING() { return false; } +export function refreshGrowthBookAfterAuthChange() {} +export function resetGrowthBook() {} +export async function refreshGrowthBookFeatures() {} +export function setupPeriodicGrowthBookRefresh() {} +export function stopPeriodicGrowthBookRefresh() {} +export async function getDynamicConfig_BLOCKS_ON_INIT(configName, defaultValue) { return defaultValue; } +export function getDynamicConfig_CACHED_MAY_BE_STALE(configName, defaultValue) { return defaultValue; } +`, + + 'services/analytics/sink': ` +export function initializeAnalyticsGates() {} +export function initializeAnalyticsSink() {} +`, + + 'services/analytics/config': ` +export function isAnalyticsDisabled() { return true; } +export function isFeedbackSurveyDisabled() { return true; } +`, + + 'services/analytics/datadog': ` +export const initializeDatadog = async () => false; +export async function shutdownDatadog() {} +export async function trackDatadogEvent() {} +`, + + 'services/analytics/firstPartyEventLogger': ` +export function getEventSamplingConfig() { return {}; } +export function shouldSampleEvent() { return null; } +export async function shutdown1PEventLogging() {} +export function is1PEventLoggingEnabled() { return false; } +export function logEventTo1P() {} +export function logGrowthBookExperimentTo1P() {} +export function initialize1PEventLogging() {} +export async function reinitialize1PEventLoggingIfConfigChanged() {} +`, + + 'services/analytics/firstPartyEventLoggingExporter': ` +export class FirstPartyEventLoggingExporter { + constructor() {} + async export(logs, resultCallback) { resultCallback({ code: 0 }); } + async getQueuedEventCount() { return 0; } + async shutdown() {} + async forceFlush() {} +} +`, + + 'services/analytics/metadata': ` +export function sanitizeToolNameForAnalytics(toolName) { return toolName; } +export function isToolDetailsLoggingEnabled() { return false; } +export function isAnalyticsToolDetailsLoggingEnabled() { return false; } +export function mcpToolDetailsForAnalytics() { return {}; } +export function extractMcpToolDetails() { return undefined; } +export function extractSkillName() { return undefined; } +export function extractToolInputForTelemetry() { return undefined; } +export function getFileExtensionForAnalytics() { return undefined; } +export function getFileExtensionsFromBashCommand() { return undefined; } +export async function getEventMetadata() { return {}; } +export function to1PEventFormat() { return {}; } +`, + + // ─── Telemetry subsystems ─────────────────────────────────────── + + 'utils/telemetry/bigqueryExporter': ` +export class BigQueryMetricsExporter { + constructor() {} + async export(metrics, resultCallback) { resultCallback({ code: 0 }); } + async shutdown() {} + async forceFlush() {} + selectAggregationTemporality() { return 0; } +} +`, + + 'utils/telemetry/perfettoTracing': ` +export function initializePerfettoTracing() {} +export function isPerfettoTracingEnabled() { return false; } +export function registerAgent() {} +export function unregisterAgent() {} +export function startLLMRequestPerfettoSpan() { return ''; } +export function endLLMRequestPerfettoSpan() {} +export function startToolPerfettoSpan() { return ''; } +export function endToolPerfettoSpan() {} +export function startUserInputPerfettoSpan() { return ''; } +export function endUserInputPerfettoSpan() {} +export function emitPerfettoInstant() {} +export function emitPerfettoCounter() {} +export function startInteractionPerfettoSpan() { return ''; } +export function endInteractionPerfettoSpan() {} +export function getPerfettoEvents() { return []; } +export function resetPerfettoTracer() {} +export async function triggerPeriodicWriteForTesting() {} +export function evictStaleSpansForTesting() {} +export const MAX_EVENTS_FOR_TESTING = 0; +export function evictOldestEventsForTesting() {} +`, + + 'utils/telemetry/sessionTracing': ` +const noopSpan = { + end() {}, setAttribute() {}, setStatus() {}, + recordException() {}, addEvent() {}, isRecording() { return false; }, +}; +export function isBetaTracingEnabled() { return false; } +export function isEnhancedTelemetryEnabled() { return false; } +export function startInteractionSpan() { return noopSpan; } +export function endInteractionSpan() {} +export function startLLMRequestSpan() { return noopSpan; } +export function endLLMRequestSpan() {} +export function startToolSpan() { return noopSpan; } +export function startToolBlockedOnUserSpan() { return noopSpan; } +export function endToolBlockedOnUserSpan() {} +export function startToolExecutionSpan() { return noopSpan; } +export function endToolExecutionSpan() {} +export function endToolSpan() {} +export function addToolContentEvent() {} +export function getCurrentSpan() { return null; } +export async function executeInSpan(spanName, fn) { return fn(noopSpan); } +export function startHookSpan() { return noopSpan; } +export function endHookSpan() {} +`, + + // ─── Auto-updater (phones home to GCS + npm) ────────────────── + + 'utils/autoUpdater': ` +export async function assertMinVersion() {} +export async function getMaxVersion() { return undefined; } +export async function getMaxVersionMessage() { return undefined; } +export function shouldSkipVersion() { return true; } +export function getLockFilePath() { return '/tmp/openclaude-update.lock'; } +export async function checkGlobalInstallPermissions() { return { hasPermissions: false, npmPrefix: null }; } +export async function getLatestVersion() { return null; } +export async function getNpmDistTags() { return { latest: null, stable: null }; } +export async function getLatestVersionFromGcs() { return null; } +export async function getGcsDistTags() { return { latest: null, stable: null }; } +export async function getVersionHistory() { return []; } +export async function installGlobalPackage() { return 'success'; } +`, + + // ─── Plugin fetch telemetry (not the marketplace itself) ─────── + + 'utils/plugins/fetchTelemetry': ` +export function logPluginFetch() {} +export function classifyFetchError() { return 'disabled'; } +`, + + // ─── Transcript / feedback sharing ───────────────────────────── + + 'components/FeedbackSurvey/submitTranscriptShare': ` +export async function submitTranscriptShare() { return { success: false }; } +`, +} + +export const noTelemetryPlugin: BunPlugin = { + name: 'no-telemetry', + setup(build) { + for (const [modulePath, contents] of Object.entries(stubs)) { + // Build regex that matches the resolved file path on any OS + // e.g. "services/analytics/growthbook" → /services[/\\]analytics[/\\]growthbook\.(ts|js)$/ + const escaped = modulePath + .replace(/\//g, '[/\\\\]') + .replace(/\./g, '\\.') + const filter = new RegExp(`${escaped}\\.(ts|js)$`) + + build.onLoad({ filter }, () => ({ + contents, + loader: 'js', + })) + } + + console.log(` 🔇 no-telemetry: stubbed ${Object.keys(stubs).length} modules`) + }, +} From 9590066b5b03e772f33b27ad33b7581fd5a487c3 Mon Sep 17 00:00:00 2001 From: Raj Rasane Date: Thu, 2 Apr 2026 10:18:52 +0530 Subject: [PATCH 09/35] fix: gracefully handle Docker/remote Ollama in system-check When Ollama runs inside Docker or a remote container, the native 'ollama ps' command is unavailable on the host. Instead of hard-failing and blocking CLI startup, downgrade to a pass() with a warning when the HTTP ping has already confirmed the server is reachable. --- scripts/system-check.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/system-check.ts b/scripts/system-check.ts index e129685a..2e12da5a 100644 --- a/scripts/system-check.ts +++ b/scripts/system-check.ts @@ -289,7 +289,7 @@ function checkOllamaProcessorMode(): CheckResult { if (result.status !== 0) { const detail = (result.stderr || result.stdout || 'Unable to run ollama ps').trim() - return fail('Ollama processor mode', detail) + return pass('Ollama processor mode', `Native CLI check failed (${detail}). Assuming valid Docker/remote backend since HTTP ping passed.`) } const output = (result.stdout || '').trim() From 310f1d344ad10667f9738d3e459092cb9d52f1dd Mon Sep 17 00:00:00 2001 From: Raj Rasane Date: Thu, 2 Apr 2026 10:26:06 +0530 Subject: [PATCH 10/35] fix: provide local session title fallback for 3P providers When using non-Anthropic providers (Ollama, Gemini, Codex), the underlying call to queryHaiku for session title generation fails. Previously, this caused the catch block to return null, leaving the terminal tab permanently stuck on 'Claude Code'. Now, when the API call fails, we gracefully derive a title locally from the user's first message (first 7 words, sentence-cased), ensuring users still see a meaningful session title in their terminal tab. --- src/utils/sessionTitle.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/utils/sessionTitle.ts b/src/utils/sessionTitle.ts index 5a722c88..72ae8054 100644 --- a/src/utils/sessionTitle.ts +++ b/src/utils/sessionTitle.ts @@ -124,6 +124,29 @@ export async function generateSessionTitle( level: 'error', }) logEvent('tengu_session_title_generated', { success: false }) - return null + + // Fallback: derive a title locally from the user's first message. + // This ensures 3P providers (Ollama, Gemini, OpenAI) still get + // meaningful terminal titles when the Haiku API call is unavailable. + return localFallbackTitle(trimmed) } } + +/** + * Fallback local title generator for when the Haiku API is unavailable + * (e.g. when using third-party providers without an Anthropic API key). + */ +function localFallbackTitle(text: string): string | null { + const words = text.split(/\s+/).slice(0, 7) + if (words.length === 0) return null + + // Create a sentence-case string + let fallback = words.join(' ') + if (fallback.length > 50) { + fallback = fallback.substring(0, 49) + '…' + } + + if (fallback.length <= 3) return null + + return fallback.charAt(0).toUpperCase() + fallback.slice(1) +} From 302d9d4e44925c029703b0dd32a93932d8b33aba Mon Sep 17 00:00:00 2001 From: Raj Rasane Date: Thu, 2 Apr 2026 10:33:56 +0530 Subject: [PATCH 11/35] fix: enable session title generation for non-firstParty providers --- src/screens/REPL.tsx | 618 +++++++++++++++++++++---------------------- 1 file changed, 309 insertions(+), 309 deletions(-) diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 9fdd3b11..ef1513aa 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -97,8 +97,8 @@ import { logError } from '../utils/log.js'; /* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ const useVoiceIntegration: typeof import('../hooks/useVoiceIntegration.js').useVoiceIntegration = feature('VOICE_MODE') ? require('../hooks/useVoiceIntegration.js').useVoiceIntegration : () => ({ stripTrailing: () => 0, - handleKeyEvent: () => {}, - resetAnchor: () => {} + handleKeyEvent: () => { }, + resetAnchor: () => { } }); const VoiceKeybindingHandler: typeof import('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler = feature('VOICE_MODE') ? require('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler : () => null; // Frustration detection is ant-only (dogfooding). Conditional require so external @@ -106,11 +106,11 @@ const VoiceKeybindingHandler: typeof import('../hooks/useVoiceIntegration.js').V // on every messages change, plus the GrowthBook fetch). const useFrustrationDetection: typeof import('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection = "external" === 'ant' ? require('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection : () => ({ state: 'closed', - handleTranscriptSelect: () => {} + handleTranscriptSelect: () => { } }); // Ant-only org warning. Conditional require so the org UUID list is // eliminated from external builds (one UUID is on excluded-strings). -const useAntOrgWarningNotification: typeof import('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification = "external" === 'ant' ? require('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification : () => {}; +const useAntOrgWarningNotification: typeof import('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification = "external" === 'ant' ? require('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification : () => { }; // Dead code elimination: conditional import for coordinator mode const getCoordinatorUserContext: (mcpClients: ReadonlyArray<{ name: string; @@ -192,7 +192,7 @@ import { useInboxPoller } from '../hooks/useInboxPoller.js'; // Dead code elimination: conditional import for loop mode /* eslint-disable @typescript-eslint/no-require-imports */ const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/index.js') : null; -const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => {}; +const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => { }; const PROACTIVE_FALSE = () => false; const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false; const useProactive = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null; @@ -297,7 +297,7 @@ const EMPTY_MCP_CLIENTS: MCPServerConnection[] = []; // Stable stub for useAssistantHistory's non-KAIROS branch — avoids a new // function identity each render, which would break composedOnScroll's memo. const HISTORY_STUB = { - maybeLoadOlder: (_: ScrollBoxHandle) => {} + maybeLoadOlder: (_: ScrollBoxHandle) => { } }; // Window after a user-initiated scroll during which type-into-empty does NOT // repin to bottom. Josh Rosen's workflow: Claude emits long output → scroll @@ -448,28 +448,28 @@ function TranscriptSearchBar({ const off = cursorOffset; const cursorChar = off < query.length ? query[off] : ' '; return - / - {query.slice(0, off)} - {cursorChar} - {off < query.length && {query.slice(off + 1)}} - - {indexStatus === 'building' ? indexing… : indexStatus ? indexed in {indexStatus.ms}ms : count === 0 && query ? no matches : count > 0 ? - // Engine-counted (indexOf on extractSearchText). May drift from - // render-count for ghost/phantom messages — badge is a rough - // location hint. scanElement gives exact per-message positions - // but counting ALL would cost ~1-3ms × matched-messages. - - {current}/{count} - {' '} - : null} - ; + // applySearchHighlight scans the whole screen buffer. The query + // text rendered here IS on screen — /foo matches its own 'foo' in + // the bar. With no content matches that's the ONLY visible match → + // gets CURRENT → underlined. noSelect makes searchHighlight.ts:76 + // skip these cells (same exclusion as gutters). You can't text- + // select the bar either; it's transient chrome, fine. + noSelect> + / + {query.slice(0, off)} + {cursorChar} + {off < query.length && {query.slice(off + 1)}} + + {indexStatus === 'building' ? indexing… : indexStatus ? indexed in {indexStatus.ms}ms : count === 0 && query ? no matches : count > 0 ? + // Engine-counted (indexOf on extractSearchText). May drift from + // render-count for ghost/phantom messages — badge is a rough + // location hint. scanElement gives exact per-message positions + // but counting ALL would cost ~1-3ms × matched-messages. + + {current}/{count} + {' '} + : null} + ; } const TITLE_ANIMATION_FRAMES = ['⠂', '⠐']; const TITLE_STATIC_PREFIX = '✳'; @@ -605,8 +605,8 @@ export function REPL({ const moreRightEnabled = useMemo(() => "external" === 'ant' && isEnvTruthy(process.env.CLAUDE_MORERIGHT), []); const disableVirtualScroll = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), []); const disableMessageActions = feature('MESSAGE_ACTIONS') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS), []) : false; + // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS), []) : false; // Agent definition is state so /resume can update it mid-session const [mainThreadAgentDefinition, setMainThreadAgentDefinition] = useState(initialMainThreadAgentDefinition); @@ -865,11 +865,11 @@ export function REPL({ // Ref for the bridge result callback — set after useReplBridge initializes, // read in the onQuery finally block to notify mobile clients that a turn ended. - const sendBridgeResultRef = useRef<() => void>(() => {}); + const sendBridgeResultRef = useRef<() => void>(() => { }); // Ref for the synchronous restore callback — set after restoreMessageSync is // defined, read in the onQuery finally block for auto-restore on interrupt. - const restoreMessageSyncRef = useRef<(m: UserMessage) => void>(() => {}); + const restoreMessageSyncRef = useRef<(m: UserMessage) => void>(() => { }); // Ref to the fullscreen layout's scroll box for keyboard scrolling. // Null when fullscreen mode is disabled (ref never attached). @@ -1246,8 +1246,8 @@ export function REPL({ const cursorNavRef = useRef(null); // Memoized so Messages' React.memo holds. const unseenDivider = useMemo(() => computeUnseenDivider(messages, dividerIndex), - // eslint-disable-next-line react-hooks/exhaustive-deps -- length change covers appends; useUnseenDivider's count-drop guard clears dividerIndex on replace/rewind - [dividerIndex, messages.length]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- length change covers appends; useUnseenDivider's count-drop guard clears dividerIndex on replace/rewind + [dividerIndex, messages.length]); // Re-pin scroll to bottom and clear the unseen-messages baseline. Called // on any user-driven return-to-live action (submit, type-into-empty, // overlay appear/dismiss). @@ -1276,13 +1276,13 @@ export function REPL({ const { maybeLoadOlder } = feature('KAIROS') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAssistantHistory({ - config: remoteSessionConfig, - setMessages, - scrollRef, - onPrepend: shiftDivider - }) : HISTORY_STUB; + // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAssistantHistory({ + config: remoteSessionConfig, + setMessages, + scrollRef, + onPrepend: shiftDivider + }) : HISTORY_STUB; // Compose useUnseenDivider's callbacks with the lazy-load trigger. const composedOnScroll = useCallback((sticky: boolean, handle: ScrollBoxHandle) => { lastUserScrollTsRef.current = Date.now(); @@ -1593,12 +1593,12 @@ export function REPL({ swarmStartTimeRef.current = null; swarmBudgetInfoRef.current = undefined; setMessages(prev => [...prev, createTurnDurationMessage(totalMs, deferredBudget, - // Count only what recordTranscript will persist — ephemeral - // progress ticks and non-ant attachments are filtered by - // isLoggableMessage and never reach disk. Using raw prev.length - // would make checkResumeConsistency report false delta<0 for - // every turn that ran a progress-emitting tool. - count(prev, isLoggableMessage))]); + // Count only what recordTranscript will persist — ephemeral + // progress ticks and non-ant attachments are filtered by + // isLoggableMessage and never reach disk. Using raw prev.length + // would make checkResumeConsistency report false delta<0 for + // every turn that ran a progress-emitting tool. + count(prev, isLoggableMessage))]); } }, [hasRunningTeammates, setMessages]); @@ -1665,19 +1665,19 @@ export function REPL({ setToolJSX }); const showSpinner = (!toolJSX || toolJSX.showSpinner === true) && toolUseConfirmQueue.length === 0 && promptQueue.length === 0 && ( - // Show spinner during input processing, API call, while teammates are running, - // or while pending task notifications are queued (prevents spinner bounce between consecutive notifications) - isLoading || userInputOnProcessing || hasRunningTeammates || - // Keep spinner visible while task notifications are queued for processing. - // Without this, the spinner briefly disappears between consecutive notifications - // (e.g., multiple background agents completing in rapid succession) because - // isLoading goes false momentarily between processing each one. - getCommandQueueLength() > 0) && - // Hide spinner when waiting for leader to approve permission request - !pendingWorkerRequest && !onlySleepToolActive && ( - // Hide spinner when streaming text is visible (the text IS the feedback), - // but keep it when isBriefOnly suppresses the streaming text display - !visibleStreamingText || isBriefOnly); + // Show spinner during input processing, API call, while teammates are running, + // or while pending task notifications are queued (prevents spinner bounce between consecutive notifications) + isLoading || userInputOnProcessing || hasRunningTeammates || + // Keep spinner visible while task notifications are queued for processing. + // Without this, the spinner briefly disappears between consecutive notifications + // (e.g., multiple background agents completing in rapid succession) because + // isLoading goes false momentarily between processing each one. + getCommandQueueLength() > 0) && + // Hide spinner when waiting for leader to approve permission request + !pendingWorkerRequest && !onlySleepToolActive && ( + // Hide spinner when streaming text is visible (the text IS the feedback), + // but keep it when isBriefOnly suppresses the streaming text display + !visibleStreamingText || isBriefOnly); // Check if any permission or ask question prompt is currently visible // This is used to prevent the survey from opening while prompts are active @@ -2323,9 +2323,9 @@ export function REPL({ addNotification({ key: 'sandbox-unavailable', jsx: <> - sandbox disabled - · /sandbox - , + sandbox disabled + · /sandbox + , priority: 'medium' }); }, [addNotification]); @@ -2676,7 +2676,7 @@ export function REPL({ // useDeferredHookMessages) and attachment messages (appended by // processTextPrompt) — both pushed length past 1 on turn one, so the // title silently fell through to the "Claude Code" default. - if (getAPIProvider() === 'firstParty' && !titleDisabled && !sessionTitle && !agentTitle && !haikuTitleAttemptedRef.current) { + if (!titleDisabled && !sessionTitle && !agentTitle && !haikuTitleAttemptedRef.current) { const firstUserMessage = newMessages.find(m => m.type === 'user' && !m.isMeta); const text = firstUserMessage?.type === 'user' ? getContentText(firstUserMessage.message.content) : null; // Skip synthetic breadcrumbs — slash-command output, prompt-skill @@ -2686,7 +2686,7 @@ export function REPL({ if (text && !text.startsWith(`<${LOCAL_COMMAND_STDOUT_TAG}>`) && !text.startsWith(`<${COMMAND_MESSAGE_TAG}>`) && !text.startsWith(`<${COMMAND_NAME_TAG}>`) && !text.startsWith(`<${BASH_INPUT_TAG}>`)) { haikuTitleAttemptedRef.current = true; void generateSessionTitle(text, new AbortController().signal).then(title => { - if (title) setHaikuTitle(title);else haikuTitleAttemptedRef.current = false; + if (title) setHaikuTitle(title); else haikuTitleAttemptedRef.current = false; }, () => { haikuTitleAttemptedRef.current = false; }); @@ -2760,11 +2760,11 @@ export function REPL({ }); } queryCheckpoint('query_context_loading_start'); - const [,, defaultSystemPrompt, baseUserContext, systemContext] = await Promise.all([ - // IMPORTANT: do this after setMessages() above, to avoid UI jank - checkAndDisableBypassPermissionsIfNeeded(toolPermissionContext, setAppState), - // Gated on TRANSCRIPT_CLASSIFIER so GrowthBook kill switch runs wherever auto mode is built in - feature('TRANSCRIPT_CLASSIFIER') ? checkAndDisableAutoModeIfNeeded(toolPermissionContext, setAppState, store.getState().fastMode) : undefined, getSystemPrompt(freshTools, mainLoopModelParam, Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), freshMcpClients), getUserContext(), getSystemContext()]); + const [, , defaultSystemPrompt, baseUserContext, systemContext] = await Promise.all([ + // IMPORTANT: do this after setMessages() above, to avoid UI jank + checkAndDisableBypassPermissionsIfNeeded(toolPermissionContext, setAppState), + // Gated on TRANSCRIPT_CLASSIFIER so GrowthBook kill switch runs wherever auto mode is built in + feature('TRANSCRIPT_CLASSIFIER') ? checkAndDisableAutoModeIfNeeded(toolPermissionContext, setAppState, store.getState().fastMode) : undefined, getSystemPrompt(freshTools, mainLoopModelParam, Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), freshMcpClients), getUserContext(), getSystemContext()]); const userContext = { ...baseUserContext, ...getCoordinatorUserContext(freshMcpClients, isScratchpadEnabled() ? getScratchpadDir() : undefined), @@ -3110,9 +3110,9 @@ export function REPL({ if (typeof content === 'string' && !initialMsg.message.planContent) { // Route through onSubmit for proper processing including UserPromptSubmit hooks void onSubmit(content, { - setCursorOffset: () => {}, - clearBuffer: () => {}, - resetHistory: () => {} + setCursorOffset: () => { }, + clearBuffer: () => { }, + resetHistory: () => { } }); } else { // Plan messages or complex content (images, etc.) - send directly to model @@ -3121,10 +3121,10 @@ export function REPL({ const newAbortController = createAbortController(); setAbortController(newAbortController); void onQuery([initialMsg.message], newAbortController, true, - // shouldQuery - [], - // additionalAllowedTools - mainLoopModel); + // shouldQuery + [], + // additionalAllowedTools + mainLoopModel); } // Reset ref after a delay to allow new initial messages @@ -3526,18 +3526,18 @@ export function REPL({ setStashedPrompt(undefined); } }, [queryGuard, - // isLoading is read at the !isLoading checks above for input-clearing - // and submitCount gating. It's derived from isQueryActive || isExternalLoading, - // so including it here ensures the closure captures the fresh value. - isLoading, isExternalLoading, inputMode, commands, setInputValue, setInputMode, setPastedContents, setSubmitCount, setIDESelection, setToolJSX, getToolUseContext, - // messages is read via messagesRef.current inside the callback to - // keep onSubmit stable across message updates (see L2384/L2400/L2662). - // Without this, each setMessages call (~30× per turn) recreates - // onSubmit, pinning the REPL render scope (1776B) + that render's - // messages array in downstream closures (PromptInput, handleAutoRunIssue). - // Heap analysis showed ~9 REPL scopes and ~15 messages array versions - // accumulating after #20174/#20175, all traced to this dep. - mainLoopModel, pastedContents, ideSelection, setUserInputOnProcessing, setAbortController, addNotification, onQuery, stashedPrompt, setStashedPrompt, setAppState, onBeforeQuery, canUseTool, remoteSession, setMessages, awaitPendingHooks, repinScroll]); + // isLoading is read at the !isLoading checks above for input-clearing + // and submitCount gating. It's derived from isQueryActive || isExternalLoading, + // so including it here ensures the closure captures the fresh value. + isLoading, isExternalLoading, inputMode, commands, setInputValue, setInputMode, setPastedContents, setSubmitCount, setIDESelection, setToolJSX, getToolUseContext, + // messages is read via messagesRef.current inside the callback to + // keep onSubmit stable across message updates (see L2384/L2400/L2662). + // Without this, each setMessages call (~30× per turn) recreates + // onSubmit, pinning the REPL render scope (1776B) + that render's + // messages array in downstream closures (PromptInput, handleAutoRunIssue). + // Heap analysis showed ~9 REPL scopes and ~15 messages array versions + // accumulating after #20174/#20175, all traced to this dep. + mainLoopModel, pastedContents, ideSelection, setUserInputOnProcessing, setAbortController, addNotification, onQuery, stashedPrompt, setStashedPrompt, setAppState, onBeforeQuery, canUseTool, remoteSession, setMessages, awaitPendingHooks, repinScroll]); // Callback for when user submits input while viewing a teammate's transcript const onAgentSubmit = useCallback(async (input: string, task: InProcessTeammateTaskState | LocalAgentTaskState, helpers: PromptInputHelpers) => { @@ -3558,8 +3558,8 @@ export function REPL({ addNotification({ key: `resume-agent-failed-${task.id}`, jsx: - Failed to resume agent: {errorMessage(err)} - , + Failed to resume agent: {errorMessage(err)} + , priority: 'low' }); }); @@ -3577,9 +3577,9 @@ export function REPL({ const command = autoRunIssueReason ? getAutoRunCommand(autoRunIssueReason) : '/issue'; setAutoRunIssueReason(null); // Clear the state onSubmit(command, { - setCursorOffset: () => {}, - clearBuffer: () => {}, - resetHistory: () => {} + setCursorOffset: () => { }, + clearBuffer: () => { }, + resetHistory: () => { } }).catch(err => { logForDebugging(`Auto-run ${command} failed: ${errorMessage(err)}`); }); @@ -3592,9 +3592,9 @@ export function REPL({ const handleSurveyRequestFeedback = useCallback(() => { const command = "external" === 'ant' ? '/issue' : '/feedback'; onSubmit(command, { - setCursorOffset: () => {}, - clearBuffer: () => {}, - resetHistory: () => {} + setCursorOffset: () => { }, + clearBuffer: () => { }, + resetHistory: () => { } }).catch(err => { logForDebugging(`Survey feedback request failed: ${err instanceof Error ? err.message : String(err)}`); }); @@ -3609,9 +3609,9 @@ export function REPL({ onSubmitRef.current = onSubmit; const handleOpenRateLimitOptions = useCallback(() => { void onSubmitRef.current('/rate-limit-options', { - setCursorOffset: () => {}, - clearBuffer: () => {}, - resetHistory: () => {} + setCursorOffset: () => { }, + clearBuffer: () => { }, + resetHistory: () => { } }); }, []); const handleExit = useCallback(async () => { @@ -3628,14 +3628,14 @@ export function REPL({ } const showWorktree = getCurrentWorktreeSession() !== null; if (showWorktree) { - setExitFlow( {}} onCancel={() => { + setExitFlow( { }} onCancel={() => { setExitFlow(null); setIsExiting(false); }} />); return; } const exitMod = await exit.load(); - const exitFlowResult = await exitMod.call(() => {}); + const exitFlowResult = await exitMod.call(() => { }); setExitFlow(exitFlowResult); // If call() returned without killing the process (bg session detach), // clear isExiting so the UI is usable on reattach. No-op on the normal @@ -3749,18 +3749,18 @@ export function REPL({ }; const messageActionCaps: MessageActionCaps = { copy: text => - // setClipboard RETURNS OSC 52 — caller must stdout.write (tmux side-effects load-buffer, but that's tmux-only). - void setClipboard(text).then(raw => { - if (raw) process.stdout.write(raw); - addNotification({ - // Same key as text-selection copy — repeated copies replace toast, don't queue. - key: 'selection-copied', - text: 'copied', - color: 'success', - priority: 'immediate', - timeoutMs: 2000 - }); - }), + // setClipboard RETURNS OSC 52 — caller must stdout.write (tmux side-effects load-buffer, but that's tmux-only). + void setClipboard(text).then(raw => { + if (raw) process.stdout.write(raw); + addNotification({ + // Same key as text-selection copy — repeated copies replace toast, don't queue. + key: 'selection-copied', + text: 'copied', + color: 'success', + priority: 'immediate', + timeoutMs: 2000 + }); + }), edit: async msg => { // Same skip-confirm check as /rewind: lossless → direct, else confirm dialog. const rawIdx = findRawIndex(msg.uuid); @@ -3856,14 +3856,14 @@ export function REPL({ const executeQueuedInput = useCallback(async (queuedCommands: QueuedCommand[]) => { await handlePromptSubmit({ helpers: { - setCursorOffset: () => {}, - clearBuffer: () => {}, - resetHistory: () => {} + setCursorOffset: () => { }, + clearBuffer: () => { }, + resetHistory: () => { } }, queryGuard, commands, - onInputChange: () => {}, - setPastedContents: () => {}, + onInputChange: () => { }, + setPastedContents: () => { }, setToolJSX, getToolUseContext, messages, @@ -3924,8 +3924,8 @@ export function REPL({ // User hasn't interacted since response ended, check other conditions const idleTimeSinceResponse = Date.now() - lastQueryCompletionTime; if (!isLoading && !toolJSX && - // Use ref to get current dialog state, avoiding stale closure - focusedInputDialogRef.current === undefined && idleTimeSinceResponse >= getGlobalConfig().messageIdleNotifThresholdMs) { + // Use ref to get current dialog state, avoiding stale closure + focusedInputDialogRef.current === undefined && idleTimeSinceResponse >= getGlobalConfig().messageIdleNotifThresholdMs) { void sendNotification({ message: 'Claude is waiting for your input', notificationType: 'idle_prompt' @@ -3957,13 +3957,13 @@ export function REPL({ addNotif({ key: 'idle-return-hint', jsx: mode === 'hint_v2' ? <> - new task? - /clear - to save - {formattedTokens} tokens - : - new task? /clear to save {formattedTokens} tokens - , + new task? + /clear + to save + {formattedTokens} tokens + : + new task? /clear to save {formattedTokens} tokens + , priority: 'medium', // Persist until submit — the hint fires at T+75min idle, user may // not return for hours. removeNotification in useEffect cleanup @@ -4015,17 +4015,17 @@ export function REPL({ // Voice input integration (VOICE_MODE builds only) const voice = feature('VOICE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceIntegration({ - setInputValueRaw, - inputValueRef, - insertTextRef - }) : { - stripTrailing: () => 0, - handleKeyEvent: () => {}, - resetAnchor: () => {}, - interimRange: null - }; + // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useVoiceIntegration({ + setInputValueRaw, + inputValueRef, + insertTextRef + }) : { + stripTrailing: () => 0, + handleKeyEvent: () => { }, + resetAnchor: () => { }, + interimRange: null + }; useInboxPoller({ enabled: isAgentSwarmsEnabled(), isLoading, @@ -4228,11 +4228,11 @@ export function REPL({ event.stopImmediatePropagation(); } }, - // Search needs virtual scroll (jumpRef drives VirtualMessageList). [ - // kills it, so !dumpMode — after [ there's nothing to jump in. - { - isActive: screen === 'transcript' && virtualScrollActive && !searchOpen && !dumpMode - }); + // Search needs virtual scroll (jumpRef drives VirtualMessageList). [ + // kills it, so !dumpMode — after [ there's nothing to jump in. + { + isActive: screen === 'transcript' && virtualScrollActive && !searchOpen && !dumpMode + }); const { setQuery: setHighlight, scanElement, @@ -4323,12 +4323,12 @@ export function REPL({ })(); } }, - // !searchOpen: typing 'v' or '[' in the search bar is search input, not - // a command. No !dumpMode here — v should work after [ (the [ handler - // guards itself inline). - { - isActive: screen === 'transcript' && virtualScrollActive && !searchOpen - }); + // !searchOpen: typing 'v' or '[' in the search bar is search input, not + // a command. No !dumpMode here — v should work after [ (the [ handler + // guards itself inline). + { + isActive: screen === 'transcript' && virtualScrollActive && !searchOpen + }); // Fresh `less` per transcript entry. Prevents stale highlights matching // unrelated normal-mode text (overlay is alt-screen-global) and avoids @@ -4396,78 +4396,78 @@ export function REPL({ const transcriptScrollRef = isFullscreenEnvEnabled() && !disableVirtualScroll && !dumpMode ? scrollRef : undefined; const transcriptMessagesElement = ; const transcriptToolJSX = toolJSX && - {toolJSX.jsx} - ; + {toolJSX.jsx} + ; const transcriptReturn = - - - {feature('VOICE_MODE') ? : null} - - {transcriptScrollRef ? - // ScrollKeybindingHandler must mount before CancelRequestHandler so - // ctrl+c-with-selection copies instead of cancelling the active task. - // Its raw useInput handler only stops propagation when a selection - // exists — without one, ctrl+c falls through to CancelRequestHandler. - jumpRef.current?.disarmSearch()} /> : null} - - {transcriptScrollRef ? - {transcriptMessagesElement} - {transcriptToolJSX} - - } bottom={searchOpen ? { - // Enter — commit. 0-match guard: junk query shouldn't - // persist (badge hidden, n/N dead anyway). - setSearchQuery(searchCount > 0 ? q : ''); - setSearchOpen(false); - // onCancel path: bar unmounts before its useEffect([query]) - // can fire with ''. Without this, searchCount stays stale - // (n guard at :4956 passes) and VML's matches[] too - // (nextMatch walks the old array). Phantom nav, no - // highlight. onExit (Enter, q non-empty) still commits. - if (!q) { - setSearchCount(0); - setSearchCurrent(0); + + + {feature('VOICE_MODE') ? : null} + + {transcriptScrollRef ? + // ScrollKeybindingHandler must mount before CancelRequestHandler so + // ctrl+c-with-selection copies instead of cancelling the active task. + // Its raw useInput handler only stops propagation when a selection + // exists — without one, ctrl+c falls through to CancelRequestHandler. + jumpRef.current?.disarmSearch()} /> : null} + + {transcriptScrollRef ? + {transcriptMessagesElement} + {transcriptToolJSX} + + } bottom={searchOpen ? { + // Enter — commit. 0-match guard: junk query shouldn't + // persist (badge hidden, n/N dead anyway). + setSearchQuery(searchCount > 0 ? q : ''); + setSearchOpen(false); + // onCancel path: bar unmounts before its useEffect([query]) + // can fire with ''. Without this, searchCount stays stale + // (n guard at :4956 passes) and VML's matches[] too + // (nextMatch walks the old array). Phantom nav, no + // highlight. onExit (Enter, q non-empty) still commits. + if (!q) { + setSearchCount(0); + setSearchCurrent(0); + jumpRef.current?.setSearchQuery(''); + } + }} onCancel={() => { + // Esc/ctrl+c/ctrl+g — undo. Bar's effect last fired + // with whatever was typed. searchQuery (REPL state) + // is unchanged since / (onClose = commit, didn't run). + // Two VML calls: '' restores anchor (0-match else- + // branch), then searchQuery re-scans from anchor's + // nearest. Both synchronous — one React batch. + // setHighlight explicit: REPL's sync-effect dep is + // searchQuery (unchanged), wouldn't re-fire. + setSearchOpen(false); jumpRef.current?.setSearchQuery(''); - } - }} onCancel={() => { - // Esc/ctrl+c/ctrl+g — undo. Bar's effect last fired - // with whatever was typed. searchQuery (REPL state) - // is unchanged since / (onClose = commit, didn't run). - // Two VML calls: '' restores anchor (0-match else- - // branch), then searchQuery re-scans from anchor's - // nearest. Both synchronous — one React batch. - // setHighlight explicit: REPL's sync-effect dep is - // searchQuery (unchanged), wouldn't re-fire. - setSearchOpen(false); - jumpRef.current?.setSearchQuery(''); - jumpRef.current?.setSearchQuery(searchQuery); - setHighlight(searchQuery); - }} setHighlight={setHighlight} /> : 0 ? { - current: searchCurrent, - count: searchCount - } : undefined} />} /> : <> - {transcriptMessagesElement} - {transcriptToolJSX} - - - } - ; + jumpRef.current?.setSearchQuery(searchQuery); + setHighlight(searchQuery); + }} setHighlight={setHighlight} /> : 0 ? { + current: searchCurrent, + count: searchCount + } : undefined} />} /> : <> + {transcriptMessagesElement} + {transcriptToolJSX} + + + } + ; // The virtual-scroll branch (FullscreenLayout above) needs // 's constraint — without it, // ScrollBox's flexGrow has no ceiling, viewport = content height, @@ -4478,8 +4478,8 @@ export function REPL({ // unwrapped — it wants native terminal scrollback. if (transcriptScrollRef) { return - {transcriptReturn} - ; + {transcriptReturn} + ; } return transcriptReturn; } @@ -4541,11 +4541,11 @@ export function REPL({ // early return above wraps its virtual-scroll branch the same way; only // the 30-cap dump branch stays unwrapped for native terminal scrollback. const mainReturn = - - - {feature('VOICE_MODE') ? : null} - - {/* ScrollKeybindingHandler must mount before CancelRequestHandler so + + + {feature('VOICE_MODE') ? : null} + + {/* ScrollKeybindingHandler must mount before CancelRequestHandler so ctrl+c-with-selection copies instead of cancelling the active task. Its raw useInput handler only stops propagation when a selection exists — without one, ctrl+c falls through to CancelRequestHandler. @@ -4553,40 +4553,40 @@ export function REPL({ the modal's inner ScrollBox is not keyboard-driven. onScroll stays suppressed while a modal is showing so scroll doesn't stamp divider/pill state. */} - - {feature('MESSAGE_ACTIONS') && isFullscreenEnvEnabled() && !disableMessageActions ? : null} - - - : undefined} modal={centeredModal} modalScrollRef={modalScrollRef} dividerYRef={dividerYRef} hidePill={!!viewedAgentTask} hideSticky={!!viewedTeammateTask} newMessageCount={unseenDivider?.count ?? 0} onPillClick={() => { + + {feature('MESSAGE_ACTIONS') && isFullscreenEnvEnabled() && !disableMessageActions ? : null} + + + : undefined} modal={centeredModal} modalScrollRef={modalScrollRef} dividerYRef={dividerYRef} hidePill={!!viewedAgentTask} hideSticky={!!viewedTeammateTask} newMessageCount={unseenDivider?.count ?? 0} onPillClick={() => { setCursor(null); jumpToNew(scrollRef.current); }} scrollable={<> - - - - {/* Hide the processing placeholder while a modal is showing — + + + + {/* Hide the processing placeholder while a modal is showing — it would sit at the last visible transcript row right above the ▔ divider, showing "❯ /config" as redundant clutter (the modal IS the /config UI). Outside modals it stays so the user sees their input echoed while Claude processes. */} - {!disabled && placeholderText && !centeredModal && } - {toolJSX && !(toolJSX.isLocalJSXCommand && toolJSX.isImmediate) && !toolJsxCentered && - {toolJSX.jsx} - } - {"external" === 'ant' && } - {feature('WEB_BROWSER_TOOL') ? WebBrowserPanelModule && : null} - - {showSpinner && 0} leaderIsIdle={!isLoading} />} - {!showSpinner && !isLoading && !userInputOnProcessing && !hasRunningTeammates && isBriefOnly && !viewedAgentTask && } - {isFullscreenEnvEnabled() && } - } bottom={ - {feature('BUDDY') && companionNarrow && isFullscreenEnvEnabled() && companionVisible ? : null} - - {permissionStickyFooter} - {/* Immediate local-jsx commands (/btw, /sandbox, /assistant, + {toolJSX && !(toolJSX.isLocalJSXCommand && toolJSX.isImmediate) && !toolJsxCentered && + {toolJSX.jsx} + } + {"external" === 'ant' && } + {feature('WEB_BROWSER_TOOL') ? WebBrowserPanelModule && : null} + + {showSpinner && 0} leaderIsIdle={!isLoading} />} + {!showSpinner && !isLoading && !userInputOnProcessing && !hasRunningTeammates && isBriefOnly && !viewedAgentTask && } + {isFullscreenEnvEnabled() && } + } bottom={ + {feature('BUDDY') && companionNarrow && isFullscreenEnvEnabled() && companionVisible ? : null} + + {permissionStickyFooter} + {/* Immediate local-jsx commands (/btw, /sandbox, /assistant, /issue) render here, NOT inside scrollable. They stay mounted while the main conversation streams behind them, so ScrollBox relayouts on each new message would drag them around. bottom @@ -4595,13 +4595,13 @@ export function REPL({ stays in scrollable: the main loop is paused so no jiggle, and their tall content (DiffDetailView renders up to 400 lines with no internal scroll) needs the outer ScrollBox. */} - {toolJSX?.isLocalJSXCommand && toolJSX.isImmediate && !toolJsxCentered && - {toolJSX.jsx} - } - {!showSpinner && !toolJSX?.isLocalJSXCommand && showExpandedTodos && tasksV2 && tasksV2.length > 0 && - - } - {focusedInputDialog === 'sandbox-permission' && + {toolJSX.jsx} + } + {!showSpinner && !toolJSX?.isLocalJSXCommand && showExpandedTodos && tasksV2 && tasksV2.length > 0 && + + } + {focusedInputDialog === 'sandbox-permission' && { @@ -4650,7 +4650,7 @@ export function REPL({ sandboxBridgeCleanupRef.current.delete(approvedHost); } }} />} - {focusedInputDialog === 'prompt' && { + {focusedInputDialog === 'prompt' && { const item = promptQueue[0]; if (!item) return; item.resolve({ @@ -4664,12 +4664,12 @@ export function REPL({ item.reject(new Error('Prompt cancelled by user')); setPromptQueue(([, ...tail]) => tail); }} />} - {/* Show pending indicator on worker while waiting for leader approval */} - {pendingWorkerRequest && } - {/* Show pending indicator for sandbox permission on worker side */} - {pendingSandboxRequest && } - {/* Worker sandbox permission requests from swarm workers */} - {focusedInputDialog === 'worker-sandbox-permission' && } + {/* Show pending indicator for sandbox permission on worker side */} + {pendingSandboxRequest && } + {/* Worker sandbox permission requests from swarm workers */} + {focusedInputDialog === 'worker-sandbox-permission' && } - {focusedInputDialog === 'elicitation' && { + {focusedInputDialog === 'elicitation' && { const currentRequest = elicitation.queue[0]; if (!currentRequest) return; // Call respond callback to resolve Promise @@ -4742,7 +4742,7 @@ export function REPL({ })); currentRequest?.onWaitingDismiss?.(action); }} />} - {focusedInputDialog === 'cost' && { + {focusedInputDialog === 'cost' && { setShowCostDialog(false); setHaveShownCostDialog(true); saveGlobalConfig(current => ({ @@ -4751,7 +4751,7 @@ export function REPL({ })); logEvent('tengu_cost_threshold_acknowledged', {}); }} />} - {focusedInputDialog === 'idle-return' && idleReturnPending && { + {focusedInputDialog === 'idle-return' && idleReturnPending && { const pending = idleReturnPending; setIdleReturnPending(null); logEvent('tengu_idle_return_action', { @@ -4793,13 +4793,13 @@ export function REPL({ } skipIdleCheckRef.current = true; void onSubmitRef.current(pending.input, { - setCursorOffset: () => {}, - clearBuffer: () => {}, - resetHistory: () => {} + setCursorOffset: () => { }, + clearBuffer: () => { }, + resetHistory: () => { } }); }} />} - {focusedInputDialog === 'ide-onboarding' && setShowIdeOnboarding(false)} installationStatus={ideInstallationStatus} />} - {"external" === 'ant' && focusedInputDialog === 'model-switch' && AntModelSwitchCallout && { + {focusedInputDialog === 'ide-onboarding' && setShowIdeOnboarding(false)} installationStatus={ideInstallationStatus} />} + {"external" === 'ant' && focusedInputDialog === 'model-switch' && AntModelSwitchCallout && { setShowModelSwitchCallout(false); if (selection === 'switch' && modelAlias) { setAppState(prev => ({ @@ -4809,8 +4809,8 @@ export function REPL({ })); } }} />} - {"external" === 'ant' && focusedInputDialog === 'undercover-callout' && UndercoverAutoCallout && setShowUndercoverCallout(false)} />} - {focusedInputDialog === 'effort-callout' && { + {"external" === 'ant' && focusedInputDialog === 'undercover-callout' && UndercoverAutoCallout && setShowUndercoverCallout(false)} />} + {focusedInputDialog === 'effort-callout' && { setShowEffortCallout(false); if (selection !== 'dismiss') { setAppState(prev => ({ @@ -4819,7 +4819,7 @@ export function REPL({ })); } }} />} - {focusedInputDialog === 'remote-callout' && { + {focusedInputDialog === 'remote-callout' && { setAppState(prev => { if (!prev.showRemoteCallout) return prev; return { @@ -4834,17 +4834,17 @@ export function REPL({ }); }} />} - {exitFlow} + {exitFlow} - {focusedInputDialog === 'plugin-hint' && hintRecommendation && } + {focusedInputDialog === 'plugin-hint' && hintRecommendation && } - {focusedInputDialog === 'lsp-recommendation' && lspRecommendation && } + {focusedInputDialog === 'lsp-recommendation' && lspRecommendation && } - {focusedInputDialog === 'desktop-upsell' && setShowDesktopUpsellStartup(false)} />} + {focusedInputDialog === 'desktop-upsell' && setShowDesktopUpsellStartup(false)} />} - {feature('ULTRAPLAN') ? focusedInputDialog === 'ultraplan-choice' && ultraplanPendingChoice && store.getState()} setConversationId={setConversationId} /> : null} + {feature('ULTRAPLAN') ? focusedInputDialog === 'ultraplan-choice' && ultraplanPendingChoice && store.getState()} setConversationId={setConversationId} /> : null} - {feature('ULTRAPLAN') ? focusedInputDialog === 'ultraplan-launch' && ultraplanLaunchPending && { + {feature('ULTRAPLAN') ? focusedInputDialog === 'ultraplan-launch' && ultraplanLaunchPending && { const blurb = ultraplanLaunchPending.blurb; setAppState(prev => prev.ultraplanLaunchPending ? { ...prev, @@ -4884,26 +4884,26 @@ export function REPL({ }).then(appendStdout).catch(logError); }} /> : null} - {mrRender()} + {mrRender()} - {!toolJSX?.shouldHidePromptInput && !focusedInputDialog && !isExiting && !disabled && !cursor && <> - {autoRunIssueReason && } - {postCompactSurvey.state !== 'closed' ? : memorySurvey.state !== 'closed' ? : } - {/* Frustration-triggered transcript sharing prompt */} - {frustrationDetection.state !== 'closed' && {}} handleTranscriptSelect={frustrationDetection.handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} />} - {/* Skill improvement survey - appears when improvements detected (ant-only) */} - {"external" === 'ant' && skillImprovementSurvey.suggestion && } - {showIssueFlagBanner && } - {} - - - } - {cursor && - // inputValue is REPL state; typed text survives the round-trip. - } - {focusedInputDialog === 'message-selector' && { + {!toolJSX?.shouldHidePromptInput && !focusedInputDialog && !isExiting && !disabled && !cursor && <> + {autoRunIssueReason && } + {postCompactSurvey.state !== 'closed' ? : memorySurvey.state !== 'closed' ? : } + {/* Frustration-triggered transcript sharing prompt */} + {frustrationDetection.state !== 'closed' && { }} handleTranscriptSelect={frustrationDetection.handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} />} + {/* Skill improvement survey - appears when improvements detected (ant-only) */} + {"external" === 'ant' && skillImprovementSurvey.suggestion && } + {showIssueFlagBanner && } + { } + + + } + {cursor && + // inputValue is REPL state; typed text survives the round-trip. + } + {focusedInputDialog === 'message-selector' && { await fileHistoryRewind((updater: (prev: FileHistoryState) => FileHistoryState) => { setAppState(prev => ({ ...prev, @@ -4985,16 +4985,16 @@ export function REPL({ setIsMessageSelectorVisible(false); setMessageSelectorPreselect(undefined); }} />} - {"external" === 'ant' && } - - {feature('BUDDY') && !(companionNarrow && isFullscreenEnvEnabled()) && companionVisible ? : null} - } /> - - ; + {"external" === 'ant' && } + + {feature('BUDDY') && !(companionNarrow && isFullscreenEnvEnabled()) && companionVisible ? : null} + } /> + + ; if (isFullscreenEnvEnabled()) { return - {mainReturn} - ; + {mainReturn} + ; } return mainReturn; } From 63546dcd9c3f80b1042897360b069033d30d7aff Mon Sep 17 00:00:00 2001 From: Raj Rasane Date: Thu, 2 Apr 2026 10:38:22 +0530 Subject: [PATCH 12/35] chore: rename default terminal title to Open Claude --- src/screens/REPL.tsx | 2 +- src/services/mcp/client.ts | 118 ++++++++++++++++++------------------- src/services/notifier.ts | 2 +- 3 files changed, 61 insertions(+), 61 deletions(-) diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index ef1513aa..65df5ca4 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -1127,7 +1127,7 @@ export function REPL({ // session from mid-conversation context. const haikuTitleAttemptedRef = useRef((initialMessages?.length ?? 0) > 0); const agentTitle = mainThreadAgentDefinition?.agentType; - const terminalTitle = sessionTitle ?? agentTitle ?? haikuTitle ?? 'Claude Code'; + const terminalTitle = sessionTitle ?? agentTitle ?? haikuTitle ?? 'Open Claude'; const isWaitingForApproval = toolUseConfirmQueue.length > 0 || promptQueue.length > 0 || pendingWorkerRequest || pendingSandboxRequest; // Local-jsx commands (like /plugin, /config) show user-facing dialogs that // wait for input. Require jsx != null — if the flag is stuck true but jsx diff --git a/src/services/mcp/client.ts b/src/services/mcp/client.ts index 0b3afc6a..b053dbb6 100644 --- a/src/services/mcp/client.ts +++ b/src/services/mcp/client.ts @@ -116,8 +116,8 @@ import { getLoggingSafeMcpBaseUrl } from './utils.js' /* eslint-disable @typescript-eslint/no-require-imports */ const fetchMcpSkillsForClient = feature('MCP_SKILLS') ? ( - require('../../skills/mcpSkills.js') as typeof import('../../skills/mcpSkills.js') - ).fetchMcpSkillsForClient + require('../../skills/mcpSkills.js') as typeof import('../../skills/mcpSkills.js') + ).fetchMcpSkillsForClient : null import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' @@ -240,12 +240,12 @@ const claudeInChromeToolRendering = // GrowthBook tengu_malort_pedway (see gates.ts). const computerUseWrapper = feature('CHICAGO_MCP') ? (): typeof import('../../utils/computerUse/wrapper.js') => - require('../../utils/computerUse/wrapper.js') + require('../../utils/computerUse/wrapper.js') : undefined const isComputerUseMCPServer = feature('CHICAGO_MCP') ? ( - require('../../utils/computerUse/common.js') as typeof import('../../utils/computerUse/common.js') - ).isComputerUseMCPServer + require('../../utils/computerUse/common.js') as typeof import('../../utils/computerUse/common.js') + ).isComputerUseMCPServer : undefined import { mkdir, readFile, unlink, writeFile } from 'fs/promises' @@ -326,9 +326,9 @@ function mcpBaseUrlAnalytics(serverRef: ScopedMcpServerConfig): { const url = getLoggingSafeMcpBaseUrl(serverRef) return url ? { - mcpServerBaseUrl: - url as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - } + mcpServerBaseUrl: + url as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + } : {} } @@ -683,20 +683,20 @@ export const connectToServer = memoize( const transportOptions: SSEClientTransportOptions = proxyOptions.dispatcher ? { - eventSourceInit: { - fetch: async (url: string | URL, init?: RequestInit) => { - // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins - return fetch(url, { - ...init, - ...proxyOptions, - headers: { - 'User-Agent': getMCPUserAgent(), - ...init?.headers, - }, - }) - }, + eventSourceInit: { + fetch: async (url: string | URL, init?: RequestInit) => { + // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins + return fetch(url, { + ...init, + ...proxyOptions, + headers: { + 'User-Agent': getMCPUserAgent(), + ...init?.headers, + }, + }) }, - } + }, + } : {} transport = new SSEClientTransport( @@ -832,8 +832,8 @@ export const connectToServer = memoize( 'User-Agent': getMCPUserAgent(), ...(sessionIngressToken && !hasOAuthTokens && { - Authorization: `Bearer ${sessionIngressToken}`, - }), + Authorization: `Bearer ${sessionIngressToken}`, + }), ...combinedHeaders, }, }, @@ -842,10 +842,10 @@ export const connectToServer = memoize( // Redact sensitive headers before logging const headersForLogging = transportOptions.requestInit?.headers ? mapValues( - transportOptions.requestInit.headers as Record, - (value, key) => - key.toLowerCase() === 'authorization' ? '[REDACTED]' : value, - ) + transportOptions.requestInit.headers as Record, + (value, key) => + key.toLowerCase() === 'authorization' ? '[REDACTED]' : value, + ) : undefined logMCPDebug( @@ -985,7 +985,7 @@ export const connectToServer = memoize( const client = new Client( { name: 'claude-code', - title: 'Claude Code', + title: 'Open Claude', version: MACRO.VERSION ?? 'unknown', description: "Anthropic's agentic coding tool", websiteUrl: PRODUCT_URL, @@ -1054,9 +1054,9 @@ export const connectToServer = memoize( `Connection timeout triggered after ${elapsed}ms (limit: ${getConnectionTimeoutMs()}ms)`, ) if (inProcessServer) { - inProcessServer.close().catch(() => {}) + inProcessServer.close().catch(() => { }) } - transport.close().catch(() => {}) + transport.close().catch(() => { }) reject( new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS( `MCP server "${name}" connection timed out after ${getConnectionTimeoutMs()}ms`, @@ -1145,9 +1145,9 @@ export const connectToServer = memoize( }) } if (inProcessServer) { - inProcessServer.close().catch(() => {}) + inProcessServer.close().catch(() => { }) } - transport.close().catch(() => {}) + transport.close().catch(() => { }) if (stderrOutput) { logMCPError(name, `Server stderr: ${stderrOutput}`) } @@ -1627,7 +1627,7 @@ export const connectToServer = memoize( logMCPError(name, `Connection failed: ${errorMessage(error)}`) if (inProcessServer) { - inProcessServer.close().catch(() => {}) + inProcessServer.close().catch(() => { }) } return { name, @@ -1779,8 +1779,8 @@ export const fetchToolsForClient = memoizeWithLRU( searchHint: typeof tool._meta?.['anthropic/searchHint'] === 'string' ? tool._meta['anthropic/searchHint'] - .replace(/\s+/g, ' ') - .trim() || undefined + .replace(/\s+/g, ' ') + .trim() || undefined : undefined, alwaysLoad: tool._meta?.['anthropic/alwaysLoad'] === true, async description() { @@ -1871,11 +1871,11 @@ export const fetchToolsForClient = memoizeWithLRU( onProgress: onProgress && toolUseId ? progressData => { - onProgress({ - toolUseID: toolUseId, - data: progressData, - }) - } + onProgress({ + toolUseID: toolUseId, + data: progressData, + }) + } : undefined, handleElicitation: context.handleElicitation, }) @@ -1975,14 +1975,14 @@ export const fetchToolsForClient = memoizeWithLRU( return `${client.name} - ${displayName} (MCP)` }, ...(isClaudeInChromeMCPServer(client.name) && - (client.config.type === 'stdio' || !client.config.type) + (client.config.type === 'stdio' || !client.config.type) ? claudeInChromeToolRendering().getClaudeInChromeMCPToolOverrides( - tool.name, - ) + tool.name, + ) : {}), ...(feature('CHICAGO_MCP') && - (client.config.type === 'stdio' || !client.config.type) && - isComputerUseMCPServer!(client.name) + (client.config.type === 'stdio' || !client.config.type) && + isComputerUseMCPServer!(client.name) ? computerUseWrapper!().getComputerUseMCPToolOverrides(tool.name) : {}), } @@ -2876,9 +2876,9 @@ export async function callMCPToolWithUrlElicitationRetry({ const errorData = error.data const rawElicitations = errorData != null && - typeof errorData === 'object' && - 'elicitations' in errorData && - Array.isArray(errorData.elicitations) + typeof errorData === 'object' && + 'elicitations' in errorData && + Array.isArray(errorData.elicitations) ? (errorData.elicitations as unknown[]) : [] @@ -3101,16 +3101,16 @@ async function callMCPTool({ timeout: timeoutMs, onprogress: onProgress ? sdkProgress => { - onProgress({ - type: 'mcp_progress', - status: 'progress', - serverName: name, - toolName: tool, - progress: sdkProgress.progress, - total: sdkProgress.total, - progressMessage: sdkProgress.message, - }) - } + onProgress({ + type: 'mcp_progress', + status: 'progress', + serverName: name, + toolName: tool, + progress: sdkProgress.progress, + total: sdkProgress.total, + progressMessage: sdkProgress.message, + }) + } : undefined, }, ), @@ -3280,7 +3280,7 @@ export async function setupSdkMcpClients( const client = new Client( { name: 'claude-code', - title: 'Claude Code', + title: 'Open Claude', version: MACRO.VERSION ?? 'unknown', description: "Anthropic's agentic coding tool", websiteUrl: PRODUCT_URL, diff --git a/src/services/notifier.ts b/src/services/notifier.ts index 330e16a0..b2136a4e 100644 --- a/src/services/notifier.ts +++ b/src/services/notifier.ts @@ -35,7 +35,7 @@ export async function sendNotification( }) } -const DEFAULT_TITLE = 'Claude Code' +const DEFAULT_TITLE = 'Open Claude' async function sendToChannel( channel: string, From f340b199c83f960ab292e6785bb78828fe45f4cc Mon Sep 17 00:00:00 2001 From: Raj Rasane Date: Thu, 2 Apr 2026 10:40:27 +0530 Subject: [PATCH 13/35] refactor: simplify session title fallback to static 'Open Claude' --- src/utils/sessionTitle.ts | 25 +++---------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/src/utils/sessionTitle.ts b/src/utils/sessionTitle.ts index 72ae8054..141833b4 100644 --- a/src/utils/sessionTitle.ts +++ b/src/utils/sessionTitle.ts @@ -125,28 +125,9 @@ export async function generateSessionTitle( }) logEvent('tengu_session_title_generated', { success: false }) - // Fallback: derive a title locally from the user's first message. - // This ensures 3P providers (Ollama, Gemini, OpenAI) still get - // meaningful terminal titles when the Haiku API call is unavailable. - return localFallbackTitle(trimmed) + // Fallback: When using 3P providers without a compatible schema, + // default to the application name. + return 'Open Claude' } } -/** - * Fallback local title generator for when the Haiku API is unavailable - * (e.g. when using third-party providers without an Anthropic API key). - */ -function localFallbackTitle(text: string): string | null { - const words = text.split(/\s+/).slice(0, 7) - if (words.length === 0) return null - - // Create a sentence-case string - let fallback = words.join(' ') - if (fallback.length > 50) { - fallback = fallback.substring(0, 49) + '…' - } - - if (fallback.length <= 3) return null - - return fallback.charAt(0).toUpperCase() + fallback.slice(1) -} From 25c5987276baf5049339a3193d04be76cd65b015 Mon Sep 17 00:00:00 2001 From: Rithul Kamesh Date: Thu, 2 Apr 2026 11:25:28 +0530 Subject: [PATCH 14/35] feat: add support for GitHub Models provider - Introduced environment variable CLAUDE_CODE_USE_GITHUB to enable GitHub Models. - Added checks for GITHUB_TOKEN or GH_TOKEN for authentication. - Updated base URL handling to include GitHub Models default. - Enhanced provider detection and error handling for GitHub Models. - Updated relevant functions and components to accommodate the new provider. --- docs/github-models-pr-draft.md | 24 ++ scripts/system-check.ts | 79 +++++- src/commands.ts | 2 + src/commands/onboard-github/index.ts | 11 + .../onboard-github/onboard-github.tsx | 228 ++++++++++++++++++ src/components/StartupScreen.ts | 8 + src/entrypoints/cli.tsx | 37 ++- src/main.tsx | 6 +- src/services/api/client.ts | 5 +- src/services/api/openaiShim.ts | 117 +++++++-- .../api/providerConfig.github.test.ts | 41 ++++ src/services/api/providerConfig.ts | 26 +- src/services/github/deviceFlow.test.ts | 94 ++++++++ src/services/github/deviceFlow.ts | 174 +++++++++++++ src/utils/auth.ts | 8 +- src/utils/context.ts | 8 +- .../githubModelsCredentials.hydrate.test.ts | 66 +++++ src/utils/githubModelsCredentials.test.ts | 47 ++++ src/utils/githubModelsCredentials.ts | 73 ++++++ src/utils/managedEnvConstants.ts | 2 + src/utils/model/providers.test.ts | 12 + src/utils/model/providers.ts | 29 ++- src/utils/providerProfile.ts | 2 + src/utils/swarm/spawnUtils.ts | 12 + 24 files changed, 1069 insertions(+), 42 deletions(-) create mode 100644 docs/github-models-pr-draft.md create mode 100644 src/commands/onboard-github/index.ts create mode 100644 src/commands/onboard-github/onboard-github.tsx create mode 100644 src/services/api/providerConfig.github.test.ts create mode 100644 src/services/github/deviceFlow.test.ts create mode 100644 src/services/github/deviceFlow.ts create mode 100644 src/utils/githubModelsCredentials.hydrate.test.ts create mode 100644 src/utils/githubModelsCredentials.test.ts create mode 100644 src/utils/githubModelsCredentials.ts diff --git a/docs/github-models-pr-draft.md b/docs/github-models-pr-draft.md new file mode 100644 index 00000000..21fa7fa8 --- /dev/null +++ b/docs/github-models-pr-draft.md @@ -0,0 +1,24 @@ +# GitHub Models + onboard — PR draft (paste into GitHub) + +**Title:** `feat: GitHub Models provider + interactive onboard (keychain-backed)` + +**Body:** + +## Summary + +- Adds GitHub Models (`models.github.ai`) as an OpenAI-compatible backend via `CLAUDE_CODE_USE_GITHUB` (see existing shim changes). +- Adds `/onboard-github`: interactive Ink flow for GitHub Device Login or PAT, stores token in OS-backed secure storage (macOS Keychain when available, else `~/.claude/.credentials.json`), and writes user settings `env` so no `export GITHUB_TOKEN` is required. +- Applies user settings before provider env validation and hydrates `GITHUB_TOKEN` from secure storage when the GitHub provider flag is on. + +## How to test + +1. Run `openclaude` and execute `/onboard-github` (or launch via command registration). +2. Complete device flow or paste a PAT with Models access. +3. Restart CLI; confirm `CLAUDE_CODE_USE_GITHUB=1` in `~/.claude/settings.json` (or merged file) and that inference works without exporting `GITHUB_TOKEN`. +4. `bun test` (new suites) + `bun run build`. + +## Notes / follow-ups + +- Device flow OAuth app client ID is configurable via `GITHUB_DEVICE_FLOW_CLIENT_ID`; verify scope list against current GitHub Models documentation. +- `/logout` currently deletes all secure storage; GitHub token is cleared too — document or narrow in a follow-up. +- Linux: secure storage is plaintext with chmod 600 today; libsecret is still TODO in `secureStorage`. diff --git a/scripts/system-check.ts b/scripts/system-check.ts index e129685a..6626149a 100644 --- a/scripts/system-check.ts +++ b/scripts/system-check.ts @@ -93,11 +93,15 @@ function isLocalBaseUrl(baseUrl: string): boolean { } const GEMINI_DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/openai' +const GITHUB_MODELS_DEFAULT_BASE = 'https://models.github.ai/inference' function currentBaseUrl(): string { if (isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) { return process.env.GEMINI_BASE_URL ?? GEMINI_DEFAULT_BASE_URL } + if (isTruthy(process.env.CLAUDE_CODE_USE_GITHUB)) { + return process.env.OPENAI_BASE_URL ?? GITHUB_MODELS_DEFAULT_BASE + } return process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1' } @@ -126,15 +130,47 @@ function checkGeminiEnv(): CheckResult[] { return results } +function checkGithubEnv(): CheckResult[] { + const results: CheckResult[] = [] + const baseUrl = process.env.OPENAI_BASE_URL ?? GITHUB_MODELS_DEFAULT_BASE + results.push(pass('Provider mode', 'GitHub Models provider enabled.')) + + const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN + if (!token?.trim()) { + results.push(fail('GITHUB_TOKEN', 'Missing. Set GITHUB_TOKEN or GH_TOKEN.')) + } else { + results.push(pass('GITHUB_TOKEN', 'Configured.')) + } + + if (!process.env.OPENAI_MODEL) { + results.push( + pass( + 'OPENAI_MODEL', + 'Not set. Default github:copilot → openai/gpt-4.1 at runtime.', + ), + ) + } else { + results.push(pass('OPENAI_MODEL', process.env.OPENAI_MODEL)) + } + + results.push(pass('OPENAI_BASE_URL', baseUrl)) + return results +} + function checkOpenAIEnv(): CheckResult[] { const results: CheckResult[] = [] const useGemini = isTruthy(process.env.CLAUDE_CODE_USE_GEMINI) + const useGithub = isTruthy(process.env.CLAUDE_CODE_USE_GITHUB) const useOpenAI = isTruthy(process.env.CLAUDE_CODE_USE_OPENAI) if (useGemini) { return checkGeminiEnv() } + if (useGithub && !useOpenAI) { + return checkGithubEnv() + } + if (!useOpenAI) { results.push(pass('Provider mode', 'Anthropic login flow enabled (CLAUDE_CODE_USE_OPENAI is off).')) return results @@ -181,10 +217,19 @@ function checkOpenAIEnv(): CheckResult[] { } const key = process.env.OPENAI_API_KEY + const githubToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN if (key === 'SUA_CHAVE') { results.push(fail('OPENAI_API_KEY', 'Placeholder value detected: SUA_CHAVE.')) - } else if (!key && !isLocalBaseUrl(request.baseUrl)) { + } else if ( + !key && + !isLocalBaseUrl(request.baseUrl) && + !(useGithub && githubToken?.trim()) + ) { results.push(fail('OPENAI_API_KEY', 'Missing key for non-local provider URL.')) + } else if (!key && useGithub && githubToken?.trim()) { + results.push( + pass('OPENAI_API_KEY', 'Not set; GITHUB_TOKEN/GH_TOKEN will be used for GitHub Models.'), + ) } else if (!key) { results.push(pass('OPENAI_API_KEY', 'Not set (allowed for local providers like Ollama/LM Studio).')) } else { @@ -197,11 +242,19 @@ function checkOpenAIEnv(): CheckResult[] { async function checkBaseUrlReachability(): Promise { const useGemini = isTruthy(process.env.CLAUDE_CODE_USE_GEMINI) const useOpenAI = isTruthy(process.env.CLAUDE_CODE_USE_OPENAI) + const useGithub = isTruthy(process.env.CLAUDE_CODE_USE_GITHUB) - if (!useGemini && !useOpenAI) { + if (!useGemini && !useOpenAI && !useGithub) { return pass('Provider reachability', 'Skipped (OpenAI-compatible mode disabled).') } + if (useGithub) { + return pass( + 'Provider reachability', + 'Skipped for GitHub Models (inference endpoint differs from OpenAI /models probe).', + ) + } + const geminiBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai' const resolvedBaseUrl = useGemini ? (process.env.GEMINI_BASE_URL ?? geminiBaseUrl) @@ -272,7 +325,11 @@ async function checkBaseUrlReachability(): Promise { } function checkOllamaProcessorMode(): CheckResult { - if (!isTruthy(process.env.CLAUDE_CODE_USE_OPENAI) || isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) { + if ( + !isTruthy(process.env.CLAUDE_CODE_USE_OPENAI) || + isTruthy(process.env.CLAUDE_CODE_USE_GEMINI) || + isTruthy(process.env.CLAUDE_CODE_USE_GITHUB) + ) { return pass('Ollama processor mode', 'Skipped (OpenAI-compatible mode disabled).') } @@ -319,6 +376,22 @@ function serializeSafeEnvSummary(): Record { GEMINI_API_KEY_SET: Boolean(process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY), } } + if ( + isTruthy(process.env.CLAUDE_CODE_USE_GITHUB) && + !isTruthy(process.env.CLAUDE_CODE_USE_OPENAI) + ) { + return { + CLAUDE_CODE_USE_GITHUB: true, + OPENAI_MODEL: + process.env.OPENAI_MODEL ?? + '(unset, default: github:copilot → openai/gpt-4.1)', + OPENAI_BASE_URL: + process.env.OPENAI_BASE_URL ?? GITHUB_MODELS_DEFAULT_BASE, + GITHUB_TOKEN_SET: Boolean( + process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN, + ), + } + } const request = resolveProviderRequest({ model: process.env.OPENAI_MODEL, baseUrl: process.env.OPENAI_BASE_URL, diff --git a/src/commands.ts b/src/commands.ts index 10f03b22..fe15aa2b 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -19,6 +19,7 @@ import cost from './commands/cost/index.js' import diff from './commands/diff/index.js' import ctx_viz from './commands/ctx_viz/index.js' import doctor from './commands/doctor/index.js' +import onboardGithub from './commands/onboard-github/index.js' import memory from './commands/memory/index.js' import help from './commands/help/index.js' import ide from './commands/ide/index.js' @@ -288,6 +289,7 @@ const COMMANDS = memoize((): Command[] => [ memory, mobile, model, + onboardGithub, outputStyle, remoteEnv, plugin, diff --git a/src/commands/onboard-github/index.ts b/src/commands/onboard-github/index.ts new file mode 100644 index 00000000..91d67247 --- /dev/null +++ b/src/commands/onboard-github/index.ts @@ -0,0 +1,11 @@ +import type { Command } from '../../commands.js' + +const onboardGithub: Command = { + name: 'onboard-github', + description: + 'Interactive setup for GitHub Models: device login or PAT, saved to secure storage', + type: 'local-jsx', + load: () => import('./onboard-github.js'), +} + +export default onboardGithub diff --git a/src/commands/onboard-github/onboard-github.tsx b/src/commands/onboard-github/onboard-github.tsx new file mode 100644 index 00000000..26088392 --- /dev/null +++ b/src/commands/onboard-github/onboard-github.tsx @@ -0,0 +1,228 @@ +import * as React from 'react' +import { useCallback, useState } from 'react' +import { Select } from '../../components/CustomSelect/select.js' +import { Spinner } from '../../components/Spinner.js' +import TextInput from '../../components/TextInput.js' +import { Box, Text } from '../../ink.js' +import { + openVerificationUri, + pollAccessToken, + requestDeviceCode, +} from '../../services/github/deviceFlow.js' +import type { LocalJSXCommandCall } from '../../types/command.js' +import { + hydrateGithubModelsTokenFromSecureStorage, + saveGithubModelsToken, +} from '../../utils/githubModelsCredentials.js' +import { updateSettingsForSource } from '../../utils/settings/settings.js' + +const DEFAULT_MODEL = 'github:copilot' + +type Step = + | 'menu' + | 'device-busy' + | 'pat' + | 'error' + +function mergeUserSettingsEnv(model: string): { ok: boolean; detail?: string } { + const { error } = updateSettingsForSource('userSettings', { + env: { + CLAUDE_CODE_USE_GITHUB: '1', + OPENAI_MODEL: model, + }, + }) + if (error) { + return { ok: false, detail: error.message } + } + return { ok: true } +} + +function OnboardGithub(props: { + onDone: Parameters[0] + onChangeAPIKey: () => void +}): React.ReactNode { + const { onDone, onChangeAPIKey } = props + const [step, setStep] = useState('menu') + const [errorMsg, setErrorMsg] = useState(null) + const [deviceHint, setDeviceHint] = useState<{ + user_code: string + verification_uri: string + } | null>(null) + const [patDraft, setPatDraft] = useState('') + + const finalize = useCallback( + async (token: string, model: string = DEFAULT_MODEL) => { + const saved = saveGithubModelsToken(token) + if (!saved.success) { + setErrorMsg(saved.warning ?? 'Could not save token to secure storage.') + setStep('error') + return + } + const merged = mergeUserSettingsEnv(model.trim() || DEFAULT_MODEL) + if (!merged.ok) { + setErrorMsg( + `Token saved, but settings were not updated: ${merged.detail ?? 'unknown error'}. ` + + `Add env CLAUDE_CODE_USE_GITHUB=1 and OPENAI_MODEL to ~/.claude/settings.json manually.`, + ) + setStep('error') + return + } + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.OPENAI_MODEL = model.trim() || DEFAULT_MODEL + hydrateGithubModelsTokenFromSecureStorage() + onChangeAPIKey() + onDone( + 'GitHub Models onboard complete. Token stored in secure storage; user settings updated. Restart if the model does not switch.', + { display: 'user' }, + ) + }, + [onChangeAPIKey, onDone], + ) + + const runDeviceFlow = useCallback(async () => { + setStep('device-busy') + setErrorMsg(null) + setDeviceHint(null) + try { + const device = await requestDeviceCode() + setDeviceHint({ + user_code: device.user_code, + verification_uri: device.verification_uri, + }) + await openVerificationUri(device.verification_uri) + const token = await pollAccessToken(device.device_code, { + initialInterval: device.interval, + timeoutSeconds: device.expires_in, + }) + await finalize(token, DEFAULT_MODEL) + } catch (e) { + setErrorMsg(e instanceof Error ? e.message : String(e)) + setStep('error') + } + }, [finalize]) + + if (step === 'error' && errorMsg) { + const options = [ + { + label: 'Back to menu', + value: 'back' as const, + }, + { + label: 'Exit', + value: 'exit' as const, + }, + ] + return ( + + {errorMsg} + { + if (v === 'cancel') { + onDone('GitHub onboard cancelled', { display: 'system' }) + return + } + if (v === 'pat') { + setStep('pat') + return + } + void runDeviceFlow() + }} + /> + + ) +} + +export const call: LocalJSXCommandCall = async (onDone, context) => { + return ( + + ) +} diff --git a/src/components/StartupScreen.ts b/src/components/StartupScreen.ts index ded4f457..b20d26c1 100644 --- a/src/components/StartupScreen.ts +++ b/src/components/StartupScreen.ts @@ -80,6 +80,7 @@ const LOGO_CLAUDE = [ function detectProvider(): { name: string; model: string; baseUrl: string; isLocal: boolean } { const useGemini = process.env.CLAUDE_CODE_USE_GEMINI === '1' || process.env.CLAUDE_CODE_USE_GEMINI === 'true' + const useGithub = process.env.CLAUDE_CODE_USE_GITHUB === '1' || process.env.CLAUDE_CODE_USE_GITHUB === 'true' const useOpenAI = process.env.CLAUDE_CODE_USE_OPENAI === '1' || process.env.CLAUDE_CODE_USE_OPENAI === 'true' if (useGemini) { @@ -88,6 +89,13 @@ function detectProvider(): { name: string; model: string; baseUrl: string; isLoc return { name: 'Google Gemini', model, baseUrl, isLocal: false } } + if (useGithub) { + const model = process.env.OPENAI_MODEL || 'github:copilot' + const baseUrl = + process.env.OPENAI_BASE_URL || 'https://models.github.ai/inference' + return { name: 'GitHub Models', model, baseUrl, isLocal: false } + } + if (useOpenAI) { const model = process.env.OPENAI_MODEL || 'gpt-4o' const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1' diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index 71adb260..629dd7db 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -46,7 +46,22 @@ function isLocalProviderUrl(baseUrl: string | undefined): boolean { } function validateProviderEnvOrExit(): void { - if (!isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) { + const useOpenAI = isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) + const useGithub = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) + + if (useGithub && !useOpenAI) { + const token = + (process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim()) ?? '' + if (!token) { + console.error( + 'GITHUB_TOKEN or GH_TOKEN is required when CLAUDE_CODE_USE_GITHUB=1.', + ) + process.exit(1) + } + return + } + + if (!useOpenAI) { return } @@ -77,8 +92,15 @@ function validateProviderEnvOrExit(): void { } if (!process.env.OPENAI_API_KEY && !isLocalProviderUrl(request.baseUrl)) { - console.error('OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.') - process.exit(1) + const hasGithubToken = !!( + process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() + ) + if (!(useGithub && hasGithubToken)) { + console.error( + 'OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local. When CLAUDE_CODE_USE_GITHUB=1, GITHUB_TOKEN or GH_TOKEN may be used instead.', + ) + process.exit(1) + } } } @@ -98,6 +120,15 @@ async function main(): Promise { return; } + { + const { enableConfigs } = await import('../utils/config.js') + enableConfigs() + const { applySafeConfigEnvironmentVariables } = await import('../utils/managedEnv.js') + applySafeConfigEnvironmentVariables() + const { hydrateGithubModelsTokenFromSecureStorage } = await import('../utils/githubModelsCredentials.js') + hydrateGithubModelsTokenFromSecureStorage() + } + validateProviderEnvOrExit() // Print the gradient startup screen before the Ink UI loads diff --git a/src/main.tsx b/src/main.tsx index 07a3a3d2..a08f5899 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2313,7 +2313,11 @@ async function run(): Promise { errors } = getSettingsWithErrors(); const nonMcpErrors = errors.filter(e => !e.mcpErrorMetadata); - if (nonMcpErrors.length > 0 && !isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) { + if ( + nonMcpErrors.length > 0 && + !isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) && + !isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) + ) { await launchInvalidSettingsDialog(root, { settingsErrors: nonMcpErrors, onExit: () => gracefulShutdownSync(1) diff --git a/src/services/api/client.ts b/src/services/api/client.ts index 493f4d73..ee50e35c 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -154,7 +154,10 @@ export async function getAnthropicClient({ fetch: resolvedFetch, }), } - if (isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) { + if ( + isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) || + isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) + ) { const { createOpenAIShimClient } = await import('./openaiShim.js') return createOpenAIShimClient({ defaultHeaders, diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 9b77d07e..f13d2f15 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -14,8 +14,15 @@ * OPENAI_BASE_URL=http://... — base URL (default: https://api.openai.com/v1) * OPENAI_MODEL=gpt-4o — default model override * CODEX_API_KEY / ~/.codex/auth.json — Codex auth for codexplan/codexspark + * + * GitHub Models (models.github.ai), OpenAI-compatible: + * CLAUDE_CODE_USE_GITHUB=1 — enable GitHub inference (no need for USE_OPENAI) + * GITHUB_TOKEN or GH_TOKEN — PAT with models access (mapped to Bearer auth) + * OPENAI_MODEL — optional; use github:copilot or openai/gpt-4.1 style IDs */ +import { isEnvTruthy } from '../../utils/envUtils.js' +import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js' import { codexStreamToAnthropic, collectCodexCompletedResponse, @@ -30,6 +37,25 @@ import { resolveProviderRequest, } from './providerConfig.js' +const GITHUB_MODELS_DEFAULT_BASE = 'https://models.github.ai/inference' +const GITHUB_API_VERSION = '2022-11-28' +const GITHUB_429_MAX_RETRIES = 3 +const GITHUB_429_BASE_DELAY_SEC = 1 +const GITHUB_429_MAX_DELAY_SEC = 32 + +function isGithubModelsMode(): boolean { + return isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) +} + +function formatRetryAfterHint(response: Response): string { + const ra = response.headers.get('retry-after') + return ra ? ` (Retry-After: ${ra})` : '' +} + +function sleepMs(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + // --------------------------------------------------------------------------- // Types — minimal subset of Anthropic SDK types we need to produce // --------------------------------------------------------------------------- @@ -254,9 +280,7 @@ function normalizeSchemaForOpenAI( function convertTools( tools: Array<{ name: string; description?: string; input_schema?: Record }>, ): OpenAITool[] { - const isGemini = - process.env.CLAUDE_CODE_USE_GEMINI === '1' || - process.env.CLAUDE_CODE_USE_GEMINI === 'true' + const isGemini = isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) return tools .filter(t => t.name !== 'ToolSearchTool') // Not relevant for OpenAI @@ -666,6 +690,12 @@ class OpenAIShimMessages { body.stream_options = { include_usage: true } } + const isGithub = isGithubModelsMode() + if (isGithub && body.max_completion_tokens !== undefined) { + body.max_tokens = body.max_completion_tokens + delete body.max_completion_tokens + } + if (params.temperature !== undefined) body.temperature = params.temperature if (params.top_p !== undefined) body.top_p = params.top_p @@ -715,6 +745,11 @@ class OpenAIShimMessages { } } + if (isGithub) { + headers.Accept = 'application/vnd.github.v3+json' + headers['X-GitHub-Api-Version'] = GITHUB_API_VERSION + } + // Build the chat completions URL // Azure Cognitive Services / Azure OpenAI require a deployment-specific path // and an api-version query parameter. @@ -737,19 +772,42 @@ class OpenAIShimMessages { chatCompletionsUrl = `${request.baseUrl}/chat/completions` } - const response = await fetch(chatCompletionsUrl, { - method: 'POST', + const fetchInit = { + method: 'POST' as const, headers, body: JSON.stringify(body), signal: options?.signal, - }) - - if (!response.ok) { - const errorBody = await response.text().catch(() => 'unknown error') - throw new Error(`OpenAI API error ${response.status}: ${errorBody}`) } - return response + const maxAttempts = isGithub ? GITHUB_429_MAX_RETRIES : 1 + let response: Response | undefined + for (let attempt = 0; attempt < maxAttempts; attempt++) { + response = await fetch(chatCompletionsUrl, fetchInit) + if (response.ok) { + return response + } + if ( + isGithub && + response.status === 429 && + attempt < maxAttempts - 1 + ) { + await response.text().catch(() => {}) + const delaySec = Math.min( + GITHUB_429_BASE_DELAY_SEC * 2 ** attempt, + GITHUB_429_MAX_DELAY_SEC, + ) + await sleepMs(delaySec * 1000) + continue + } + const errorBody = await response.text().catch(() => 'unknown error') + const rateHint = + isGithub && response.status === 429 ? formatRetryAfterHint(response) : '' + throw new Error( + `OpenAI API error ${response.status}: ${errorBody}${rateHint}`, + ) + } + + throw new Error('OpenAI shim: request loop exited unexpectedly') } private _convertNonStreamingResponse( @@ -759,7 +817,10 @@ class OpenAIShimMessages { choices?: Array<{ message?: { role?: string - content?: string | null + content?: + | string + | null + | Array<{ type?: string; text?: string }> tool_calls?: Array<{ id: string function: { name: string; arguments: string } @@ -778,8 +839,25 @@ class OpenAIShimMessages { const choice = data.choices?.[0] const content: Array> = [] - if (choice?.message?.content) { - content.push({ type: 'text', text: choice.message.content }) + const rawContent = choice?.message?.content + if (typeof rawContent === 'string' && rawContent) { + content.push({ type: 'text', text: rawContent }) + } else if (Array.isArray(rawContent) && rawContent.length > 0) { + const parts: string[] = [] + for (const part of rawContent) { + if ( + part && + typeof part === 'object' && + part.type === 'text' && + typeof part.text === 'string' + ) { + parts.push(part.text) + } + } + const joined = parts.join('\n') + if (joined) { + content.push({ type: 'text', text: joined }) + } } if (choice?.message?.tool_calls) { @@ -838,12 +916,11 @@ export function createOpenAIShimClient(options: { maxRetries?: number timeout?: number }): unknown { + hydrateGithubModelsTokenFromSecureStorage() + // When Gemini provider is active, map Gemini env vars to OpenAI-compatible ones // so the existing providerConfig.ts infrastructure picks them up correctly. - if ( - process.env.CLAUDE_CODE_USE_GEMINI === '1' || - process.env.CLAUDE_CODE_USE_GEMINI === 'true' - ) { + if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) { process.env.OPENAI_BASE_URL ??= process.env.GEMINI_BASE_URL ?? 'https://generativelanguage.googleapis.com/v1beta/openai' @@ -852,6 +929,10 @@ export function createOpenAIShimClient(options: { if (process.env.GEMINI_MODEL && !process.env.OPENAI_MODEL) { process.env.OPENAI_MODEL = process.env.GEMINI_MODEL } + } else if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)) { + process.env.OPENAI_BASE_URL ??= GITHUB_MODELS_DEFAULT_BASE + process.env.OPENAI_API_KEY ??= + process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? '' } const beta = new OpenAIShimBeta({ diff --git a/src/services/api/providerConfig.github.test.ts b/src/services/api/providerConfig.github.test.ts new file mode 100644 index 00000000..6177a9c6 --- /dev/null +++ b/src/services/api/providerConfig.github.test.ts @@ -0,0 +1,41 @@ +import { afterEach, expect, test } from 'bun:test' + +import { + DEFAULT_GITHUB_MODELS_API_MODEL, + normalizeGithubModelsApiModel, + resolveProviderRequest, +} from './providerConfig.js' + +const originalUseGithub = process.env.CLAUDE_CODE_USE_GITHUB + +afterEach(() => { + if (originalUseGithub === undefined) { + delete process.env.CLAUDE_CODE_USE_GITHUB + } else { + process.env.CLAUDE_CODE_USE_GITHUB = originalUseGithub + } +}) + +test.each([ + ['copilot', DEFAULT_GITHUB_MODELS_API_MODEL], + ['github:copilot', DEFAULT_GITHUB_MODELS_API_MODEL], + ['', DEFAULT_GITHUB_MODELS_API_MODEL], + ['github:gpt-4o', 'gpt-4o'], + ['gpt-4o', 'gpt-4o'], + ['github:copilot?reasoning=high', DEFAULT_GITHUB_MODELS_API_MODEL], +] as const)('normalizeGithubModelsApiModel(%s) -> %s', (input, expected) => { + expect(normalizeGithubModelsApiModel(input)).toBe(expected) +}) + +test('resolveProviderRequest applies GitHub normalization when CLAUDE_CODE_USE_GITHUB=1', () => { + process.env.CLAUDE_CODE_USE_GITHUB = '1' + const r = resolveProviderRequest({ model: 'github:gpt-4o' }) + expect(r.resolvedModel).toBe('gpt-4o') + expect(r.transport).toBe('chat_completions') +}) + +test('resolveProviderRequest leaves model unchanged without GitHub flag', () => { + delete process.env.CLAUDE_CODE_USE_GITHUB + const r = resolveProviderRequest({ model: 'github:gpt-4o' }) + expect(r.resolvedModel).toBe('github:gpt-4o') +}) diff --git a/src/services/api/providerConfig.ts b/src/services/api/providerConfig.ts index b197d785..bbbc2cb9 100644 --- a/src/services/api/providerConfig.ts +++ b/src/services/api/providerConfig.ts @@ -2,8 +2,12 @@ import { existsSync, readFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' +import { isEnvTruthy } from '../../utils/envUtils.js' + export const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1' export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex' +/** Default GitHub Models API model when user selects copilot / github:copilot */ +export const DEFAULT_GITHUB_MODELS_API_MODEL = 'openai/gpt-4.1' const CODEX_ALIAS_MODELS: Record< string, @@ -171,6 +175,20 @@ export function isCodexBaseUrl(baseUrl: string | undefined): boolean { } } +/** + * Normalize user model string for GitHub Models inference (models.github.ai). + * Mirrors runtime devsper `github._normalize_model_id`. + */ +export function normalizeGithubModelsApiModel(requestedModel: string): string { + const noQuery = requestedModel.split('?', 1)[0] ?? requestedModel + const segment = + noQuery.includes(':') ? noQuery.split(':', 2)[1]!.trim() : noQuery.trim() + if (!segment || segment.toLowerCase() === 'copilot') { + return DEFAULT_GITHUB_MODELS_API_MODEL + } + return segment +} + export function resolveProviderRequest(options?: { model?: string baseUrl?: string @@ -192,10 +210,16 @@ export function resolveProviderRequest(options?: { ? 'codex_responses' : 'chat_completions' + const resolvedModel = + transport === 'chat_completions' && + isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) + ? normalizeGithubModelsApiModel(requestedModel) + : descriptor.baseModel + return { transport, requestedModel, - resolvedModel: descriptor.baseModel, + resolvedModel, baseUrl: (rawBaseUrl ?? (transport === 'codex_responses' diff --git a/src/services/github/deviceFlow.test.ts b/src/services/github/deviceFlow.test.ts new file mode 100644 index 00000000..4b7ce584 --- /dev/null +++ b/src/services/github/deviceFlow.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' + +import { + GitHubDeviceFlowError, + pollAccessToken, + requestDeviceCode, +} from './deviceFlow.js' + +describe('requestDeviceCode', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('parses successful device code response', async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + device_code: 'abc', + user_code: 'ABCD-1234', + verification_uri: 'https://github.com/login/device', + expires_in: 600, + interval: 5, + }), + { status: 200 }, + ), + ), + ) + + const r = await requestDeviceCode({ + clientId: 'test-client', + fetchImpl: globalThis.fetch, + }) + expect(r.device_code).toBe('abc') + expect(r.user_code).toBe('ABCD-1234') + expect(r.verification_uri).toBe('https://github.com/login/device') + expect(r.expires_in).toBe(600) + expect(r.interval).toBe(5) + }) + + test('throws on HTTP error', async () => { + globalThis.fetch = mock(() => + Promise.resolve(new Response('bad', { status: 500 })), + ) + await expect( + requestDeviceCode({ clientId: 'x', fetchImpl: globalThis.fetch }), + ).rejects.toThrow(GitHubDeviceFlowError) + }) +}) + +describe('pollAccessToken', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('returns token when GitHub responds with access_token immediately', async () => { + let calls = 0 + globalThis.fetch = mock(() => { + calls++ + return Promise.resolve( + new Response(JSON.stringify({ access_token: 'tok-xyz' }), { + status: 200, + }), + ) + }) + + const token = await pollAccessToken('dev-code', { + clientId: 'cid', + fetchImpl: globalThis.fetch, + }) + expect(token).toBe('tok-xyz') + expect(calls).toBe(1) + }) + + test('throws on access_denied', async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ error: 'access_denied' }), { + status: 200, + }), + ), + ) + await expect( + pollAccessToken('dc', { + clientId: 'c', + fetchImpl: globalThis.fetch, + }), + ).rejects.toThrow(/denied/) + }) +}) diff --git a/src/services/github/deviceFlow.ts b/src/services/github/deviceFlow.ts new file mode 100644 index 00000000..0e207b7f --- /dev/null +++ b/src/services/github/deviceFlow.ts @@ -0,0 +1,174 @@ +/** + * GitHub OAuth device flow for CLI login (https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow). + */ + +import { execFileNoThrow } from '../../utils/execFileNoThrow.js' + +export const DEFAULT_GITHUB_DEVICE_FLOW_CLIENT_ID = 'Ov23liXjWSSui6QIahPl' + +export const GITHUB_DEVICE_CODE_URL = 'https://github.com/login/device/code' +export const GITHUB_DEVICE_ACCESS_TOKEN_URL = + 'https://github.com/login/oauth/access_token' + +/** Match runtime devsper github_oauth DEFAULT_SCOPE */ +export const DEFAULT_GITHUB_DEVICE_SCOPE = 'read:user' + +export class GitHubDeviceFlowError extends Error { + constructor(message: string) { + super(message) + this.name = 'GitHubDeviceFlowError' + } +} + +export type DeviceCodeResult = { + device_code: string + user_code: string + verification_uri: string + expires_in: number + interval: number +} + +export function getGithubDeviceFlowClientId(): string { + return ( + process.env.GITHUB_DEVICE_FLOW_CLIENT_ID?.trim() || + DEFAULT_GITHUB_DEVICE_FLOW_CLIENT_ID + ) +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +export async function requestDeviceCode(options?: { + clientId?: string + scope?: string + fetchImpl?: typeof fetch +}): Promise { + const clientId = options?.clientId ?? getGithubDeviceFlowClientId() + if (!clientId) { + throw new GitHubDeviceFlowError( + 'No OAuth client ID: set GITHUB_DEVICE_FLOW_CLIENT_ID or paste a PAT instead.', + ) + } + const fetchFn = options?.fetchImpl ?? fetch + const res = await fetchFn(GITHUB_DEVICE_CODE_URL, { + method: 'POST', + headers: { Accept: 'application/json' }, + body: new URLSearchParams({ + client_id: clientId, + scope: options?.scope ?? DEFAULT_GITHUB_DEVICE_SCOPE, + }), + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new GitHubDeviceFlowError( + `Device code request failed: ${res.status} ${text}`, + ) + } + const data = (await res.json()) as Record + const device_code = data.device_code + const user_code = data.user_code + const verification_uri = data.verification_uri + if ( + typeof device_code !== 'string' || + typeof user_code !== 'string' || + typeof verification_uri !== 'string' + ) { + throw new GitHubDeviceFlowError('Malformed device code response from GitHub') + } + return { + device_code, + user_code, + verification_uri, + expires_in: typeof data.expires_in === 'number' ? data.expires_in : 900, + interval: typeof data.interval === 'number' ? data.interval : 5, + } +} + +export type PollOptions = { + clientId?: string + initialInterval?: number + timeoutSeconds?: number + fetchImpl?: typeof fetch +} + +export async function pollAccessToken( + deviceCode: string, + options?: PollOptions, +): Promise { + const clientId = options?.clientId ?? getGithubDeviceFlowClientId() + if (!clientId) { + throw new GitHubDeviceFlowError('client_id required for polling') + } + let interval = Math.max(1, options?.initialInterval ?? 5) + const timeoutSeconds = options?.timeoutSeconds ?? 900 + const fetchFn = options?.fetchImpl ?? fetch + const start = Date.now() + + while ((Date.now() - start) / 1000 < timeoutSeconds) { + const res = await fetchFn(GITHUB_DEVICE_ACCESS_TOKEN_URL, { + method: 'POST', + headers: { Accept: 'application/json' }, + body: new URLSearchParams({ + client_id: clientId, + device_code: deviceCode, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }), + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new GitHubDeviceFlowError( + `Token request failed: ${res.status} ${text}`, + ) + } + const data = (await res.json()) as Record + const err = data.error as string | undefined + if (err == null) { + const token = data.access_token + if (typeof token === 'string' && token) { + return token + } + throw new GitHubDeviceFlowError('No access_token in response') + } + if (err === 'authorization_pending') { + await sleep(interval * 1000) + continue + } + if (err === 'slow_down') { + interval = + typeof data.interval === 'number' ? data.interval : interval + 5 + await sleep(interval * 1000) + continue + } + if (err === 'expired_token') { + throw new GitHubDeviceFlowError( + 'Device code expired. Start the login flow again.', + ) + } + if (err === 'access_denied') { + throw new GitHubDeviceFlowError('Authorization was denied or cancelled.') + } + throw new GitHubDeviceFlowError(`GitHub OAuth error: ${err}`) + } + throw new GitHubDeviceFlowError('Timed out waiting for authorization.') +} + +/** + * Best-effort open browser / OS handler for the verification URL. + */ +export async function openVerificationUri(uri: string): Promise { + try { + if (process.platform === 'darwin') { + await execFileNoThrow('open', [uri], { useCwd: false, timeout: 5000 }) + } else if (process.platform === 'win32') { + await execFileNoThrow('cmd', ['/c', 'start', '', uri], { + useCwd: false, + timeout: 5000, + }) + } else { + await execFileNoThrow('xdg-open', [uri], { useCwd: false, timeout: 5000 }) + } + } catch { + // User can open the URL manually + } +} diff --git a/src/utils/auth.ts b/src/utils/auth.ts index b1cd024e..37d1ca1f 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -117,7 +117,8 @@ export function isAnthropicAuthEnabled(): boolean { isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) || isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) || - isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) + isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) || + isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) // Check if user has configured an external API key source // This allows externally-provided API keys to work (without requiring proxy configuration) @@ -1731,14 +1732,15 @@ export function getSubscriptionName(): string { } } -/** Check if using third-party services (Bedrock or Vertex or Foundry or OpenAI-compatible or Gemini) */ +/** Check if using third-party services (Bedrock or Vertex or Foundry or OpenAI-compatible or Gemini or GitHub Models) */ export function isUsing3PServices(): boolean { return !!( isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) || isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) || - isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) + isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) || + isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) ) } diff --git a/src/utils/context.ts b/src/utils/context.ts index f13b2b0a..7dba02b7 100644 --- a/src/utils/context.ts +++ b/src/utils/context.ts @@ -77,7 +77,9 @@ export function getContextWindowForModel( process.env.CLAUDE_CODE_USE_OPENAI === '1' || process.env.CLAUDE_CODE_USE_OPENAI === 'true' || process.env.CLAUDE_CODE_USE_GEMINI === '1' || - process.env.CLAUDE_CODE_USE_GEMINI === 'true' + process.env.CLAUDE_CODE_USE_GEMINI === 'true' || + process.env.CLAUDE_CODE_USE_GITHUB === '1' || + process.env.CLAUDE_CODE_USE_GITHUB === 'true' ) { const openaiWindow = getOpenAIContextWindow(model) if (openaiWindow !== undefined) { @@ -181,7 +183,9 @@ export function getModelMaxOutputTokens(model: string): { process.env.CLAUDE_CODE_USE_OPENAI === '1' || process.env.CLAUDE_CODE_USE_OPENAI === 'true' || process.env.CLAUDE_CODE_USE_GEMINI === '1' || - process.env.CLAUDE_CODE_USE_GEMINI === 'true' + process.env.CLAUDE_CODE_USE_GEMINI === 'true' || + process.env.CLAUDE_CODE_USE_GITHUB === '1' || + process.env.CLAUDE_CODE_USE_GITHUB === 'true' ) { const openaiMax = getOpenAIMaxOutputTokens(model) if (openaiMax !== undefined) { diff --git a/src/utils/githubModelsCredentials.hydrate.test.ts b/src/utils/githubModelsCredentials.hydrate.test.ts new file mode 100644 index 00000000..23b0a5ee --- /dev/null +++ b/src/utils/githubModelsCredentials.hydrate.test.ts @@ -0,0 +1,66 @@ +/** + * Hydrate tests live in a separate file with no static import of + * githubModelsCredentials so Bun's mock.module can replace secureStorage + * before that module is first loaded. + */ +import { afterEach, describe, expect, mock, test } from 'bun:test' + +describe('hydrateGithubModelsTokenFromSecureStorage', () => { + const orig = { + CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB, + GITHUB_TOKEN: process.env.GITHUB_TOKEN, + GH_TOKEN: process.env.GH_TOKEN, + CLAUDE_CODE_SIMPLE: process.env.CLAUDE_CODE_SIMPLE, + } + + afterEach(() => { + mock.restore() + for (const [k, v] of Object.entries(orig)) { + if (v === undefined) { + delete process.env[k as keyof typeof orig] + } else { + process.env[k as keyof typeof orig] = v + } + } + }) + + test('sets GITHUB_TOKEN from secure storage when USE_GITHUB and env token empty', async () => { + process.env.CLAUDE_CODE_USE_GITHUB = '1' + delete process.env.GITHUB_TOKEN + delete process.env.GH_TOKEN + delete process.env.CLAUDE_CODE_SIMPLE + + mock.module('./secureStorage/index.js', () => ({ + getSecureStorage: () => ({ + read: () => ({ + githubModels: { accessToken: 'stored-secret' }, + }), + }), + })) + + const { hydrateGithubModelsTokenFromSecureStorage } = await import( + './githubModelsCredentials.js' + ) + hydrateGithubModelsTokenFromSecureStorage() + expect(process.env.GITHUB_TOKEN).toBe('stored-secret') + }) + + test('does not override existing GITHUB_TOKEN', async () => { + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.GITHUB_TOKEN = 'already' + + mock.module('./secureStorage/index.js', () => ({ + getSecureStorage: () => ({ + read: () => ({ + githubModels: { accessToken: 'stored-secret' }, + }), + }), + })) + + const { hydrateGithubModelsTokenFromSecureStorage } = await import( + './githubModelsCredentials.js' + ) + hydrateGithubModelsTokenFromSecureStorage() + expect(process.env.GITHUB_TOKEN).toBe('already') + }) +}) diff --git a/src/utils/githubModelsCredentials.test.ts b/src/utils/githubModelsCredentials.test.ts new file mode 100644 index 00000000..81c3cdcc --- /dev/null +++ b/src/utils/githubModelsCredentials.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from 'bun:test' + +import { + clearGithubModelsToken, + readGithubModelsToken, + saveGithubModelsToken, +} from './githubModelsCredentials.js' + +describe('readGithubModelsToken', () => { + test('returns undefined in bare mode', () => { + const prev = process.env.CLAUDE_CODE_SIMPLE + process.env.CLAUDE_CODE_SIMPLE = '1' + expect(readGithubModelsToken()).toBeUndefined() + if (prev === undefined) { + delete process.env.CLAUDE_CODE_SIMPLE + } else { + process.env.CLAUDE_CODE_SIMPLE = prev + } + }) +}) + +describe('saveGithubModelsToken / clearGithubModelsToken', () => { + test('save returns failure in bare mode', () => { + const prev = process.env.CLAUDE_CODE_SIMPLE + process.env.CLAUDE_CODE_SIMPLE = '1' + const r = saveGithubModelsToken('abc') + expect(r.success).toBe(false) + expect(r.warning).toContain('Bare mode') + if (prev === undefined) { + delete process.env.CLAUDE_CODE_SIMPLE + } else { + process.env.CLAUDE_CODE_SIMPLE = prev + } + }) + + test('clear succeeds in bare mode', () => { + const prev = process.env.CLAUDE_CODE_SIMPLE + process.env.CLAUDE_CODE_SIMPLE = '1' + expect(clearGithubModelsToken().success).toBe(true) + if (prev === undefined) { + delete process.env.CLAUDE_CODE_SIMPLE + } else { + process.env.CLAUDE_CODE_SIMPLE = prev + } + }) +}) + diff --git a/src/utils/githubModelsCredentials.ts b/src/utils/githubModelsCredentials.ts new file mode 100644 index 00000000..83d5934c --- /dev/null +++ b/src/utils/githubModelsCredentials.ts @@ -0,0 +1,73 @@ +import { isBareMode, isEnvTruthy } from './envUtils.js' +import { getSecureStorage } from './secureStorage/index.js' + +/** JSON key in the shared OpenClaude secure storage blob. */ +export const GITHUB_MODELS_STORAGE_KEY = 'githubModels' as const + +export type GithubModelsCredentialBlob = { + accessToken: string +} + +export function readGithubModelsToken(): string | undefined { + if (isBareMode()) return undefined + try { + const data = getSecureStorage().read() as + | ({ githubModels?: GithubModelsCredentialBlob } & Record) + | null + const t = data?.githubModels?.accessToken?.trim() + return t || undefined + } catch { + return undefined + } +} + +/** + * If GitHub Models mode is on and no token is in the environment, copy the + * stored token into process.env so the OpenAI shim and validation see it. + */ +export function hydrateGithubModelsTokenFromSecureStorage(): void { + if (!isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)) { + return + } + if (process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim()) { + return + } + if (isBareMode()) { + return + } + const t = readGithubModelsToken() + if (t) { + process.env.GITHUB_TOKEN = t + } +} + +export function saveGithubModelsToken(token: string): { + success: boolean + warning?: string +} { + if (isBareMode()) { + return { success: false, warning: 'Bare mode: secure storage is disabled.' } + } + const trimmed = token.trim() + if (!trimmed) { + return { success: false, warning: 'Token is empty.' } + } + const secureStorage = getSecureStorage() + const prev = secureStorage.read() || {} + const merged = { + ...(prev as Record), + [GITHUB_MODELS_STORAGE_KEY]: { accessToken: trimmed }, + } + return secureStorage.update(merged as typeof prev) +} + +export function clearGithubModelsToken(): { success: boolean; warning?: string } { + if (isBareMode()) { + return { success: true } + } + const secureStorage = getSecureStorage() + const prev = secureStorage.read() || {} + const next = { ...(prev as Record) } + delete next[GITHUB_MODELS_STORAGE_KEY] + return secureStorage.update(next as typeof prev) +} diff --git a/src/utils/managedEnvConstants.ts b/src/utils/managedEnvConstants.ts index 12c56565..86b2da29 100644 --- a/src/utils/managedEnvConstants.ts +++ b/src/utils/managedEnvConstants.ts @@ -18,6 +18,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([ 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_GITHUB', // Endpoint config (base URLs, project/resource identifiers) 'ANTHROPIC_BASE_URL', 'ANTHROPIC_BEDROCK_BASE_URL', @@ -147,6 +148,7 @@ export const SAFE_ENV_VARS = new Set([ 'CLAUDE_CODE_SUBAGENT_MODEL', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_GITHUB', 'CLAUDE_CODE_USE_VERTEX', 'DISABLE_AUTOUPDATER', 'DISABLE_BUG_COMMAND', diff --git a/src/utils/model/providers.test.ts b/src/utils/model/providers.test.ts index 1da3d596..ea03454f 100644 --- a/src/utils/model/providers.test.ts +++ b/src/utils/model/providers.test.ts @@ -7,6 +7,7 @@ import { const originalEnv = { CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI, + CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB, CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI, CLAUDE_CODE_USE_BEDROCK: process.env.CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX: process.env.CLAUDE_CODE_USE_VERTEX, @@ -15,6 +16,7 @@ const originalEnv = { afterEach(() => { process.env.CLAUDE_CODE_USE_GEMINI = originalEnv.CLAUDE_CODE_USE_GEMINI + process.env.CLAUDE_CODE_USE_GITHUB = originalEnv.CLAUDE_CODE_USE_GITHUB process.env.CLAUDE_CODE_USE_OPENAI = originalEnv.CLAUDE_CODE_USE_OPENAI process.env.CLAUDE_CODE_USE_BEDROCK = originalEnv.CLAUDE_CODE_USE_BEDROCK process.env.CLAUDE_CODE_USE_VERTEX = originalEnv.CLAUDE_CODE_USE_VERTEX @@ -23,6 +25,7 @@ afterEach(() => { function clearProviderEnv(): void { delete process.env.CLAUDE_CODE_USE_GEMINI + delete process.env.CLAUDE_CODE_USE_GITHUB delete process.env.CLAUDE_CODE_USE_OPENAI delete process.env.CLAUDE_CODE_USE_BEDROCK delete process.env.CLAUDE_CODE_USE_VERTEX @@ -38,6 +41,7 @@ test('first-party provider keeps Anthropic account setup flow enabled', () => { test.each([ ['CLAUDE_CODE_USE_OPENAI', 'openai'], + ['CLAUDE_CODE_USE_GITHUB', 'github'], ['CLAUDE_CODE_USE_GEMINI', 'gemini'], ['CLAUDE_CODE_USE_BEDROCK', 'bedrock'], ['CLAUDE_CODE_USE_VERTEX', 'vertex'], @@ -52,3 +56,11 @@ test.each([ expect(usesAnthropicAccountFlow()).toBe(false) }, ) + +test('GEMINI takes precedence over GitHub when both are set', () => { + clearProviderEnv() + process.env.CLAUDE_CODE_USE_GEMINI = '1' + process.env.CLAUDE_CODE_USE_GITHUB = '1' + + expect(getAPIProvider()).toBe('gemini') +}) diff --git a/src/utils/model/providers.ts b/src/utils/model/providers.ts index 847b5fc3..30a1f1c9 100644 --- a/src/utils/model/providers.ts +++ b/src/utils/model/providers.ts @@ -1,20 +1,29 @@ import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/index.js' import { isEnvTruthy } from '../envUtils.js' -export type APIProvider = 'firstParty' | 'bedrock' | 'vertex' | 'foundry' | 'openai' | 'gemini' +export type APIProvider = + | 'firstParty' + | 'bedrock' + | 'vertex' + | 'foundry' + | 'openai' + | 'gemini' + | 'github' export function getAPIProvider(): APIProvider { return isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) ? 'gemini' - : isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) - ? 'openai' - : isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) - ? 'bedrock' - : isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) - ? 'vertex' - : isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) - ? 'foundry' - : 'firstParty' + : isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) + ? 'github' + : isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) + ? 'openai' + : isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) + ? 'bedrock' + : isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) + ? 'vertex' + : isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) + ? 'foundry' + : 'firstParty' } export function usesAnthropicAccountFlow(): boolean { diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index 866c19c5..e88662ac 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -190,6 +190,7 @@ export async function buildLaunchEnv(options: { } delete env.CLAUDE_CODE_USE_OPENAI + delete env.CLAUDE_CODE_USE_GITHUB env.GEMINI_MODEL = processEnv.GEMINI_MODEL || @@ -224,6 +225,7 @@ export async function buildLaunchEnv(options: { } delete env.CLAUDE_CODE_USE_GEMINI + delete env.CLAUDE_CODE_USE_GITHUB delete env.GEMINI_API_KEY delete env.GEMINI_MODEL delete env.GEMINI_BASE_URL diff --git a/src/utils/swarm/spawnUtils.ts b/src/utils/swarm/spawnUtils.ts index cfccdf5a..037d273d 100644 --- a/src/utils/swarm/spawnUtils.ts +++ b/src/utils/swarm/spawnUtils.ts @@ -99,6 +99,18 @@ const TEAMMATE_ENV_VARS = [ 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_GITHUB', + 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_USE_OPENAI', + 'GITHUB_TOKEN', + 'GH_TOKEN', + 'OPENAI_API_KEY', + 'OPENAI_BASE_URL', + 'OPENAI_MODEL', + 'GEMINI_API_KEY', + 'GEMINI_BASE_URL', + 'GEMINI_MODEL', + 'GOOGLE_API_KEY', // Custom API endpoint 'ANTHROPIC_BASE_URL', // Config directory override From 2619401d34a6547ddfc7d59d3f245b72d75a10c4 Mon Sep 17 00:00:00 2001 From: Rithul Kamesh Date: Thu, 2 Apr 2026 11:26:27 +0530 Subject: [PATCH 15/35] Remove github-models-pr-draft.md --- docs/github-models-pr-draft.md | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 docs/github-models-pr-draft.md diff --git a/docs/github-models-pr-draft.md b/docs/github-models-pr-draft.md deleted file mode 100644 index 21fa7fa8..00000000 --- a/docs/github-models-pr-draft.md +++ /dev/null @@ -1,24 +0,0 @@ -# GitHub Models + onboard — PR draft (paste into GitHub) - -**Title:** `feat: GitHub Models provider + interactive onboard (keychain-backed)` - -**Body:** - -## Summary - -- Adds GitHub Models (`models.github.ai`) as an OpenAI-compatible backend via `CLAUDE_CODE_USE_GITHUB` (see existing shim changes). -- Adds `/onboard-github`: interactive Ink flow for GitHub Device Login or PAT, stores token in OS-backed secure storage (macOS Keychain when available, else `~/.claude/.credentials.json`), and writes user settings `env` so no `export GITHUB_TOKEN` is required. -- Applies user settings before provider env validation and hydrates `GITHUB_TOKEN` from secure storage when the GitHub provider flag is on. - -## How to test - -1. Run `openclaude` and execute `/onboard-github` (or launch via command registration). -2. Complete device flow or paste a PAT with Models access. -3. Restart CLI; confirm `CLAUDE_CODE_USE_GITHUB=1` in `~/.claude/settings.json` (or merged file) and that inference works without exporting `GITHUB_TOKEN`. -4. `bun test` (new suites) + `bun run build`. - -## Notes / follow-ups - -- Device flow OAuth app client ID is configurable via `GITHUB_DEVICE_FLOW_CLIENT_ID`; verify scope list against current GitHub Models documentation. -- `/logout` currently deletes all secure storage; GitHub token is cleared too — document or narrow in a follow-up. -- Linux: secure storage is plaintext with chmod 600 today; libsecret is still TODO in `secureStorage`. From 93bc50f8cd5b68d725b9832e12c961b25235331e Mon Sep 17 00:00:00 2001 From: gnanam1990 Date: Thu, 2 Apr 2026 11:37:26 +0530 Subject: [PATCH 16/35] docs: replace stale OpenRouter Gemini example Update the OpenRouter Gemini README example to a model ID that works in current OpenRouter validation, and note that model availability can change over time. --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5c94ed80..7784083a 100644 --- a/README.md +++ b/README.md @@ -118,9 +118,13 @@ export OPENAI_MODEL=deepseek-chat export CLAUDE_CODE_USE_OPENAI=1 export OPENAI_API_KEY=sk-or-... export OPENAI_BASE_URL=https://openrouter.ai/api/v1 -export OPENAI_MODEL=google/gemini-2.0-flash +export OPENAI_MODEL=google/gemini-2.0-flash-001 ``` +OpenRouter model availability changes over time. If a model stops working, +pick another currently available OpenRouter model before assuming the +OpenAI-compatible setup is broken. + ### Ollama (local, free) ```bash From d156aed32da370b8feca4a273096b3725c16ec79 Mon Sep 17 00:00:00 2001 From: Aarondio <31071454+Aarondio@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:14:52 +0100 Subject: [PATCH 17/35] fix(shim): implement tolerant bracket balancer for truncated tool JSON --- src/services/api/openaiShim.ts | 36 +++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 9a500490..4fa6ea31 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -342,7 +342,7 @@ async function* openaiStreamToAnthropic( ): AsyncGenerator { const messageId = makeMessageId() let contentBlockIndex = 0 - const activeToolCalls = new Map() + const activeToolCalls = new Map() let hasEmittedContentStart = false let lastStopReason: 'tool_use' | 'max_tokens' | 'end_turn' | null = null let hasEmittedFinalUsage = false @@ -436,6 +436,7 @@ async function* openaiStreamToAnthropic( id: tc.id, name: tc.function.name, index: toolBlockIndex, + jsonBuffer: tc.function.arguments ?? '', }) yield { @@ -466,6 +467,9 @@ async function* openaiStreamToAnthropic( // Continuation of existing tool call const active = activeToolCalls.get(tc.index) if (active) { + if (tc.function.arguments) { + active.jsonBuffer += tc.function.arguments + } yield { type: 'content_block_delta', index: active.index, @@ -493,6 +497,36 @@ async function* openaiStreamToAnthropic( } // Close active tool calls for (const [, tc] of activeToolCalls) { + let suffixToAdd = '' + if (tc.jsonBuffer) { + try { + JSON.parse(tc.jsonBuffer) + } catch { + const str = tc.jsonBuffer.trimEnd() + const combinations = [ + '}', '"}', ']}', '"]}', '}}', '"}}', ']}}', '"]}}', '"]}]}', '}]}' + ] + for (const combo of combinations) { + try { + JSON.parse(str + combo) + suffixToAdd = combo + break + } catch {} + } + } + } + + if (suffixToAdd) { + yield { + type: 'content_block_delta', + index: tc.index, + delta: { + type: 'input_json_delta', + partial_json: suffixToAdd, + }, + } + } + yield { type: 'content_block_stop', index: tc.index } } From f07f11b7b6134f07b74024afb299f1f2a258623a Mon Sep 17 00:00:00 2001 From: Rithul Kamesh Date: Thu, 2 Apr 2026 12:53:56 +0530 Subject: [PATCH 18/35] fix: use bun test for provider-recommendation script to resolve module errors --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5f5351b8..0bfa42c6 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "dev:fast": "bun run profile:fast && bun run dev:ollama:fast", "dev:code": "bun run profile:code && bun run dev:profile", "start": "node dist/cli.mjs", - "test:provider-recommendation": "node --test --experimental-strip-types src/utils/providerRecommendation.test.ts src/utils/providerProfile.test.ts", + "test:provider-recommendation": "bun test src/utils/providerRecommendation.test.ts src/utils/providerProfile.test.ts", "typecheck": "tsc --noEmit", "smoke": "bun run build && node dist/cli.mjs --version", "test:provider": "bun test src/services/api/*.test.ts src/utils/context.test.ts", From 577e654ae74e868fb7095d5a0804e6d9b42b4e32 Mon Sep 17 00:00:00 2001 From: Misha Skvortsov Date: Wed, 1 Apr 2026 21:42:43 +0300 Subject: [PATCH 19/35] feat: add support for Atomic Chat provider - Introduced a new provider profile for Atomic Chat, allowing it to be used alongside existing providers. - Updated `package.json` to include a new development script for launching Atomic Chat. - Modified `smart_router.py` to recognize Atomic Chat as a local provider that does not require an API key. - Enhanced provider discovery and launch scripts to handle Atomic Chat, including model listing and connection checks. - Added tests to ensure proper environment setup and behavior for Atomic Chat profiles. This update expands the functionality of the application to support local LLMs via Atomic Chat, improving versatility for users. --- atomic_chat_provider.py | 146 ++++++++++++++++++++++++++++++ package.json | 1 + scripts/provider-bootstrap.ts | 21 ++++- scripts/provider-discovery.ts | 56 ++++++++++++ scripts/provider-launch.ts | 36 +++++++- smart_router.py | 13 ++- src/utils/providerProfile.test.ts | 70 ++++++++++++++ src/utils/providerProfile.ts | 37 +++++++- test_atomic_chat_provider.py | 130 ++++++++++++++++++++++++++ 9 files changed, 503 insertions(+), 7 deletions(-) create mode 100644 atomic_chat_provider.py create mode 100644 test_atomic_chat_provider.py diff --git a/atomic_chat_provider.py b/atomic_chat_provider.py new file mode 100644 index 00000000..bf55155f --- /dev/null +++ b/atomic_chat_provider.py @@ -0,0 +1,146 @@ +""" +atomic_chat_provider.py +----------------------- +Adds native Atomic Chat support to openclaude. +Lets Claude Code route requests to any locally-running model via +Atomic Chat (Apple Silicon only) at 127.0.0.1:1337. + +Atomic Chat exposes an OpenAI-compatible API, so messages are forwarded +directly without translation. + +Usage (.env): + PREFERRED_PROVIDER=atomic-chat + ATOMIC_CHAT_BASE_URL=http://127.0.0.1:1337 +""" + +import httpx +import json +import logging +import os +from typing import AsyncIterator + +logger = logging.getLogger(__name__) +ATOMIC_CHAT_BASE_URL = os.getenv("ATOMIC_CHAT_BASE_URL", "http://127.0.0.1:1337") + + +def _api_url(path: str) -> str: + return f"{ATOMIC_CHAT_BASE_URL}/v1{path}" + + +async def check_atomic_chat_running() -> bool: + try: + async with httpx.AsyncClient(timeout=3.0) as client: + resp = await client.get(_api_url("/models")) + return resp.status_code == 200 + except Exception: + return False + + +async def list_atomic_chat_models() -> list[str]: + try: + async with httpx.AsyncClient(timeout=5.0) as client: + resp = await client.get(_api_url("/models")) + resp.raise_for_status() + data = resp.json() + return [m["id"] for m in data.get("data", [])] + except Exception as e: + logger.warning(f"Could not list Atomic Chat models: {e}") + return [] + + +async def atomic_chat( + model: str, + messages: list[dict], + system: str | None = None, + max_tokens: int = 4096, + temperature: float = 1.0, +) -> dict: + chat_messages = list(messages) + if system: + chat_messages.insert(0, {"role": "system", "content": system}) + + payload = { + "model": model, + "messages": chat_messages, + "max_tokens": max_tokens, + "temperature": temperature, + "stream": False, + } + + async with httpx.AsyncClient(timeout=120.0) as client: + resp = await client.post(_api_url("/chat/completions"), json=payload) + resp.raise_for_status() + data = resp.json() + + choice = data.get("choices", [{}])[0] + assistant_text = choice.get("message", {}).get("content", "") + usage = data.get("usage", {}) + + return { + "id": data.get("id", "msg_atomic_chat"), + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": assistant_text}], + "model": model, + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": { + "input_tokens": usage.get("prompt_tokens", 0), + "output_tokens": usage.get("completion_tokens", 0), + }, + } + + +async def atomic_chat_stream( + model: str, + messages: list[dict], + system: str | None = None, + max_tokens: int = 4096, + temperature: float = 1.0, +) -> AsyncIterator[str]: + chat_messages = list(messages) + if system: + chat_messages.insert(0, {"role": "system", "content": system}) + + payload = { + "model": model, + "messages": chat_messages, + "max_tokens": max_tokens, + "temperature": temperature, + "stream": True, + } + + yield "event: message_start\n" + yield f'data: {json.dumps({"type": "message_start", "message": {"id": "msg_atomic_chat_stream", "type": "message", "role": "assistant", "content": [], "model": model, "stop_reason": None, "usage": {"input_tokens": 0, "output_tokens": 0}}})}\n\n' + yield "event: content_block_start\n" + yield f'data: {json.dumps({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}})}\n\n' + + async with httpx.AsyncClient(timeout=120.0) as client: + async with client.stream("POST", _api_url("/chat/completions"), json=payload) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if not line or not line.startswith("data: "): + continue + raw = line[len("data: "):] + if raw.strip() == "[DONE]": + break + try: + chunk = json.loads(raw) + delta = chunk.get("choices", [{}])[0].get("delta", {}) + delta_text = delta.get("content", "") + if delta_text: + yield "event: content_block_delta\n" + yield f'data: {json.dumps({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": delta_text}})}\n\n' + + finish_reason = chunk.get("choices", [{}])[0].get("finish_reason") + if finish_reason: + usage = chunk.get("usage", {}) + yield "event: content_block_stop\n" + yield f'data: {json.dumps({"type": "content_block_stop", "index": 0})}\n\n' + yield "event: message_delta\n" + yield f'data: {json.dumps({"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": usage.get("completion_tokens", 0)}})}\n\n' + yield "event: message_stop\n" + yield f'data: {json.dumps({"type": "message_stop"})}\n\n' + break + except json.JSONDecodeError: + continue diff --git a/package.json b/package.json index 47052352..03abde72 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dev:gemini": "bun run scripts/provider-launch.ts gemini", "dev:ollama": "bun run scripts/provider-launch.ts ollama", "dev:ollama:fast": "bun run scripts/provider-launch.ts ollama --fast --bare", + "dev:atomic-chat": "bun run scripts/provider-launch.ts atomic-chat", "profile:init": "bun run scripts/provider-bootstrap.ts", "profile:recommend": "bun run scripts/provider-recommend.ts", "profile:auto": "bun run scripts/provider-recommend.ts --apply", diff --git a/scripts/provider-bootstrap.ts b/scripts/provider-bootstrap.ts index 82ebbbb6..f39e3e50 100644 --- a/scripts/provider-bootstrap.ts +++ b/scripts/provider-bootstrap.ts @@ -10,6 +10,7 @@ import { recommendOllamaModel, } from '../src/utils/providerRecommendation.ts' import { + buildAtomicChatProfileEnv, buildCodexProfileEnv, buildGeminiProfileEnv, buildOllamaProfileEnv, @@ -20,8 +21,11 @@ import { type ProviderProfile, } from '../src/utils/providerProfile.ts' import { + getAtomicChatChatBaseUrl, getOllamaChatBaseUrl, + hasLocalAtomicChat, hasLocalOllama, + listAtomicChatModels, listOllamaModels, } from './provider-discovery.ts' @@ -34,7 +38,7 @@ function parseArg(name: string): string | null { function parseProviderArg(): ProviderProfile | 'auto' { const p = parseArg('--provider')?.toLowerCase() - if (p === 'openai' || p === 'ollama' || p === 'codex' || p === 'gemini') return p + if (p === 'openai' || p === 'ollama' || p === 'codex' || p === 'gemini' || p === 'atomic-chat') return p return 'auto' } @@ -102,6 +106,21 @@ async function main(): Promise { getOllamaChatBaseUrl, }, ) + } else if (selected === 'atomic-chat') { + const model = argModel || (await listAtomicChatModels(argBaseUrl || undefined))[0] + if (!model) { + if (!(await hasLocalAtomicChat(argBaseUrl || undefined))) { + console.error('Atomic Chat is not running (could not connect to 127.0.0.1:1337).\n Download from https://atomic.chat/ and launch the application.') + } else { + console.error('Atomic Chat is running but no model is loaded. Open Atomic Chat and download or start a model first.') + } + process.exit(1) + } + + env = buildAtomicChatProfileEnv(model, { + baseUrl: argBaseUrl, + getAtomicChatChatBaseUrl, + }) } else if (selected === 'codex') { const builtEnv = buildCodexProfileEnv({ model: argModel, diff --git a/scripts/provider-discovery.ts b/scripts/provider-discovery.ts index 9e3aacda..9c463f2f 100644 --- a/scripts/provider-discovery.ts +++ b/scripts/provider-discovery.ts @@ -1,6 +1,7 @@ import type { OllamaModelDescriptor } from '../src/utils/providerRecommendation.ts' export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434' +export const DEFAULT_ATOMIC_CHAT_BASE_URL = 'http://127.0.0.1:1337' function withTimeoutSignal(timeoutMs: number): { signal: AbortSignal @@ -93,6 +94,61 @@ export async function listOllamaModels( } } +// ── Atomic Chat discovery (Apple Silicon local LLMs at 127.0.0.1:1337) ────── + +export function getAtomicChatApiBaseUrl(baseUrl?: string): string { + const raw = baseUrl || process.env.ATOMIC_CHAT_BASE_URL || DEFAULT_ATOMIC_CHAT_BASE_URL + return trimTrailingSlash(raw) +} + +export function getAtomicChatChatBaseUrl(baseUrl?: string): string { + return `${getAtomicChatApiBaseUrl(baseUrl)}/v1` +} + +export async function hasLocalAtomicChat(baseUrl?: string): Promise { + const { signal, clear } = withTimeoutSignal(1200) + try { + const response = await fetch(`${getAtomicChatChatBaseUrl(baseUrl)}/models`, { + method: 'GET', + signal, + }) + return response.ok + } catch { + return false + } finally { + clear() + } +} + +export async function listAtomicChatModels( + baseUrl?: string, +): Promise { + const { signal, clear } = withTimeoutSignal(5000) + try { + const response = await fetch(`${getAtomicChatChatBaseUrl(baseUrl)}/models`, { + method: 'GET', + signal, + }) + if (!response.ok) { + return [] + } + + const data = await response.json() as { + data?: Array<{ id?: string }> + } + + return (data.data ?? []) + .filter(model => Boolean(model.id)) + .map(model => model.id!) + } catch { + return [] + } finally { + clear() + } +} + +// ── Ollama benchmarking ───────────────────────────────────────────────────── + export async function benchmarkOllamaModel( modelName: string, baseUrl?: string, diff --git a/scripts/provider-launch.ts b/scripts/provider-launch.ts index 2859e9e8..17f11fb8 100644 --- a/scripts/provider-launch.ts +++ b/scripts/provider-launch.ts @@ -16,8 +16,11 @@ import { type ProviderProfile, } from '../src/utils/providerProfile.ts' import { + getAtomicChatChatBaseUrl, getOllamaChatBaseUrl, + hasLocalAtomicChat, hasLocalOllama, + listAtomicChatModels, listOllamaModels, } from './provider-discovery.ts' @@ -48,7 +51,7 @@ function parseLaunchOptions(argv: string[]): LaunchOptions { continue } - if ((lower === 'auto' || lower === 'openai' || lower === 'ollama' || lower === 'codex' || lower === 'gemini') && requestedProfile === 'auto') { + if ((lower === 'auto' || lower === 'openai' || lower === 'ollama' || lower === 'codex' || lower === 'gemini' || lower === 'atomic-chat') && requestedProfile === 'auto') { requestedProfile = lower as ProviderProfile | 'auto' continue } @@ -79,7 +82,7 @@ function loadPersistedProfile(): ProfileFile | null { if (!existsSync(path)) return null try { const parsed = JSON.parse(readFileSync(path, 'utf8')) as ProfileFile - if (parsed.profile === 'openai' || parsed.profile === 'ollama' || parsed.profile === 'codex' || parsed.profile === 'gemini') { + if (parsed.profile === 'openai' || parsed.profile === 'ollama' || parsed.profile === 'codex' || parsed.profile === 'gemini' || parsed.profile === 'atomic-chat') { return parsed } return null @@ -96,6 +99,11 @@ async function resolveOllamaDefaultModel( return recommended?.name ?? null } +async function resolveAtomicChatDefaultModel(): Promise { + const models = await listAtomicChatModels() + return models[0] ?? null +} + function runCommand(command: string, env: NodeJS.ProcessEnv): Promise { return runProcess(command, [], env) } @@ -132,6 +140,10 @@ function printSummary(profile: ProviderProfile, env: NodeJS.ProcessEnv): void { console.log(`OPENAI_BASE_URL=${env.OPENAI_BASE_URL}`) console.log(`OPENAI_MODEL=${env.OPENAI_MODEL}`) console.log(`CODEX_API_KEY_SET=${Boolean(resolveCodexApiCredentials(env).apiKey)}`) + } else if (profile === 'atomic-chat') { + console.log(`OPENAI_BASE_URL=${env.OPENAI_BASE_URL}`) + console.log(`OPENAI_MODEL=${env.OPENAI_MODEL}`) + console.log('OPENAI_API_KEY_SET=false (local provider, no key required)') } else { console.log(`OPENAI_BASE_URL=${env.OPENAI_BASE_URL}`) console.log(`OPENAI_MODEL=${env.OPENAI_MODEL}`) @@ -143,7 +155,7 @@ async function main(): Promise { const options = parseLaunchOptions(process.argv.slice(2)) const requestedProfile = options.requestedProfile if (!requestedProfile) { - console.error('Usage: bun run scripts/provider-launch.ts [openai|ollama|codex|gemini|auto] [--fast] [--goal ] [-- ]') + console.error('Usage: bun run scripts/provider-launch.ts [openai|ollama|codex|gemini|atomic-chat|auto] [--fast] [--goal ] [-- ]') process.exit(1) } @@ -175,12 +187,30 @@ async function main(): Promise { } } + let resolvedAtomicChatModel: string | null = null + if ( + profile === 'atomic-chat' && + (persisted?.profile !== 'atomic-chat' || !persisted?.env?.OPENAI_MODEL) + ) { + if (!(await hasLocalAtomicChat())) { + console.error('Atomic Chat is not running (could not connect to 127.0.0.1:1337).\n Download from https://atomic.chat/ and launch the application.') + process.exit(1) + } + resolvedAtomicChatModel = await resolveAtomicChatDefaultModel() + if (!resolvedAtomicChatModel) { + console.error('Atomic Chat is running but no model is loaded. Open Atomic Chat and download or start a model first.') + process.exit(1) + } + } + const env = await buildLaunchEnv({ profile, persisted, goal: options.goal, getOllamaChatBaseUrl, resolveOllamaDefaultModel: async () => resolvedOllamaModel || 'llama3.1:8b', + getAtomicChatChatBaseUrl, + resolveAtomicChatDefaultModel: async () => resolvedAtomicChatModel, }) if (options.fast) { applyFastFlags(env) diff --git a/smart_router.py b/smart_router.py index 0a54a791..14b90c03 100644 --- a/smart_router.py +++ b/smart_router.py @@ -57,8 +57,8 @@ class Provider: @property def is_configured(self) -> bool: """True if the provider has an API key set.""" - if self.name == "ollama": - return True # Ollama needs no API key + if self.name in ("ollama", "atomic-chat"): + return True # Local providers need no API key return bool(self.api_key) @property @@ -93,6 +93,7 @@ def build_default_providers() -> list[Provider]: big = os.getenv("BIG_MODEL", "gpt-4.1") small = os.getenv("SMALL_MODEL", "gpt-4.1-mini") ollama_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") + atomic_chat_url = os.getenv("ATOMIC_CHAT_BASE_URL", "http://127.0.0.1:1337") return [ Provider( @@ -119,6 +120,14 @@ def build_default_providers() -> list[Provider]: big_model=big if "gemini" not in big and "gpt" not in big else "llama3:8b", small_model=small if "gemini" not in small and "gpt" not in small else "llama3:8b", ), + Provider( + name="atomic-chat", + ping_url=f"{atomic_chat_url}/v1/models", + api_key_env="", + cost_per_1k_tokens=0.0, # free — local (Apple Silicon) + big_model=big if "gemini" not in big and "gpt" not in big else "llama3:8b", + small_model=small if "gemini" not in small and "gpt" not in small else "llama3:8b", + ), ] diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index e90746c6..b953e1b6 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import test from 'node:test' import { + buildAtomicChatProfileEnv, buildCodexProfileEnv, buildGeminiProfileEnv, buildLaunchEnv, @@ -381,3 +382,72 @@ test('auto profile falls back to openai when no viable ollama model exists', () assert.equal(selectAutoProfile(null), 'openai') assert.equal(selectAutoProfile('qwen2.5-coder:7b'), 'ollama') }) + +// ── Atomic Chat profile tests ──────────────────────────────────────────────── + +test('atomic-chat profiles never persist openai api keys', () => { + const env = buildAtomicChatProfileEnv('some-local-model', { + getAtomicChatChatBaseUrl: () => 'http://127.0.0.1:1337/v1', + }) + + assert.deepEqual(env, { + OPENAI_BASE_URL: 'http://127.0.0.1:1337/v1', + OPENAI_MODEL: 'some-local-model', + }) + assert.equal('OPENAI_API_KEY' in env, false) +}) + +test('atomic-chat profiles respect custom base url', () => { + const env = buildAtomicChatProfileEnv('my-model', { + baseUrl: 'http://192.168.1.100:1337', + getAtomicChatChatBaseUrl: (baseUrl?: string) => + baseUrl ? `${baseUrl}/v1` : 'http://127.0.0.1:1337/v1', + }) + + assert.equal(env.OPENAI_BASE_URL, 'http://192.168.1.100:1337/v1') + assert.equal(env.OPENAI_MODEL, 'my-model') +}) + +test('matching persisted atomic-chat env is reused for atomic-chat launch', async () => { + const env = await buildLaunchEnv({ + profile: 'atomic-chat', + persisted: profile('atomic-chat', { + OPENAI_BASE_URL: 'http://127.0.0.1:1337/v1', + OPENAI_MODEL: 'llama-3.1-8b', + }), + goal: 'balanced', + processEnv: {}, + getAtomicChatChatBaseUrl: () => 'http://127.0.0.1:1337/v1', + resolveAtomicChatDefaultModel: async () => 'other-model', + }) + + assert.equal(env.OPENAI_BASE_URL, 'http://127.0.0.1:1337/v1') + assert.equal(env.OPENAI_MODEL, 'llama-3.1-8b') + assert.equal(env.OPENAI_API_KEY, undefined) + assert.equal(env.CODEX_API_KEY, undefined) +}) + +test('atomic-chat launch ignores mismatched persisted openai env', async () => { + const env = await buildLaunchEnv({ + profile: 'atomic-chat', + persisted: profile('openai', { + OPENAI_BASE_URL: 'https://api.openai.com/v1', + OPENAI_MODEL: 'gpt-4o', + OPENAI_API_KEY: 'sk-persisted', + }), + goal: 'balanced', + processEnv: { + OPENAI_API_KEY: 'sk-live', + CODEX_API_KEY: 'codex-live', + CHATGPT_ACCOUNT_ID: 'acct_live', + }, + getAtomicChatChatBaseUrl: () => 'http://127.0.0.1:1337/v1', + resolveAtomicChatDefaultModel: async () => 'local-model', + }) + + assert.equal(env.OPENAI_BASE_URL, 'http://127.0.0.1:1337/v1') + assert.equal(env.OPENAI_MODEL, 'local-model') + assert.equal(env.OPENAI_API_KEY, undefined) + assert.equal(env.CODEX_API_KEY, undefined) + assert.equal(env.CHATGPT_ACCOUNT_ID, undefined) +}) diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index 866c19c5..d85af0c6 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -13,7 +13,7 @@ import { const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/openai' const DEFAULT_GEMINI_MODEL = 'gemini-2.0-flash' -export type ProviderProfile = 'openai' | 'ollama' | 'codex' | 'gemini' +export type ProviderProfile = 'openai' | 'ollama' | 'codex' | 'gemini' | 'atomic-chat' export type ProfileEnv = { OPENAI_BASE_URL?: string @@ -53,6 +53,19 @@ export function buildOllamaProfileEnv( } } +export function buildAtomicChatProfileEnv( + model: string, + options: { + baseUrl?: string | null + getAtomicChatChatBaseUrl: (baseUrl?: string) => string + }, +): ProfileEnv { + return { + OPENAI_BASE_URL: options.getAtomicChatChatBaseUrl(options.baseUrl ?? undefined), + OPENAI_MODEL: model, + } +} + export function buildGeminiProfileEnv(options: { model?: string | null baseUrl?: string | null @@ -171,6 +184,8 @@ export async function buildLaunchEnv(options: { processEnv?: NodeJS.ProcessEnv getOllamaChatBaseUrl?: (baseUrl?: string) => string resolveOllamaDefaultModel?: (goal: RecommendationGoal) => Promise + getAtomicChatChatBaseUrl?: (baseUrl?: string) => string + resolveAtomicChatDefaultModel?: () => Promise }): Promise { const processEnv = options.processEnv ?? process.env const persistedEnv = @@ -248,6 +263,26 @@ export async function buildLaunchEnv(options: { return env } + if (options.profile === 'atomic-chat') { + const getAtomicChatBaseUrl = + options.getAtomicChatChatBaseUrl ?? (() => 'http://127.0.0.1:1337/v1') + const resolveModel = + options.resolveAtomicChatDefaultModel ?? (async () => null as string | null) + + env.OPENAI_BASE_URL = persistedEnv.OPENAI_BASE_URL || getAtomicChatBaseUrl() + env.OPENAI_MODEL = + persistedEnv.OPENAI_MODEL || + (await resolveModel()) || + '' + + delete env.OPENAI_API_KEY + delete env.CODEX_API_KEY + delete env.CHATGPT_ACCOUNT_ID + delete env.CODEX_ACCOUNT_ID + + return env + } + if (options.profile === 'codex') { env.OPENAI_BASE_URL = persistedEnv.OPENAI_BASE_URL && isCodexBaseUrl(persistedEnv.OPENAI_BASE_URL) diff --git a/test_atomic_chat_provider.py b/test_atomic_chat_provider.py new file mode 100644 index 00000000..819c610c --- /dev/null +++ b/test_atomic_chat_provider.py @@ -0,0 +1,130 @@ +""" +test_atomic_chat_provider.py +Run: pytest test_atomic_chat_provider.py -v +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from atomic_chat_provider import ( + atomic_chat, + list_atomic_chat_models, + check_atomic_chat_running, +) + + +@pytest.mark.asyncio +async def test_atomic_chat_running_true(): + mock_response = MagicMock() + mock_response.status_code = 200 + with patch("atomic_chat_provider.httpx.AsyncClient") as MockClient: + MockClient.return_value.__aenter__.return_value.get = AsyncMock(return_value=mock_response) + result = await check_atomic_chat_running() + assert result is True + + +@pytest.mark.asyncio +async def test_atomic_chat_running_false_on_exception(): + with patch("atomic_chat_provider.httpx.AsyncClient") as MockClient: + MockClient.return_value.__aenter__.return_value.get = AsyncMock(side_effect=Exception("refused")) + result = await check_atomic_chat_running() + assert result is False + + +@pytest.mark.asyncio +async def test_list_models_returns_ids(): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [{"id": "llama-3.1-8b"}, {"id": "mistral-7b"}], + } + mock_response.raise_for_status = MagicMock() + with patch("atomic_chat_provider.httpx.AsyncClient") as MockClient: + MockClient.return_value.__aenter__.return_value.get = AsyncMock(return_value=mock_response) + models = await list_atomic_chat_models() + assert "llama-3.1-8b" in models + assert "mistral-7b" in models + + +@pytest.mark.asyncio +async def test_list_models_empty_on_failure(): + with patch("atomic_chat_provider.httpx.AsyncClient") as MockClient: + MockClient.return_value.__aenter__.return_value.get = AsyncMock(side_effect=Exception("down")) + models = await list_atomic_chat_models() + assert models == [] + + +@pytest.mark.asyncio +async def test_atomic_chat_returns_anthropic_format(): + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = { + "id": "chatcmpl-abc123", + "choices": [{"message": {"content": "42 is the answer."}}], + "usage": {"prompt_tokens": 10, "completion_tokens": 8}, + } + with patch("atomic_chat_provider.httpx.AsyncClient") as MockClient: + MockClient.return_value.__aenter__.return_value.post = AsyncMock(return_value=mock_response) + result = await atomic_chat( + model="llama-3.1-8b", + messages=[{"role": "user", "content": "What is 6*7?"}], + ) + assert result["type"] == "message" + assert result["role"] == "assistant" + assert "42" in result["content"][0]["text"] + assert result["usage"]["input_tokens"] == 10 + assert result["usage"]["output_tokens"] == 8 + + +@pytest.mark.asyncio +async def test_atomic_chat_prepends_system(): + captured = {} + + async def mock_post(url, json=None, **kwargs): + captured.update(json or {}) + m = MagicMock() + m.raise_for_status = MagicMock() + m.json.return_value = { + "id": "chatcmpl-xyz", + "choices": [{"message": {"content": "ok"}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1}, + } + return m + + with patch("atomic_chat_provider.httpx.AsyncClient") as MockClient: + MockClient.return_value.__aenter__.return_value.post = mock_post + await atomic_chat( + model="llama-3.1-8b", + messages=[{"role": "user", "content": "Hi"}], + system="Be helpful.", + ) + assert captured["messages"][0]["role"] == "system" + assert "helpful" in captured["messages"][0]["content"] + + +@pytest.mark.asyncio +async def test_atomic_chat_sends_correct_payload(): + captured = {} + + async def mock_post(url, json=None, **kwargs): + captured.update(json or {}) + m = MagicMock() + m.raise_for_status = MagicMock() + m.json.return_value = { + "id": "chatcmpl-xyz", + "choices": [{"message": {"content": "ok"}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1}, + } + return m + + with patch("atomic_chat_provider.httpx.AsyncClient") as MockClient: + MockClient.return_value.__aenter__.return_value.post = mock_post + await atomic_chat( + model="test-model", + messages=[{"role": "user", "content": "Test"}], + max_tokens=2048, + temperature=0.5, + ) + assert captured["model"] == "test-model" + assert captured["max_tokens"] == 2048 + assert captured["temperature"] == 0.5 + assert captured["stream"] is False From 3b7b9740f2dd3493a887e1277a7570c129bae3d9 Mon Sep 17 00:00:00 2001 From: Misha Skvortsov Date: Wed, 1 Apr 2026 23:06:25 +0300 Subject: [PATCH 20/35] fix: update OPENAI_API_KEY message and add Atomic Chat URL check - Updated the message for the OPENAI_API_KEY check to include Atomic Chat as an allowed local provider. - Introduced a new function to check if the base URL corresponds to Atomic Chat, enhancing the system's ability to identify local providers. - Adjusted the Ollama processor mode check to skip processing when an Atomic Chat local provider is detected. --- hello/world | 1 + scripts/system-check.ts | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 hello/world diff --git a/hello/world b/hello/world new file mode 100644 index 00000000..8ab686ea --- /dev/null +++ b/hello/world @@ -0,0 +1 @@ +Hello, World! diff --git a/scripts/system-check.ts b/scripts/system-check.ts index e129685a..dfb9db21 100644 --- a/scripts/system-check.ts +++ b/scripts/system-check.ts @@ -186,7 +186,7 @@ function checkOpenAIEnv(): CheckResult[] { } else if (!key && !isLocalBaseUrl(request.baseUrl)) { results.push(fail('OPENAI_API_KEY', 'Missing key for non-local provider URL.')) } else if (!key) { - results.push(pass('OPENAI_API_KEY', 'Not set (allowed for local providers like Ollama/LM Studio).')) + results.push(pass('OPENAI_API_KEY', 'Not set (allowed for local providers like Atomic Chat/Ollama/LM Studio).')) } else { results.push(pass('OPENAI_API_KEY', 'Configured.')) } @@ -271,6 +271,15 @@ async function checkBaseUrlReachability(): Promise { } } +function isAtomicChatUrl(baseUrl: string): boolean { + try { + const parsed = new URL(baseUrl) + return parsed.port === '1337' && isLocalBaseUrl(baseUrl) + } catch { + return false + } +} + function checkOllamaProcessorMode(): CheckResult { if (!isTruthy(process.env.CLAUDE_CODE_USE_OPENAI) || isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) { return pass('Ollama processor mode', 'Skipped (OpenAI-compatible mode disabled).') @@ -281,6 +290,10 @@ function checkOllamaProcessorMode(): CheckResult { return pass('Ollama processor mode', 'Skipped (provider URL is not local).') } + if (isAtomicChatUrl(baseUrl)) { + return pass('Ollama processor mode', 'Skipped (Atomic Chat local provider detected, not Ollama).') + } + const result = spawnSync('ollama', ['ps'], { cwd: process.cwd(), encoding: 'utf8', From 4f78bde08581fda7e636d000ec08558c68d0cf6a Mon Sep 17 00:00:00 2001 From: Mike <71440932+Vect0rM@users.noreply.github.com> Date: Wed, 1 Apr 2026 23:15:10 +0300 Subject: [PATCH 21/35] Delete hello/world --- hello/world | 1 - 1 file changed, 1 deletion(-) delete mode 100644 hello/world diff --git a/hello/world b/hello/world deleted file mode 100644 index 8ab686ea..00000000 --- a/hello/world +++ /dev/null @@ -1 +0,0 @@ -Hello, World! From 6aec8416ccfffb2f2c0ae67a78388f1521a1af05 Mon Sep 17 00:00:00 2001 From: salmanrajz Date: Thu, 2 Apr 2026 11:51:04 +0400 Subject: [PATCH 22/35] fix: make normalizeSchemaForOpenAI recursive for nested objects Fixes #111. normalizeSchemaForOpenAI only processed the top-level object schema, leaving nested objects untouched. OpenAI strict mode rejects schemas where nested objects have properties not listed in their required array, causing 400 errors on tools with nested params. Now recurses into properties, items, and anyOf/oneOf/allOf combinators (matching the pattern used by enforceStrictSchema in codexShim.ts). Also adds additionalProperties: false to nested objects in strict mode. Build verified passing. --- src/services/api/openaiShim.ts | 64 +++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 9a500490..017af0d1 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -235,20 +235,58 @@ function normalizeSchemaForOpenAI( schema: Record, strict = true, ): Record { - if (schema.type !== 'object' || !schema.properties) return schema - const properties = schema.properties as Record - const existingRequired = Array.isArray(schema.required) ? schema.required as string[] : [] - // OpenAI strict mode requires every property to be listed in required[]. - // Gemini rejects schemas where required[] contains keys absent from properties, - // so only promote keys that actually exist in properties. - if (strict) { - const allKeys = Object.keys(properties) - const required = Array.from(new Set([...existingRequired, ...allKeys])) - return { ...schema, required } + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) { + return (schema ?? {}) as Record } - // For Gemini: keep only existing required keys that are present in properties - const required = existingRequired.filter(k => k in properties) - return { ...schema, required } + + const record = { ...schema } + + if (record.type === 'object' && record.properties) { + const properties = record.properties as Record> + const existingRequired = Array.isArray(record.required) ? record.required as string[] : [] + + // Recurse into each property + const normalizedProps: Record = {} + for (const [key, value] of Object.entries(properties)) { + normalizedProps[key] = normalizeSchemaForOpenAI( + value as Record, + strict, + ) + } + record.properties = normalizedProps + + if (strict) { + // OpenAI strict mode requires every property to be listed in required[] + const allKeys = Object.keys(normalizedProps) + record.required = Array.from(new Set([...existingRequired, ...allKeys])) + record.additionalProperties = false + } else { + // For Gemini: keep only existing required keys that are present in properties + record.required = existingRequired.filter(k => k in normalizedProps) + } + } + + // Recurse into array items + if ('items' in record) { + if (Array.isArray(record.items)) { + record.items = (record.items as unknown[]).map( + item => normalizeSchemaForOpenAI(item as Record, strict), + ) + } else { + record.items = normalizeSchemaForOpenAI(record.items as Record, strict) + } + } + + // Recurse into combinators + for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { + if (key in record && Array.isArray(record[key])) { + record[key] = (record[key] as unknown[]).map( + item => normalizeSchemaForOpenAI(item as Record, strict), + ) + } + } + + return record } function convertTools( From 5b20fe783dac0581b8ea486ef5cc56d2659d8790 Mon Sep 17 00:00:00 2001 From: salmanrajz Date: Thu, 2 Apr 2026 12:00:07 +0400 Subject: [PATCH 23/35] fix: make CostThresholdDialog provider-aware instead of hardcoding Anthropic Partially addresses #39. The cost threshold dialog hardcoded 'Anthropic API' in the title, which is misleading for users on OpenAI, Gemini, Ollama, or other providers. Now detects the active provider via getAPIProvider() and shows the correct label. --- src/components/CostThresholdDialog.tsx | 101 +++++++++++++------------ 1 file changed, 53 insertions(+), 48 deletions(-) diff --git a/src/components/CostThresholdDialog.tsx b/src/components/CostThresholdDialog.tsx index 59c2c582..1bb5f4ce 100644 --- a/src/components/CostThresholdDialog.tsx +++ b/src/components/CostThresholdDialog.tsx @@ -1,50 +1,55 @@ -import { c as _c } from "react-compiler-runtime"; -import React from 'react'; -import { Box, Link, Text } from '../ink.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; +import React from 'react' +import { Box, Link, Text } from '../ink.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' +import { getAPIProvider } from '../utils/model/providers.js' + type Props = { - onDone: () => void; -}; -export function CostThresholdDialog(t0) { - const $ = _c(7); - const { - onDone - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Learn more about how to monitor your spending:; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = [{ - value: "ok", - label: "Got it, thanks!" - }]; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] !== onDone) { - t3 = + + ) } -//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkJveCIsIkxpbmsiLCJUZXh0IiwiU2VsZWN0IiwiRGlhbG9nIiwiUHJvcHMiLCJvbkRvbmUiLCJDb3N0VGhyZXNob2xkRGlhbG9nIiwidDAiLCIkIiwiX2MiLCJ0MSIsIlN5bWJvbCIsImZvciIsInQyIiwidmFsdWUiLCJsYWJlbCIsInQzIiwidDQiXSwic291cmNlcyI6WyJDb3N0VGhyZXNob2xkRGlhbG9nLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBCb3gsIExpbmssIFRleHQgfSBmcm9tICcuLi9pbmsuanMnXG5pbXBvcnQgeyBTZWxlY3QgfSBmcm9tICcuL0N1c3RvbVNlbGVjdC9pbmRleC5qcydcbmltcG9ydCB7IERpYWxvZyB9IGZyb20gJy4vZGVzaWduLXN5c3RlbS9EaWFsb2cuanMnXG5cbnR5cGUgUHJvcHMgPSB7XG4gIG9uRG9uZTogKCkgPT4gdm9pZFxufVxuXG5leHBvcnQgZnVuY3Rpb24gQ29zdFRocmVzaG9sZERpYWxvZyh7IG9uRG9uZSB9OiBQcm9wcyk6IFJlYWN0LlJlYWN0Tm9kZSB7XG4gIHJldHVybiAoXG4gICAgPERpYWxvZ1xuICAgICAgdGl0bGU9XCJZb3UndmUgc3BlbnQgJDUgb24gdGhlIEFudGhyb3BpYyBBUEkgdGhpcyBzZXNzaW9uLlwiXG4gICAgICBvbkNhbmNlbD17b25Eb25lfVxuICAgID5cbiAgICAgIDxCb3ggZmxleERpcmVjdGlvbj1cImNvbHVtblwiPlxuICAgICAgICA8VGV4dD5MZWFybiBtb3JlIGFib3V0IGhvdyB0byBtb25pdG9yIHlvdXIgc3BlbmRpbmc6PC9UZXh0PlxuICAgICAgICA8TGluayB1cmw9XCJodHRwczovL2NvZGUuY2xhdWRlLmNvbS9kb2NzL2VuL2Nvc3RzXCIgLz5cbiAgICAgIDwvQm94PlxuICAgICAgPFNlbGVjdFxuICAgICAgICBvcHRpb25zPXtbXG4gICAgICAgICAge1xuICAgICAgICAgICAgdmFsdWU6ICdvaycsXG4gICAgICAgICAgICBsYWJlbDogJ0dvdCBpdCwgdGhhbmtzIScsXG4gICAgICAgICAgfSxcbiAgICAgICAgXX1cbiAgICAgICAgb25DaGFuZ2U9e29uRG9uZX1cbiAgICAgIC8+XG4gICAgPC9EaWFsb2c+XG4gIClcbn1cbiJdLCJtYXBwaW5ncyI6IjtBQUFBLE9BQU9BLEtBQUssTUFBTSxPQUFPO0FBQ3pCLFNBQVNDLEdBQUcsRUFBRUMsSUFBSSxFQUFFQyxJQUFJLFFBQVEsV0FBVztBQUMzQyxTQUFTQyxNQUFNLFFBQVEseUJBQXlCO0FBQ2hELFNBQVNDLE1BQU0sUUFBUSwyQkFBMkI7QUFFbEQsS0FBS0MsS0FBSyxHQUFHO0VBQ1hDLE1BQU0sRUFBRSxHQUFHLEdBQUcsSUFBSTtBQUNwQixDQUFDO0FBRUQsT0FBTyxTQUFBQyxvQkFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUE2QjtJQUFBSjtFQUFBLElBQUFFLEVBQWlCO0VBQUEsSUFBQUcsRUFBQTtFQUFBLElBQUFGLENBQUEsUUFBQUcsTUFBQSxDQUFBQyxHQUFBO0lBTS9DRixFQUFBLElBQUMsR0FBRyxDQUFlLGFBQVEsQ0FBUixRQUFRLENBQ3pCLENBQUMsSUFBSSxDQUFDLDhDQUE4QyxFQUFuRCxJQUFJLENBQ0wsQ0FBQyxJQUFJLENBQUssR0FBdUMsQ0FBdkMsdUNBQXVDLEdBQ25ELEVBSEMsR0FBRyxDQUdFO0lBQUFGLENBQUEsTUFBQUUsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUYsQ0FBQTtFQUFBO0VBQUEsSUFBQUssRUFBQTtFQUFBLElBQUFMLENBQUEsUUFBQUcsTUFBQSxDQUFBQyxHQUFBO0lBRUtDLEVBQUEsSUFDUDtNQUFBQyxLQUFBLEVBQ1MsSUFBSTtNQUFBQyxLQUFBLEVBQ0o7SUFDVCxDQUFDLENBQ0Y7SUFBQVAsQ0FBQSxNQUFBSyxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBTCxDQUFBO0VBQUE7RUFBQSxJQUFBUSxFQUFBO0VBQUEsSUFBQVIsQ0FBQSxRQUFBSCxNQUFBO0lBTkhXLEVBQUEsSUFBQyxNQUFNLENBQ0ksT0FLUixDQUxRLENBQUFILEVBS1QsQ0FBQyxDQUNTUixRQUFNLENBQU5BLE9BQUssQ0FBQyxHQUNoQjtJQUFBRyxDQUFBLE1BQUFILE1BQUE7SUFBQUcsQ0FBQSxNQUFBUSxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBUixDQUFBO0VBQUE7RUFBQSxJQUFBUyxFQUFBO0VBQUEsSUFBQVQsQ0FBQSxRQUFBSCxNQUFBLElBQUFHLENBQUEsUUFBQVEsRUFBQTtJQWhCSkMsRUFBQSxJQUFDLE1BQU0sQ0FDQyxLQUFvRCxDQUFwRCxvREFBb0QsQ0FDaERaLFFBQU0sQ0FBTkEsT0FBSyxDQUFDLENBRWhCLENBQUFLLEVBR0ssQ0FDTCxDQUFBTSxFQVFDLENBQ0gsRUFqQkMsTUFBTSxDQWlCRTtJQUFBUixDQUFBLE1BQUFILE1BQUE7SUFBQUcsQ0FBQSxNQUFBUSxFQUFBO0lBQUFSLENBQUEsTUFBQVMsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQVQsQ0FBQTtFQUFBO0VBQUEsT0FqQlRTLEVBaUJTO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0= \ No newline at end of file From e494015e9a9f02427230fc8c8e6b1834aae60474 Mon Sep 17 00:00:00 2001 From: salmanrajz Date: Thu, 2 Apr 2026 12:12:24 +0400 Subject: [PATCH 24/35] fix: wrap streaming reader in try/finally to release lock and prevent resource leaks Partially addresses #112. The streaming reader in openaiStreamToAnthropic had no error handling - if an error occurred during streaming, the reader lock was never released. Wrapped the while loop in try/finally to ensure reader.releaseLock() is always called. --- src/services/api/openaiShim.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 017af0d1..c3b8566b 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -412,15 +412,16 @@ async function* openaiStreamToAnthropic( const decoder = new TextDecoder() let buffer = '' - while (true) { - const { done, value } = await reader.read() - if (done) break + try { + while (true) { + const { done, value } = await reader.read() + if (done) break - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() ?? '' + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' - for (const line of lines) { + for (const line of lines) { const trimmed = line.trim() if (!trimmed || trimmed === 'data: [DONE]') continue if (!trimmed.startsWith('data: ')) continue @@ -566,6 +567,9 @@ async function* openaiStreamToAnthropic( hasEmittedFinalUsage = true } } + } + } finally { + reader.releaseLock() } yield { type: 'message_stop' } From 7f969200fb40776b14830f52d74e2d23a7d1793b Mon Sep 17 00:00:00 2001 From: Raj Rasane Date: Thu, 2 Apr 2026 14:00:32 +0530 Subject: [PATCH 25/35] Add exit reason types and improve graceful shutdown handling --- src/entrypoints/agentSdkTypes.ts | 5 +++++ src/screens/REPL.tsx | 4 ++-- src/utils/gracefulShutdown.ts | 18 ++++++++++++++---- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/entrypoints/agentSdkTypes.ts b/src/entrypoints/agentSdkTypes.ts index b1994585..1389113e 100644 --- a/src/entrypoints/agentSdkTypes.ts +++ b/src/entrypoints/agentSdkTypes.ts @@ -441,3 +441,8 @@ export async function connectRemoteControl( ): Promise { throw new Error('not implemented') } + +// add exit reason types for removing the error within gracefulShutdown file +export type ExitReason = { + +} \ No newline at end of file diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 65df5ca4..93e61985 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -137,7 +137,7 @@ import { generateSessionTitle } from '../utils/sessionTitle.js'; import { BASH_INPUT_TAG, COMMAND_MESSAGE_TAG, COMMAND_NAME_TAG, LOCAL_COMMAND_STDOUT_TAG } from '../constants/xml.js'; import { escapeXml } from '../utils/xml.js'; import type { ThinkingConfig } from '../utils/thinking.js'; -import { gracefulShutdownSync } from '../utils/gracefulShutdown.js'; +import { gracefulShutdownSync, isShuttingDown } from '../utils/gracefulShutdown.js'; import { handlePromptSubmit, type PromptInputHelpers } from '../utils/handlePromptSubmit.js'; import { useQueueProcessor } from '../hooks/useQueueProcessor.js'; import { useMailboxBridge } from '../hooks/useMailboxBridge.js'; @@ -4886,7 +4886,7 @@ export function REPL({ {mrRender()} - {!toolJSX?.shouldHidePromptInput && !focusedInputDialog && !isExiting && !disabled && !cursor && <> + {!toolJSX?.shouldHidePromptInput && !focusedInputDialog && !isExiting && !disabled && !cursor && !isShuttingDown() && <> {autoRunIssueReason && } {postCompactSurvey.state !== 'closed' ? : memorySurvey.state !== 'closed' ? : } {/* Frustration-triggered transcript sharing prompt */} diff --git a/src/utils/gracefulShutdown.ts b/src/utils/gracefulShutdown.ts index 4e003000..ae486e34 100644 --- a/src/utils/gracefulShutdown.ts +++ b/src/utils/gracefulShutdown.ts @@ -56,7 +56,7 @@ import { profileReport } from './startupProfiler.js' * 3. Failing to disable leaves the terminal in a broken state */ /* eslint-disable custom-rules/no-sync-fs -- must be sync to flush before process.exit */ -function cleanupTerminalModes(): void { +function cleanupTerminalModes(skipUnmount: boolean = false): void { if (!process.stdout.isTTY) { return } @@ -84,7 +84,7 @@ function cleanupTerminalModes(): void { // Calling unmount() now does the final render on the alt buffer, // unsubscribes from signal-exit, and writes 1049l exactly once. const inst = instances.get(process.stdout) - if (inst?.isAltScreenActive) { + if (!skipUnmount && inst?.isAltScreenActive) { try { inst.unmount() } catch { @@ -92,6 +92,11 @@ function cleanupTerminalModes(): void { // so printResumeHint still hits the main buffer. writeSync(1, EXIT_ALT_SCREEN) } + } else if (skipUnmount && inst?.isAltScreenActive) { + // We already unmounted asynchronously in gracefulShutdown, but if we + // fallback to manual alt-screen exit here just in case Ink didn't write it or is dead. + // Actually, AlternateScreen unmount writes EXIT_ALT_SCREEN, so if we awaited unmount, + // we shouldn't emit it again. So we just do nothing here. } // Catches events that arrived during the unmount tree-walk. // detachForShutdown() below also drains. @@ -411,12 +416,17 @@ export async function gracefulShutdown( ) const sessionEndTimeoutMs = getSessionEndHookTimeoutMs() + // Await one tick so React can flush pending updates from commands (e.g. hiding + // the autocomplete menu on /exit) before we detach Ink. This lets log-update + // erase floating UI elements from the terminal so they don't linger after exit. + await new Promise(r => setTimeout(r, 20)) + // Failsafe: guarantee process exits even if cleanup hangs (e.g., MCP connections). // Runs cleanupTerminalModes first so a hung cleanup doesn't leave the terminal dirty. // Budget = max(5s, hook budget + 3.5s headroom for cleanup + analytics flush). failsafeTimer = setTimeout( code => { - cleanupTerminalModes() + cleanupTerminalModes(true) printResumeHint() forceExit(code) }, @@ -433,7 +443,7 @@ export async function gracefulShutdown( // cleanup (e.g., SIGKILL during macOS reboot). Without this, the resume // hint would only appear after cleanup functions, hooks, and analytics // flush — which can take several seconds. - cleanupTerminalModes() + cleanupTerminalModes(true) printResumeHint() // Flush session data first — this is the most critical cleanup. If the From 14de9cf0fb53cfe94d01d995e7d06b03ecdc75e5 Mon Sep 17 00:00:00 2001 From: salmanrajz Date: Thu, 2 Apr 2026 12:36:05 +0400 Subject: [PATCH 26/35] refactor: address code review feedback - Make getProviderLabel() switch exhaustive with explicit openai/gemini arms instead of falling through to env-var checks in default - Add clarifying comment on additionalProperties override in schema normalization --- src/components/CostThresholdDialog.tsx | 10 ++++------ src/services/api/openaiShim.ts | 2 ++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/CostThresholdDialog.tsx b/src/components/CostThresholdDialog.tsx index 1bb5f4ce..7c21becd 100644 --- a/src/components/CostThresholdDialog.tsx +++ b/src/components/CostThresholdDialog.tsx @@ -19,13 +19,11 @@ function getProviderLabel(): string { return 'Google Vertex' case 'foundry': return 'Azure Foundry' + case 'openai': + return 'OpenAI-compatible API' + case 'gemini': + return 'Gemini API' default: - if (process.env.CLAUDE_CODE_USE_OPENAI === '1' || process.env.CLAUDE_CODE_USE_OPENAI === 'true') { - return 'OpenAI-compatible API' - } - if (process.env.CLAUDE_CODE_USE_GEMINI === '1' || process.env.CLAUDE_CODE_USE_GEMINI === 'true') { - return 'Gemini API' - } return 'API' } } diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index c3b8566b..0d5bde28 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -259,6 +259,8 @@ function normalizeSchemaForOpenAI( // OpenAI strict mode requires every property to be listed in required[] const allKeys = Object.keys(normalizedProps) record.required = Array.from(new Set([...existingRequired, ...allKeys])) + // OpenAI strict mode requires additionalProperties: false on all object + // schemas — override unconditionally to ensure nested objects comply. record.additionalProperties = false } else { // For Gemini: keep only existing required keys that are present in properties From 7a7437b309bdd50291b85ca90920ac738d98439b Mon Sep 17 00:00:00 2001 From: Juan Camilo Date: Thu, 2 Apr 2026 09:29:53 +0200 Subject: [PATCH 27/35] fix: skip Anthropic model migration for third-party providers Add provider guard to migrateSonnet1mToSonnet45() so it only runs for firstParty (Anthropic) users. Without this, a 3P user with model='sonnet[1m]' would have it rewritten to an Anthropic-specific alias that is invalid for OpenAI/Gemini/Ollama providers. --- src/migrations/migrateSonnet1mToSonnet45.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/migrations/migrateSonnet1mToSonnet45.ts b/src/migrations/migrateSonnet1mToSonnet45.ts index f2936388..23319591 100644 --- a/src/migrations/migrateSonnet1mToSonnet45.ts +++ b/src/migrations/migrateSonnet1mToSonnet45.ts @@ -3,6 +3,7 @@ import { setMainLoopModelOverride, } from '../bootstrap/state.js' import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' +import { getAPIProvider } from '../utils/model/providers.js' import { getSettingsForSource, updateSettingsForSource, @@ -23,6 +24,10 @@ import { * tracked by a completion flag in global config. */ export function migrateSonnet1mToSonnet45(): void { + if (getAPIProvider() !== 'firstParty') { + return + } + const config = getGlobalConfig() if (config.sonnet1m45MigrationComplete) { return From 3ca6c299d68fca85b7f69a7d4c9009334d80177b Mon Sep 17 00:00:00 2001 From: Juan Camilo Date: Thu, 2 Apr 2026 09:46:16 +0200 Subject: [PATCH 28/35] security: pin GitHub Actions to immutable SHA digests Pin all GitHub Actions to commit SHA instead of mutable version tags to prevent supply chain attacks via tag poisoning. This is especially important for third-party actions like oven-sh/setup-bun. --- .github/workflows/pr-checks.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 1b2bba1b..34580f4d 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -12,15 +12,15 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 - name: Set up Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1 with: bun-version: 1.3.11 From 6c4225f6f4da060d1ca20362f490120d350a1624 Mon Sep 17 00:00:00 2001 From: Juan Camilo Date: Thu, 2 Apr 2026 09:46:53 +0200 Subject: [PATCH 29/35] fix: skip assertMinVersion for third-party providers The version kill-switch calls Anthropic's GrowthBook endpoint to enforce a minimum version. This is currently safe for 3P users only because isAnalyticsDisabled() returns true (disabling GrowthBook). Adding an explicit provider guard makes this safety independent of the analytics stub, preventing 3P users from being blocked by Anthropic's version requirements in case of future upstream merges. --- src/utils/autoUpdater.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/utils/autoUpdater.ts b/src/utils/autoUpdater.ts index 2a5fc6f9..4d4c2bf3 100644 --- a/src/utils/autoUpdater.ts +++ b/src/utils/autoUpdater.ts @@ -9,6 +9,7 @@ import { logEvent, } from 'src/services/analytics/index.js' import { type ReleaseChannel, saveGlobalConfig } from './config.js' +import { getAPIProvider } from './model/providers.js' import { logForDebugging } from './debug.js' import { env } from './env.js' import { getClaudeConfigHomeDir } from './envUtils.js' @@ -72,6 +73,12 @@ export async function assertMinVersion(): Promise { return } + // Skip version check for third-party providers — the min version + // kill-switch is Anthropic-specific and should not block 3P users + if (getAPIProvider() !== 'firstParty') { + return + } + try { const versionConfig = await getDynamicConfig_BLOCKS_ON_INIT<{ minVersion: string From 001f89f62c76d16dae8d30135144cf0d1b2228f8 Mon Sep 17 00:00:00 2001 From: gnanam1990 Date: Thu, 2 Apr 2026 14:55:04 +0530 Subject: [PATCH 30/35] feat: add MCP doctor diagnostics service Add the diagnostics core and report model for MCP health, scope, and config analysis. This creates the structured report used by both text and JSON doctor output. Co-Authored-By: Claude --- src/services/mcp/doctor.test.ts | 540 +++++++++++++++++++++++++ src/services/mcp/doctor.ts | 695 ++++++++++++++++++++++++++++++++ 2 files changed, 1235 insertions(+) create mode 100644 src/services/mcp/doctor.test.ts create mode 100644 src/services/mcp/doctor.ts diff --git a/src/services/mcp/doctor.test.ts b/src/services/mcp/doctor.test.ts new file mode 100644 index 00000000..83b74d3f --- /dev/null +++ b/src/services/mcp/doctor.test.ts @@ -0,0 +1,540 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { ValidationError } from '../../utils/settings/validation.js' + +import { + buildEmptyDoctorReport, + doctorAllServers, + doctorServer, + findingsFromValidationErrors, + type McpDoctorDependencies, +} from './doctor.js' + +function stdioConfig(scope: 'local' | 'project' | 'user' | 'enterprise', command: string) { + return { + type: 'stdio' as const, + command, + args: [], + scope, + } +} + +function makeDependencies(overrides: Partial = {}): McpDoctorDependencies { + return { + getAllMcpConfigs: async () => ({ servers: {}, errors: [] }), + getMcpConfigsByScope: () => ({ servers: {}, errors: [] }), + getProjectMcpServerStatus: () => 'approved', + isMcpServerDisabled: () => false, + describeMcpConfigFilePath: scope => `scope://${scope}`, + clearServerCache: async () => {}, + connectToServer: async (name, config) => ({ + name, + type: 'connected', + capabilities: {}, + config, + cleanup: async () => {}, + }), + ...overrides, + } +} + +test('buildEmptyDoctorReport returns zeroed summary', () => { + const report = buildEmptyDoctorReport({ + configOnly: true, + scopeFilter: 'project', + targetName: 'filesystem', + }) + + assert.equal(report.targetName, 'filesystem') + assert.equal(report.scopeFilter, 'project') + assert.equal(report.configOnly, true) + assert.deepEqual(report.summary, { + totalReports: 0, + healthy: 0, + warnings: 0, + blocking: 0, + }) + assert.deepEqual(report.findings, []) + assert.deepEqual(report.servers, []) +}) + +test('findingsFromValidationErrors maps missing env warnings into doctor findings', () => { + const validationErrors: ValidationError[] = [ + { + file: '.mcp.json', + path: 'mcpServers.filesystem', + message: 'Missing environment variables: API_KEY, API_URL', + suggestion: 'Set the following environment variables: API_KEY, API_URL', + mcpErrorMetadata: { + scope: 'project', + serverName: 'filesystem', + severity: 'warning', + }, + }, + ] + + const findings = findingsFromValidationErrors(validationErrors) + + assert.equal(findings.length, 1) + assert.deepEqual(findings[0], { + blocking: false, + code: 'config.missing_env_vars', + message: 'Missing environment variables: API_KEY, API_URL', + remediation: 'Set the following environment variables: API_KEY, API_URL', + scope: 'project', + serverName: 'filesystem', + severity: 'warn', + sourcePath: '.mcp.json', + }) +}) + +test('findingsFromValidationErrors maps Windows npx warnings into doctor findings', () => { + const validationErrors: ValidationError[] = [ + { + file: '.mcp.json', + path: 'mcpServers.node-tools', + message: "Windows requires 'cmd /c' wrapper to execute npx", + suggestion: + 'Change command to "cmd" with args ["/c", "npx", ...]. See: https://code.claude.com/docs/en/mcp#configure-mcp-servers', + mcpErrorMetadata: { + scope: 'project', + serverName: 'node-tools', + severity: 'warning', + }, + }, + ] + + const findings = findingsFromValidationErrors(validationErrors) + + assert.equal(findings.length, 1) + assert.equal(findings[0]?.code, 'config.windows_npx_wrapper_required') + assert.equal(findings[0]?.serverName, 'node-tools') + assert.equal(findings[0]?.severity, 'warn') + assert.equal(findings[0]?.blocking, false) +}) + +test('findingsFromValidationErrors maps fatal parse errors into blocking findings', () => { + const validationErrors: ValidationError[] = [ + { + file: 'C:/repo/.mcp.json', + path: '', + message: 'MCP config is not a valid JSON', + suggestion: 'Fix the JSON syntax errors in the file', + mcpErrorMetadata: { + scope: 'project', + severity: 'fatal', + }, + }, + ] + + const findings = findingsFromValidationErrors(validationErrors) + + assert.equal(findings.length, 1) + assert.equal(findings[0]?.code, 'config.invalid_json') + assert.equal(findings[0]?.severity, 'error') + assert.equal(findings[0]?.blocking, true) +}) + +test('doctorAllServers reports global validation findings once without duplicating them into every server', async () => { + const localConfig = stdioConfig('local', 'node-local') + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { filesystem: localConfig }, + errors: [], + }), + getMcpConfigsByScope: scope => + scope === 'project' + ? { + servers: {}, + errors: [ + { + file: '.mcp.json', + path: '', + message: 'MCP config is not a valid JSON', + suggestion: 'Fix the JSON syntax errors in the file', + mcpErrorMetadata: { + scope: 'project', + severity: 'fatal', + }, + }, + ], + } + : scope === 'local' + ? { servers: { filesystem: localConfig }, errors: [] } + : { servers: {}, errors: [] }, + }) + + const report = await doctorAllServers({ configOnly: true }, deps) + + assert.equal(report.summary.totalReports, 1) + assert.equal(report.summary.blocking, 1) + assert.equal(report.findings.length, 1) + assert.equal(report.findings[0]?.code, 'config.invalid_json') + assert.deepEqual(report.servers[0]?.findings, []) +}) + +test('doctorServer explains same-name shadowing across scopes', async () => { + const localConfig = stdioConfig('local', 'node-local') + const userConfig = stdioConfig('user', 'node-user') + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { + filesystem: localConfig, + }, + errors: [], + }), + getMcpConfigsByScope: scope => { + switch (scope) { + case 'local': + return { servers: { filesystem: localConfig }, errors: [] } + case 'user': + return { servers: { filesystem: userConfig }, errors: [] } + default: + return { servers: {}, errors: [] } + } + }, + }) + + const report = await doctorServer('filesystem', { configOnly: true }, deps) + assert.equal(report.servers.length, 1) + assert.equal(report.servers[0]?.definitions.length, 2) + assert.equal(report.servers[0]?.definitions.find(def => def.sourceType === 'local')?.runtimeActive, true) + assert.equal(report.servers[0]?.definitions.find(def => def.sourceType === 'user')?.runtimeActive, false) + assert.deepEqual( + report.servers[0]?.findings.map(finding => finding.code).sort(), + ['duplicate.same_name_multiple_scopes', 'scope.shadowed'], + ) +}) + +test('doctorServer reports project servers pending approval', async () => { + const projectConfig = stdioConfig('project', 'node-project') + const deps = makeDependencies({ + getMcpConfigsByScope: scope => + scope === 'project' + ? { servers: { sentry: projectConfig }, errors: [] } + : { servers: {}, errors: [] }, + getProjectMcpServerStatus: name => (name === 'sentry' ? 'pending' : 'approved'), + }) + + const report = await doctorServer('sentry', { configOnly: true }, deps) + assert.equal(report.servers.length, 1) + assert.equal(report.servers[0]?.definitions[0]?.pendingApproval, true) + assert.equal(report.servers[0]?.definitions[0]?.runtimeActive, false) + assert.equal(report.servers[0]?.definitions[0]?.runtimeVisible, false) + assert.equal( + report.servers[0]?.findings.some(finding => finding.code === 'state.pending_project_approval'), + true, + ) +}) + +test('doctorServer does not treat disabled servers as runtime-active or live-check targets', async () => { + let connectCalls = 0 + const localConfig = stdioConfig('local', 'node-local') + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { github: localConfig }, + errors: [], + }), + getMcpConfigsByScope: scope => + scope === 'local' + ? { servers: { github: localConfig }, errors: [] } + : { servers: {}, errors: [] }, + isMcpServerDisabled: name => name === 'github', + connectToServer: async (name, config) => { + connectCalls += 1 + return { + name, + type: 'failed', + config, + error: 'should not connect', + } + }, + }) + + const report = await doctorServer('github', { configOnly: false }, deps) + + assert.equal(connectCalls, 0) + assert.equal(report.summary.blocking, 0) + assert.equal(report.summary.warnings, 1) + assert.equal(report.servers[0]?.definitions[0]?.disabled, true) + assert.equal(report.servers[0]?.definitions[0]?.runtimeActive, false) + assert.equal(report.servers[0]?.definitions[0]?.runtimeVisible, false) + assert.equal(report.servers[0]?.liveCheck.result, 'disabled') + assert.equal( + report.servers[0]?.findings.some(finding => finding.code === 'state.disabled' && finding.severity === 'warn'), + true, + ) +}) + +test('doctorAllServers skips live checks in config-only mode', async () => { + let connectCalls = 0 + const localConfig = stdioConfig('local', 'node-local') + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { linear: localConfig }, + errors: [], + }), + getMcpConfigsByScope: scope => + scope === 'local' + ? { servers: { linear: localConfig }, errors: [] } + : { servers: {}, errors: [] }, + connectToServer: async (name, config) => { + connectCalls += 1 + return { + name, + type: 'connected', + capabilities: {}, + config, + cleanup: async () => {}, + } + }, + }) + + const report = await doctorAllServers({ configOnly: true }, deps) + assert.equal(connectCalls, 0) + assert.equal(report.servers[0]?.liveCheck.attempted, false) + assert.equal(report.servers[0]?.liveCheck.result, 'skipped') +}) + +test('doctorAllServers honors scopeFilter when collecting names', async () => { + const pluginConfig = { + type: 'http' as const, + url: 'https://example.test/mcp', + scope: 'dynamic' as const, + pluginSource: 'plugin:github@official', + } + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { 'plugin:github:github': pluginConfig }, + errors: [], + }), + }) + + const report = await doctorAllServers({ configOnly: true, scopeFilter: 'user' }, deps) + + assert.equal(report.summary.totalReports, 0) + assert.deepEqual(report.servers, []) +}) + +test('doctorAllServers honors scopeFilter when collecting validation errors', async () => { + const userConfig = stdioConfig('user', 'node-user') + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { filesystem: userConfig }, + errors: [], + }), + getMcpConfigsByScope: scope => { + switch (scope) { + case 'project': + return { + servers: {}, + errors: [ + { + file: '.mcp.json', + path: '', + message: 'MCP config is not a valid JSON', + suggestion: 'Fix the JSON syntax errors in the file', + mcpErrorMetadata: { + scope: 'project', + severity: 'fatal', + }, + }, + ], + } + case 'user': + return { servers: { filesystem: userConfig }, errors: [] } + default: + return { servers: {}, errors: [] } + } + }, + }) + + const report = await doctorAllServers({ configOnly: true, scopeFilter: 'user' }, deps) + + assert.equal(report.summary.totalReports, 1) + assert.equal(report.summary.blocking, 0) + assert.deepEqual(report.findings, []) + assert.deepEqual(report.servers[0]?.findings, []) +}) + +test('doctorAllServers includes observed runtime definitions for plugin-only servers', async () => { + const pluginConfig = { + type: 'http' as const, + url: 'https://example.test/mcp', + scope: 'dynamic' as const, + pluginSource: 'plugin:github@official', + } + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { 'plugin:github:github': pluginConfig }, + errors: [], + }), + }) + + const report = await doctorAllServers({ configOnly: true }, deps) + + assert.equal(report.summary.totalReports, 1) + assert.equal(report.servers[0]?.definitions.length, 1) + assert.equal(report.servers[0]?.definitions[0]?.sourceType, 'plugin') + assert.equal(report.servers[0]?.definitions[0]?.runtimeActive, true) +}) + +test('doctorAllServers reports disabled plugin servers as disabled, not not-found', async () => { + const pluginConfig = { + type: 'http' as const, + url: 'https://example.test/mcp', + scope: 'dynamic' as const, + pluginSource: 'plugin:github@official', + } + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { 'plugin:github:github': pluginConfig }, + errors: [], + }), + isMcpServerDisabled: name => name === 'plugin:github:github', + }) + + const report = await doctorAllServers({ configOnly: true }, deps) + + assert.equal(report.summary.totalReports, 1) + assert.equal(report.summary.warnings, 1) + assert.equal(report.summary.blocking, 0) + assert.equal(report.servers[0]?.definitions.length, 1) + assert.equal(report.servers[0]?.definitions[0]?.sourceType, 'plugin') + assert.equal(report.servers[0]?.definitions[0]?.disabled, true) + assert.equal(report.servers[0]?.definitions[0]?.runtimeActive, false) + assert.equal( + report.servers[0]?.findings.some(finding => finding.code === 'state.disabled' && !finding.blocking), + true, + ) + assert.equal( + report.servers[0]?.findings.some(finding => finding.code === 'state.not_found'), + false, + ) +}) + +test('doctorServer converts failed live checks into blocking findings', async () => { + const localConfig = stdioConfig('local', 'node-local') + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { github: localConfig }, + errors: [], + }), + getMcpConfigsByScope: scope => + scope === 'local' + ? { servers: { github: localConfig }, errors: [] } + : { servers: {}, errors: [] }, + connectToServer: async (name, config) => ({ + name, + type: 'failed', + config, + error: 'command not found: node-local', + }), + }) + + const report = await doctorServer('github', { configOnly: false }, deps) + + assert.equal(report.summary.blocking, 1) + assert.equal(report.servers[0]?.liveCheck.result, 'failed') + assert.equal( + report.servers[0]?.findings.some( + finding => finding.code === 'stdio.command_not_found' && finding.blocking, + ), + true, + ) +}) + +test('doctorServer converts needs-auth live checks into warning findings', async () => { + const localConfig = stdioConfig('local', 'node-local') + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { sentry: localConfig }, + errors: [], + }), + getMcpConfigsByScope: scope => + scope === 'local' + ? { servers: { sentry: localConfig }, errors: [] } + : { servers: {}, errors: [] }, + connectToServer: async (name, config) => ({ + name, + type: 'needs-auth', + config, + }), + }) + + const report = await doctorServer('sentry', { configOnly: false }, deps) + + assert.equal(report.summary.warnings, 1) + assert.equal(report.summary.blocking, 0) + assert.equal( + report.servers[0]?.findings.some(finding => finding.code === 'auth.needs_auth' && finding.severity === 'warn'), + true, + ) +}) + +test('doctorServer includes observed runtime definition for plugin-only targets', async () => { + const pluginConfig = { + type: 'http' as const, + url: 'https://example.test/mcp', + scope: 'dynamic' as const, + pluginSource: 'plugin:github@official', + } + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { 'plugin:github:github': pluginConfig }, + errors: [], + }), + }) + + const report = await doctorServer('plugin:github:github', { configOnly: true }, deps) + + assert.equal(report.summary.totalReports, 1) + assert.equal(report.servers[0]?.definitions.length, 1) + assert.equal(report.servers[0]?.definitions[0]?.sourceType, 'plugin') + assert.equal(report.servers[0]?.definitions[0]?.runtimeActive, true) +}) + +test('doctorServer with scopeFilter does not leak runtime definition from another scope when target is absent', async () => { + let connectCalls = 0 + const localConfig = stdioConfig('local', 'node-local') + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { github: localConfig }, + errors: [], + }), + getMcpConfigsByScope: scope => + scope === 'local' + ? { servers: { github: localConfig }, errors: [] } + : { servers: {}, errors: [] }, + connectToServer: async (name, config) => { + connectCalls += 1 + return { + name, + type: 'connected', + capabilities: {}, + config, + cleanup: async () => {}, + } + }, + }) + + const report = await doctorServer('github', { configOnly: false, scopeFilter: 'user' }, deps) + + assert.equal(connectCalls, 0) + assert.equal(report.summary.totalReports, 1) + assert.equal(report.summary.blocking, 1) + assert.deepEqual(report.servers[0]?.definitions, []) + assert.equal(report.servers[0]?.liveCheck.result, 'skipped') + assert.equal( + report.servers[0]?.findings.some(finding => finding.code === 'state.not_found' && finding.blocking), + true, + ) +}) + +test('doctorServer reports blocking not-found state when no definition exists', async () => { + const report = await doctorServer('missing-server', { configOnly: true }, makeDependencies()) + + assert.equal(report.summary.blocking, 1) + assert.equal(report.servers[0]?.findings.some(finding => finding.code === 'state.not_found' && finding.blocking), true) +}) diff --git a/src/services/mcp/doctor.ts b/src/services/mcp/doctor.ts new file mode 100644 index 00000000..6cdd15e5 --- /dev/null +++ b/src/services/mcp/doctor.ts @@ -0,0 +1,695 @@ +import type { ValidationError } from '../../utils/settings/validation.js' +import { clearServerCache, connectToServer } from './client.js' +import { + getAllMcpConfigs, + getMcpConfigsByScope, + isMcpServerDisabled, +} from './config.js' +import type { + ConfigScope, + ScopedMcpServerConfig, +} from './types.js' +import { describeMcpConfigFilePath, getProjectMcpServerStatus } from './utils.js' + +export type McpDoctorSeverity = 'info' | 'warn' | 'error' +export type McpDoctorScopeFilter = 'local' | 'project' | 'user' | 'enterprise' + +export type McpDoctorFinding = { + blocking: boolean + code: string + message: string + remediation?: string + scope?: string + serverName?: string + severity: McpDoctorSeverity + sourcePath?: string +} + +export type McpDoctorLiveCheck = { + attempted: boolean + durationMs?: number + error?: string + result?: 'connected' | 'needs-auth' | 'failed' | 'pending' | 'disabled' | 'skipped' +} + +export type McpDoctorDefinition = { + name: string + sourceType: + | 'local' + | 'project' + | 'user' + | 'enterprise' + | 'managed' + | 'plugin' + | 'claudeai' + | 'dynamic' + | 'internal' + sourcePath?: string + transport?: string + runtimeVisible: boolean + runtimeActive: boolean + pendingApproval?: boolean + disabled?: boolean +} + +export type McpDoctorServerReport = { + serverName: string + requestedByUser: boolean + definitions: McpDoctorDefinition[] + liveCheck: McpDoctorLiveCheck + findings: McpDoctorFinding[] +} + +export type McpDoctorDependencies = { + getAllMcpConfigs: typeof getAllMcpConfigs + getMcpConfigsByScope: typeof getMcpConfigsByScope + getProjectMcpServerStatus: typeof getProjectMcpServerStatus + isMcpServerDisabled: typeof isMcpServerDisabled + describeMcpConfigFilePath: typeof describeMcpConfigFilePath + connectToServer: typeof connectToServer + clearServerCache: typeof clearServerCache +} + +export type McpDoctorReport = { + generatedAt: string + targetName?: string + scopeFilter?: McpDoctorScopeFilter + configOnly: boolean + summary: { + totalReports: number + healthy: number + warnings: number + blocking: number + } + findings: McpDoctorFinding[] + servers: McpDoctorServerReport[] +} + +const DEFAULT_DEPENDENCIES: McpDoctorDependencies = { + getAllMcpConfigs, + getMcpConfigsByScope, + getProjectMcpServerStatus, + isMcpServerDisabled, + describeMcpConfigFilePath, + connectToServer, + clearServerCache, +} + +export function buildEmptyDoctorReport(options: { + configOnly: boolean + scopeFilter?: McpDoctorScopeFilter + targetName?: string +}): McpDoctorReport { + return { + generatedAt: new Date().toISOString(), + targetName: options.targetName, + scopeFilter: options.scopeFilter, + configOnly: options.configOnly, + summary: { + totalReports: 0, + healthy: 0, + warnings: 0, + blocking: 0, + }, + findings: [], + servers: [], + } +} + +function getFindingCode(error: ValidationError): string { + if (error.message === 'MCP config is not a valid JSON') { + return 'config.invalid_json' + } + if (error.message.startsWith('Missing environment variables:')) { + return 'config.missing_env_vars' + } + if (error.message.includes("Windows requires 'cmd /c' wrapper to execute npx")) { + return 'config.windows_npx_wrapper_required' + } + if (error.message === 'Does not adhere to MCP server configuration schema') { + return 'config.invalid_schema' + } + return 'config.validation_error' +} + +function getSeverity(error: ValidationError): McpDoctorSeverity { + const severity = error.mcpErrorMetadata?.severity + if (severity === 'fatal') { + return 'error' + } + if (severity === 'warning') { + return 'warn' + } + return 'warn' +} + +export function findingsFromValidationErrors( + validationErrors: ValidationError[], +): McpDoctorFinding[] { + return validationErrors.map(error => { + const severity = getSeverity(error) + return { + blocking: severity === 'error', + code: getFindingCode(error), + message: error.message, + remediation: error.suggestion, + scope: error.mcpErrorMetadata?.scope, + serverName: error.mcpErrorMetadata?.serverName, + severity, + sourcePath: error.file, + } + }) +} + +function splitValidationFindings(validationFindings: McpDoctorFinding[]): { + globalFindings: McpDoctorFinding[] + serverFindingsByName: Map +} { + const globalFindings: McpDoctorFinding[] = [] + const serverFindingsByName = new Map() + + for (const finding of validationFindings) { + if (!finding.serverName) { + globalFindings.push(finding) + continue + } + + const findings = serverFindingsByName.get(finding.serverName) ?? [] + findings.push(finding) + serverFindingsByName.set(finding.serverName, findings) + } + + return { + globalFindings, + serverFindingsByName, + } +} + +function getSourceType(config: ScopedMcpServerConfig): McpDoctorDefinition['sourceType'] { + if (config.scope === 'claudeai') { + return 'claudeai' + } + if (config.scope === 'dynamic') { + return config.pluginSource ? 'plugin' : 'dynamic' + } + if (config.scope === 'managed') { + return 'managed' + } + return config.scope +} + +function getTransport(config: ScopedMcpServerConfig): string { + return config.type ?? 'stdio' +} + +function getConfigSignature(config: ScopedMcpServerConfig): string { + switch (config.type) { + case 'sse': + case 'http': + case 'ws': + case 'claudeai-proxy': + return `${config.scope}:${config.type}:${config.url}` + case 'sdk': + return `${config.scope}:${config.type}:${config.name}` + default: + return `${config.scope}:${config.type ?? 'stdio'}:${config.command}:${JSON.stringify(config.args ?? [])}` + } +} + +function isSameDefinition( + config: ScopedMcpServerConfig, + activeConfig: ScopedMcpServerConfig | undefined, +): boolean { + if (!activeConfig) { + return false + } + return getSourceType(config) === getSourceType(activeConfig) && getConfigSignature(config) === getConfigSignature(activeConfig) +} + +function buildScopeDefinitions( + name: string, + scope: ConfigScope, + servers: Record, + activeConfig: ScopedMcpServerConfig | undefined, + deps: McpDoctorDependencies, +): McpDoctorDefinition[] { + const config = servers[name] + if (!config) { + return [] + } + + const pendingApproval = + scope === 'project' ? deps.getProjectMcpServerStatus(name) === 'pending' : false + const disabled = deps.isMcpServerDisabled(name) + const runtimeActive = !disabled && isSameDefinition(config, activeConfig) + + return [ + { + name, + sourceType: getSourceType(config), + sourcePath: deps.describeMcpConfigFilePath(scope), + transport: getTransport(config), + runtimeVisible: runtimeActive, + runtimeActive, + pendingApproval, + disabled, + }, + ] +} + +function shouldIncludeScope( + scope: ConfigScope, + scopeFilter: McpDoctorScopeFilter | undefined, +): boolean { + if (!scopeFilter) { + return scope === 'enterprise' || scope === 'local' || scope === 'project' || scope === 'user' + } + return scope === scopeFilter +} + +function getValidationErrorsForSelectedScopes( + scopeResults: { + enterprise: ReturnType + local: ReturnType + project: ReturnType + user: ReturnType + }, + scopeFilter: McpDoctorScopeFilter | undefined, +): ValidationError[] { + return [ + ...(shouldIncludeScope('enterprise', scopeFilter) ? scopeResults.enterprise.errors : []), + ...(shouldIncludeScope('local', scopeFilter) ? scopeResults.local.errors : []), + ...(shouldIncludeScope('project', scopeFilter) ? scopeResults.project.errors : []), + ...(shouldIncludeScope('user', scopeFilter) ? scopeResults.user.errors : []), + ] +} + +function buildObservedDefinition( + name: string, + activeConfig: ScopedMcpServerConfig, + options?: { + disabled?: boolean + runtimeActive?: boolean + runtimeVisible?: boolean + }, +): McpDoctorDefinition { + return { + name, + sourceType: getSourceType(activeConfig), + sourcePath: + getSourceType(activeConfig) === 'plugin' + ? `plugin:${activeConfig.pluginSource ?? 'unknown'}` + : getSourceType(activeConfig) === 'claudeai' + ? 'claude.ai' + : activeConfig.scope, + transport: getTransport(activeConfig), + runtimeVisible: options?.runtimeVisible ?? true, + runtimeActive: options?.runtimeActive ?? true, + disabled: options?.disabled ?? false, + } +} + +function hasDefinitionForRuntimeSource( + definitions: McpDoctorDefinition[], + runtimeConfig: ScopedMcpServerConfig, + deps: McpDoctorDependencies, +): boolean { + const runtimeSourceType = getSourceType(runtimeConfig) + const runtimeSourcePath = + runtimeSourceType === 'plugin' + ? `plugin:${runtimeConfig.pluginSource ?? 'unknown'}` + : runtimeSourceType === 'claudeai' + ? 'claude.ai' + : deps.describeMcpConfigFilePath(runtimeConfig.scope) + + return definitions.some( + definition => + definition.sourceType === runtimeSourceType && + definition.sourcePath === runtimeSourcePath && + definition.transport === getTransport(runtimeConfig), + ) +} + +function buildShadowingFindings(definitions: McpDoctorDefinition[]): McpDoctorFinding[] { + const userEditable = definitions.filter(definition => + definition.sourceType === 'local' || + definition.sourceType === 'project' || + definition.sourceType === 'user' || + definition.sourceType === 'enterprise', + ) + + if (userEditable.length <= 1) { + return [] + } + + const active = userEditable.find(definition => definition.runtimeActive) ?? userEditable[0] + return [ + { + blocking: false, + code: 'duplicate.same_name_multiple_scopes', + message: `Server is defined in multiple config scopes; active source is ${active.sourceType}`, + remediation: 'Remove or rename one of the duplicate definitions to avoid confusion.', + serverName: active.name, + severity: 'warn', + }, + { + blocking: false, + code: 'scope.shadowed', + message: `${active.name} has shadowed definitions in lower-precedence config scopes.`, + remediation: 'Inspect the other definitions and remove the ones you no longer want to keep.', + serverName: active.name, + severity: 'warn', + }, + ] +} + +function buildStateFindings(definitions: McpDoctorDefinition[]): McpDoctorFinding[] { + const findings: McpDoctorFinding[] = [] + + for (const definition of definitions) { + if (definition.pendingApproval) { + findings.push({ + blocking: false, + code: 'state.pending_project_approval', + message: `${definition.name} is declared in project config but pending project approval.`, + remediation: 'Approve the server in the project MCP approval flow before expecting it to become active.', + scope: 'project', + serverName: definition.name, + severity: 'warn', + sourcePath: definition.sourcePath, + }) + } + + if (definition.disabled) { + findings.push({ + blocking: false, + code: 'state.disabled', + message: `${definition.name} is currently disabled.`, + remediation: 'Re-enable the server before expecting it to be available at runtime.', + serverName: definition.name, + severity: 'warn', + sourcePath: definition.sourcePath, + }) + } + } + + return findings +} + +function summarizeReport(report: McpDoctorReport): McpDoctorReport { + const allFindings = [...report.findings, ...report.servers.flatMap(server => server.findings)] + const blocking = allFindings.filter(finding => finding.blocking).length + const warnings = allFindings.filter(finding => finding.severity === 'warn').length + const healthy = report.servers.filter( + server => + server.liveCheck.result === 'connected' && + server.findings.every(finding => !finding.blocking && finding.severity !== 'warn'), + ).length + + return { + ...report, + summary: { + totalReports: report.servers.length, + healthy, + warnings, + blocking, + }, + } +} + +async function getLiveCheck( + name: string, + activeConfig: ScopedMcpServerConfig | undefined, + configOnly: boolean, + definitions: McpDoctorDefinition[], + deps: McpDoctorDependencies, +): Promise { + if (configOnly) { + return { attempted: false, result: 'skipped' } + } + + if (!activeConfig) { + if (definitions.some(definition => definition.pendingApproval)) { + return { attempted: false, result: 'pending' } + } + if (definitions.some(definition => definition.disabled)) { + return { attempted: false, result: 'disabled' } + } + return { attempted: false, result: 'skipped' } + } + + const startedAt = Date.now() + const connection = await deps.connectToServer(name, activeConfig) + const durationMs = Date.now() - startedAt + + try { + switch (connection.type) { + case 'connected': + return { attempted: true, result: 'connected', durationMs } + case 'needs-auth': + return { attempted: true, result: 'needs-auth', durationMs } + case 'disabled': + return { attempted: true, result: 'disabled', durationMs } + case 'pending': + return { attempted: true, result: 'pending', durationMs } + case 'failed': + return { + attempted: true, + result: 'failed', + durationMs, + error: connection.error, + } + } + } finally { + await deps.clearServerCache(name, activeConfig).catch(() => { + // Best-effort cleanup for diagnostic connections. + }) + } +} + +function buildLiveFindings( + name: string, + definitions: McpDoctorDefinition[], + liveCheck: McpDoctorLiveCheck, +): McpDoctorFinding[] { + const activeDefinition = definitions.find(definition => definition.runtimeActive) + + if (liveCheck.result === 'needs-auth') { + return [ + { + blocking: false, + code: 'auth.needs_auth', + message: `${name} requires authentication before it can be used.`, + remediation: 'Authenticate the server and then rerun the doctor command.', + serverName: name, + severity: 'warn', + sourcePath: activeDefinition?.sourcePath, + }, + ] + } + + if (liveCheck.result === 'failed') { + const commandNotFound = + activeDefinition?.transport === 'stdio' && + typeof liveCheck.error === 'string' && + liveCheck.error.toLowerCase().includes('not found') + + return [ + { + blocking: true, + code: commandNotFound ? 'stdio.command_not_found' : 'health.failed', + message: liveCheck.error + ? `${name} failed its live health check: ${liveCheck.error}` + : `${name} failed its live health check.`, + remediation: commandNotFound + ? 'Verify the configured executable exists on PATH or use a full executable path.' + : 'Inspect the server configuration and retry the connection once the underlying problem is fixed.', + serverName: name, + severity: 'error', + sourcePath: activeDefinition?.sourcePath, + }, + ] + } + + return [] +} + +async function buildServerReport( + name: string, + options: { + configOnly: boolean + requestedByUser: boolean + scopeFilter?: McpDoctorScopeFilter + }, + validationFindingsByName: Map, + deps: McpDoctorDependencies, +): Promise { + const scopeResults = { + enterprise: deps.getMcpConfigsByScope('enterprise'), + local: deps.getMcpConfigsByScope('local'), + project: deps.getMcpConfigsByScope('project'), + user: deps.getMcpConfigsByScope('user'), + } + const { servers: activeServers } = await deps.getAllMcpConfigs() + const serverDisabled = deps.isMcpServerDisabled(name) + const runtimeConfig = activeServers[name] ?? undefined + const activeConfig = serverDisabled ? undefined : runtimeConfig + + const definitions = [ + ...(shouldIncludeScope('enterprise', options.scopeFilter) + ? buildScopeDefinitions(name, 'enterprise', scopeResults.enterprise.servers, activeConfig, deps) + : []), + ...(shouldIncludeScope('local', options.scopeFilter) + ? buildScopeDefinitions(name, 'local', scopeResults.local.servers, activeConfig, deps) + : []), + ...(shouldIncludeScope('project', options.scopeFilter) + ? buildScopeDefinitions(name, 'project', scopeResults.project.servers, activeConfig, deps) + : []), + ...(shouldIncludeScope('user', options.scopeFilter) + ? buildScopeDefinitions(name, 'user', scopeResults.user.servers, activeConfig, deps) + : []), + ] + + const shouldAddObservedDefinition = + !!runtimeConfig && + !hasDefinitionForRuntimeSource(definitions, runtimeConfig, deps) && + ((definitions.length === 0 && !options.scopeFilter) || + (definitions.length > 0 && definitions.every(definition => !definition.runtimeActive))) + + if (runtimeConfig && shouldAddObservedDefinition) { + definitions.push( + buildObservedDefinition(name, runtimeConfig, { + disabled: serverDisabled, + runtimeActive: !serverDisabled, + runtimeVisible: !serverDisabled, + }), + ) + } + + const visibleRuntimeConfig = + definitions.some(definition => definition.runtimeActive) || shouldAddObservedDefinition + ? activeConfig + : undefined + + const findings: McpDoctorFinding[] = [ + ...(validationFindingsByName.get(name) ?? []), + ...buildShadowingFindings(definitions), + ...buildStateFindings(definitions), + ] + + if (definitions.length === 0 && !shouldAddObservedDefinition) { + findings.push({ + blocking: true, + code: 'state.not_found', + message: `${name} was not found in the selected MCP configuration sources.`, + remediation: 'Check the server name and scope, or add the MCP server before retrying.', + serverName: name, + severity: 'error', + }) + } + + const liveCheck = await getLiveCheck(name, visibleRuntimeConfig, options.configOnly, definitions, deps) + findings.push(...buildLiveFindings(name, definitions, liveCheck)) + + return { + serverName: name, + requestedByUser: options.requestedByUser, + definitions, + liveCheck, + findings, + } +} + +function getServerNames( + scopeServers: Array>, + activeServers: Record, + includeActiveServers: boolean, +): string[] { + const names = new Set(includeActiveServers ? Object.keys(activeServers) : []) + for (const servers of scopeServers) { + for (const name of Object.keys(servers)) { + names.add(name) + } + } + return [...names].sort() +} + +export async function doctorAllServers( + options: { configOnly: boolean; scopeFilter?: McpDoctorScopeFilter } = { + configOnly: false, + }, + deps: McpDoctorDependencies = DEFAULT_DEPENDENCIES, +): Promise { + const report = buildEmptyDoctorReport(options) + const scopeResults = { + enterprise: deps.getMcpConfigsByScope('enterprise'), + local: deps.getMcpConfigsByScope('local'), + project: deps.getMcpConfigsByScope('project'), + user: deps.getMcpConfigsByScope('user'), + } + const validationFindings = findingsFromValidationErrors( + getValidationErrorsForSelectedScopes(scopeResults, options.scopeFilter), + ) + const { globalFindings, serverFindingsByName } = splitValidationFindings(validationFindings) + const { servers: activeServers } = await deps.getAllMcpConfigs() + const names = getServerNames( + [ + ...(shouldIncludeScope('enterprise', options.scopeFilter) ? [scopeResults.enterprise.servers] : []), + ...(shouldIncludeScope('local', options.scopeFilter) ? [scopeResults.local.servers] : []), + ...(shouldIncludeScope('project', options.scopeFilter) ? [scopeResults.project.servers] : []), + ...(shouldIncludeScope('user', options.scopeFilter) ? [scopeResults.user.servers] : []), + ], + activeServers, + !options.scopeFilter, + ) + + const servers = await Promise.all( + names.map(name => + buildServerReport( + name, + { + configOnly: options.configOnly, + requestedByUser: false, + scopeFilter: options.scopeFilter, + }, + serverFindingsByName, + deps, + ), + ), + ) + + report.servers = servers + report.findings = globalFindings + return summarizeReport(report) +} + +export async function doctorServer( + name: string, + options: { configOnly: boolean; scopeFilter?: McpDoctorScopeFilter }, + deps: McpDoctorDependencies = DEFAULT_DEPENDENCIES, +): Promise { + const report = buildEmptyDoctorReport({ ...options, targetName: name }) + const scopeResults = { + enterprise: deps.getMcpConfigsByScope('enterprise'), + local: deps.getMcpConfigsByScope('local'), + project: deps.getMcpConfigsByScope('project'), + user: deps.getMcpConfigsByScope('user'), + } + const validationFindings = findingsFromValidationErrors( + getValidationErrorsForSelectedScopes(scopeResults, options.scopeFilter), + ) + const { globalFindings, serverFindingsByName } = splitValidationFindings(validationFindings) + const server = await buildServerReport( + name, + { + configOnly: options.configOnly, + requestedByUser: true, + scopeFilter: options.scopeFilter, + }, + serverFindingsByName, + deps, + ) + report.servers = [server] + report.findings = globalFindings + return summarizeReport(report) +} From ad1f328672ecc9046c772507e771cdc37f8050b2 Mon Sep 17 00:00:00 2001 From: gnanam1990 Date: Thu, 2 Apr 2026 14:55:05 +0530 Subject: [PATCH 31/35] feat(mcp): add doctor command Add the MCP doctor subcommand with text and JSON output, config-only mode, and scope filtering so users can diagnose MCP issues from the CLI. Co-Authored-By: Claude --- src/cli/handlers/mcp.tsx | 99 +++++++++++++++++++++++++- src/commands/mcp/doctorCommand.test.ts | 19 +++++ src/commands/mcp/doctorCommand.ts | 25 +++++++ src/main.tsx | 2 + 4 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 src/commands/mcp/doctorCommand.test.ts create mode 100644 src/commands/mcp/doctorCommand.ts diff --git a/src/cli/handlers/mcp.tsx b/src/cli/handlers/mcp.tsx index e530c268..bf43d490 100644 --- a/src/cli/handlers/mcp.tsx +++ b/src/cli/handlers/mcp.tsx @@ -12,6 +12,7 @@ import { render } from '../../ink.js'; import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; import { clearMcpClientConfig, clearServerTokensFromLocalStorage, getMcpClientConfig, readClientSecret, saveMcpClientSecret } from '../../services/mcp/auth.js'; +import { doctorAllServers, doctorServer, type McpDoctorReport, type McpDoctorScopeFilter } from '../../services/mcp/doctor.js'; import { connectToServer, getMcpServerConnectionBatchSize } from '../../services/mcp/client.js'; import { addMcpConfig, getAllMcpConfigs, getMcpConfigByName, getMcpConfigsByScope, removeMcpConfig } from '../../services/mcp/config.js'; import type { ConfigScope, ScopedMcpServerConfig } from '../../services/mcp/types.js'; @@ -23,6 +24,102 @@ import { gracefulShutdown } from '../../utils/gracefulShutdown.js'; import { safeParseJSON } from '../../utils/json.js'; import { getPlatform } from '../../utils/platform.js'; import { cliError, cliOk } from '../exit.js'; + +function formatDoctorReport(report: McpDoctorReport): string { + const lines: string[] = [] + lines.push('MCP Doctor') + lines.push('') + lines.push('Summary') + lines.push(`- ${report.summary.totalReports} server reports generated`) + lines.push(`- ${report.summary.healthy} healthy`) + lines.push(`- ${report.summary.warnings} warnings`) + lines.push(`- ${report.summary.blocking} blocking issues`) + + if (report.targetName) { + lines.push(`- target: ${report.targetName}`) + } + + for (const server of report.servers) { + lines.push('') + lines.push(server.serverName) + + const activeDefinition = server.definitions.find(definition => definition.runtimeActive) + if (activeDefinition) { + lines.push(`- Active source: ${activeDefinition.sourceType}`) + lines.push(`- Transport: ${activeDefinition.transport ?? 'unknown'}`) + } + + if (server.definitions.length > 1) { + const extraDefinitions = server.definitions + .filter(definition => !definition.runtimeActive) + .map(definition => definition.sourceType) + if (extraDefinitions.length > 0) { + lines.push(`- Additional definitions: ${extraDefinitions.join(', ')}`) + } + } + + if (server.liveCheck.result) { + const stateLikeResults = new Set(['disabled', 'pending', 'skipped']) + const label = stateLikeResults.has(server.liveCheck.result) + ? 'State' + : 'Live check' + lines.push(`- ${label}: ${server.liveCheck.result}`) + } + + if (server.liveCheck.error) { + lines.push(`- Error: ${server.liveCheck.error}`) + } + + for (const finding of server.findings) { + lines.push(`- ${finding.message}`) + if (finding.remediation) { + lines.push(`- Fix: ${finding.remediation}`) + } + } + } + + if (report.findings.length > 0) { + lines.push('') + lines.push('Global findings') + for (const finding of report.findings) { + lines.push(`- ${finding.message}`) + if (finding.remediation) { + lines.push(`- Fix: ${finding.remediation}`) + } + } + } + + return lines.join('\n') +} + +export async function mcpDoctorHandler(name: string | undefined, options: { + scope?: string; + configOnly?: boolean; + json?: boolean; +}): Promise { + try { + const scopeFilter = options.scope ? ensureConfigScope(options.scope) as McpDoctorScopeFilter : undefined + const configOnly = !!options.configOnly + const report = name + ? await doctorServer(name, { configOnly, scopeFilter }) + : await doctorAllServers({ configOnly, scopeFilter }) + + if (options.json) { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`) + } else { + process.stdout.write(`${formatDoctorReport(report)}\n`) + } + + // On Windows, exiting immediately after a single failed HTTP MCP health check + // can trip a libuv assertion while async handle shutdown is still settling. + // Let the event loop drain briefly before exiting this one-shot command. + await new Promise(resolve => setTimeout(resolve, 50)) + process.exit(report.summary.blocking > 0 ? 1 : 0) + return + } catch (error) { + cliError((error as Error).message) + } +} async function checkMcpServerHealth(name: string, server: ScopedMcpServerConfig): Promise { try { const result = await connectToServer(name, server); @@ -359,4 +456,4 @@ export async function mcpResetChoicesHandler(): Promise { })); cliOk('All project-scoped (.mcp.json) server approvals and rejections have been reset.\n' + 'You will be prompted for approval next time you start Claude Code.'); } -//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJzdGF0IiwicE1hcCIsImN3ZCIsIlJlYWN0IiwiTUNQU2VydmVyRGVza3RvcEltcG9ydERpYWxvZyIsInJlbmRlciIsIktleWJpbmRpbmdTZXR1cCIsIkFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMiLCJsb2dFdmVudCIsImNsZWFyTWNwQ2xpZW50Q29uZmlnIiwiY2xlYXJTZXJ2ZXJUb2tlbnNGcm9tTG9jYWxTdG9yYWdlIiwiZ2V0TWNwQ2xpZW50Q29uZmlnIiwicmVhZENsaWVudFNlY3JldCIsInNhdmVNY3BDbGllbnRTZWNyZXQiLCJjb25uZWN0VG9TZXJ2ZXIiLCJnZXRNY3BTZXJ2ZXJDb25uZWN0aW9uQmF0Y2hTaXplIiwiYWRkTWNwQ29uZmlnIiwiZ2V0QWxsTWNwQ29uZmlncyIsImdldE1jcENvbmZpZ0J5TmFtZSIsImdldE1jcENvbmZpZ3NCeVNjb3BlIiwicmVtb3ZlTWNwQ29uZmlnIiwiQ29uZmlnU2NvcGUiLCJTY29wZWRNY3BTZXJ2ZXJDb25maWciLCJkZXNjcmliZU1jcENvbmZpZ0ZpbGVQYXRoIiwiZW5zdXJlQ29uZmlnU2NvcGUiLCJnZXRTY29wZUxhYmVsIiwiQXBwU3RhdGVQcm92aWRlciIsImdldEN1cnJlbnRQcm9qZWN0Q29uZmlnIiwiZ2V0R2xvYmFsQ29uZmlnIiwic2F2ZUN1cnJlbnRQcm9qZWN0Q29uZmlnIiwiaXNGc0luYWNjZXNzaWJsZSIsImdyYWNlZnVsU2h1dGRvd24iLCJzYWZlUGFyc2VKU09OIiwiZ2V0UGxhdGZvcm0iLCJjbGlFcnJvciIsImNsaU9rIiwiY2hlY2tNY3BTZXJ2ZXJIZWFsdGgiLCJuYW1lIiwic2VydmVyIiwiUHJvbWlzZSIsInJlc3VsdCIsInR5cGUiLCJfZXJyb3IiLCJtY3BTZXJ2ZUhhbmRsZXIiLCJkZWJ1ZyIsInZlcmJvc2UiLCJwcm92aWRlZEN3ZCIsImVycm9yIiwic2V0dXAiLCJ1bmRlZmluZWQiLCJzdGFydE1DUFNlcnZlciIsIm1jcFJlbW92ZUhhbmRsZXIiLCJvcHRpb25zIiwic2NvcGUiLCJzZXJ2ZXJCZWZvcmVSZW1vdmFsIiwiY2xlYW51cFNlY3VyZVN0b3JhZ2UiLCJwcm9jZXNzIiwic3Rkb3V0Iiwid3JpdGUiLCJwcm9qZWN0Q29uZmlnIiwiZ2xvYmFsQ29uZmlnIiwic2VydmVycyIsInByb2plY3RTZXJ2ZXJzIiwibWNwSnNvbkV4aXN0cyIsInNjb3BlcyIsIkFycmF5IiwiRXhjbHVkZSIsIm1jcFNlcnZlcnMiLCJwdXNoIiwibGVuZ3RoIiwic3RkZXJyIiwiZm9yRWFjaCIsIkVycm9yIiwibWVzc2FnZSIsIm1jcExpc3RIYW5kbGVyIiwiY29uZmlncyIsIk9iamVjdCIsImtleXMiLCJjb25zb2xlIiwibG9nIiwiZW50cmllcyIsInJlc3VsdHMiLCJzdGF0dXMiLCJjb25jdXJyZW5jeSIsInVybCIsImFyZ3MiLCJpc0FycmF5IiwiY29tbWFuZCIsImpvaW4iLCJtY3BHZXRIYW5kbGVyIiwiaGVhZGVycyIsImtleSIsInZhbHVlIiwib2F1dGgiLCJjbGllbnRJZCIsImNhbGxiYWNrUG9ydCIsInBhcnRzIiwiY2xpZW50Q29uZmlnIiwiY2xpZW50U2VjcmV0IiwiZW52IiwibWNwQWRkSnNvbkhhbmRsZXIiLCJqc29uIiwicGFyc2VkSnNvbiIsIm5lZWRzU2VjcmV0IiwidHJhbnNwb3J0VHlwZSIsIlN0cmluZyIsInNvdXJjZSIsIm1jcEFkZEZyb21EZXNrdG9wSGFuZGxlciIsInBsYXRmb3JtIiwicmVhZENsYXVkZURlc2t0b3BNY3BTZXJ2ZXJzIiwidW5tb3VudCIsImV4aXRPbkN0cmxDIiwibWNwUmVzZXRDaG9pY2VzSGFuZGxlciIsImN1cnJlbnQiLCJlbmFibGVkTWNwanNvblNlcnZlcnMiLCJkaXNhYmxlZE1jcGpzb25TZXJ2ZXJzIiwiZW5hYmxlQWxsUHJvamVjdE1jcFNlcnZlcnMiXSwic291cmNlcyI6WyJtY3AudHN4Il0sInNvdXJjZXNDb250ZW50IjpbIi8qKlxuICogTUNQIHN1YmNvbW1hbmQgaGFuZGxlcnMg4oCUIGV4dHJhY3RlZCBmcm9tIG1haW4udHN4IGZvciBsYXp5IGxvYWRpbmcuXG4gKiBUaGVzZSBhcmUgZHluYW1pY2FsbHkgaW1wb3J0ZWQgb25seSB3aGVuIHRoZSBjb3JyZXNwb25kaW5nIGBjbGF1ZGUgbWNwICpgIGNvbW1hbmQgcnVucy5cbiAqL1xuXG5pbXBvcnQgeyBzdGF0IH0gZnJvbSAnZnMvcHJvbWlzZXMnXG5pbXBvcnQgcE1hcCBmcm9tICdwLW1hcCdcbmltcG9ydCB7IGN3ZCB9IGZyb20gJ3Byb2Nlc3MnXG5pbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBNQ1BTZXJ2ZXJEZXNrdG9wSW1wb3J0RGlhbG9nIH0gZnJvbSAnLi4vLi4vY29tcG9uZW50cy9NQ1BTZXJ2ZXJEZXNrdG9wSW1wb3J0RGlhbG9nLmpzJ1xuaW1wb3J0IHsgcmVuZGVyIH0gZnJvbSAnLi4vLi4vaW5rLmpzJ1xuaW1wb3J0IHsgS2V5YmluZGluZ1NldHVwIH0gZnJvbSAnLi4vLi4va2V5YmluZGluZ3MvS2V5YmluZGluZ1Byb3ZpZGVyU2V0dXAuanMnXG5pbXBvcnQge1xuICB0eXBlIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gIGxvZ0V2ZW50LFxufSBmcm9tICcuLi8uLi9zZXJ2aWNlcy9hbmFseXRpY3MvaW5kZXguanMnXG5pbXBvcnQge1xuICBjbGVhck1jcENsaWVudENvbmZpZyxcbiAgY2xlYXJTZXJ2ZXJUb2tlbnNGcm9tTG9jYWxTdG9yYWdlLFxuICBnZXRNY3BDbGllbnRDb25maWcsXG4gIHJlYWRDbGllbnRTZWNyZXQsXG4gIHNhdmVNY3BDbGllbnRTZWNyZXQsXG59IGZyb20gJy4uLy4uL3NlcnZpY2VzL21jcC9hdXRoLmpzJ1xuaW1wb3J0IHtcbiAgY29ubmVjdFRvU2VydmVyLFxuICBnZXRNY3BTZXJ2ZXJDb25uZWN0aW9uQmF0Y2hTaXplLFxufSBmcm9tICcuLi8uLi9zZXJ2aWNlcy9tY3AvY2xpZW50LmpzJ1xuaW1wb3J0IHtcbiAgYWRkTWNwQ29uZmlnLFxuICBnZXRBbGxNY3BDb25maWdzLFxuICBnZXRNY3BDb25maWdCeU5hbWUsXG4gIGdldE1jcENvbmZpZ3NCeVNjb3BlLFxuICByZW1vdmVNY3BDb25maWcsXG59IGZyb20gJy4uLy4uL3NlcnZpY2VzL21jcC9jb25maWcuanMnXG5pbXBvcnQgdHlwZSB7XG4gIENvbmZpZ1Njb3BlLFxuICBTY29wZWRNY3BTZXJ2ZXJDb25maWcsXG59IGZyb20gJy4uLy4uL3NlcnZpY2VzL21jcC90eXBlcy5qcydcbmltcG9ydCB7XG4gIGRlc2NyaWJlTWNwQ29uZmlnRmlsZVBhdGgsXG4gIGVuc3VyZUNvbmZpZ1Njb3BlLFxuICBnZXRTY29wZUxhYmVsLFxufSBmcm9tICcuLi8uLi9zZXJ2aWNlcy9tY3AvdXRpbHMuanMnXG5pbXBvcnQgeyBBcHBTdGF0ZVByb3ZpZGVyIH0gZnJvbSAnLi4vLi4vc3RhdGUvQXBwU3RhdGUuanMnXG5pbXBvcnQge1xuICBnZXRDdXJyZW50UHJvamVjdENvbmZpZyxcbiAgZ2V0R2xvYmFsQ29uZmlnLFxuICBzYXZlQ3VycmVudFByb2plY3RDb25maWcsXG59IGZyb20gJy4uLy4uL3V0aWxzL2NvbmZpZy5qcydcbmltcG9ydCB7IGlzRnNJbmFjY2Vzc2libGUgfSBmcm9tICcuLi8uLi91dGlscy9lcnJvcnMuanMnXG5pbXBvcnQgeyBncmFjZWZ1bFNodXRkb3duIH0gZnJvbSAnLi4vLi4vdXRpbHMvZ3JhY2VmdWxTaHV0ZG93bi5qcydcbmltcG9ydCB7IHNhZmVQYXJzZUpTT04gfSBmcm9tICcuLi8uLi91dGlscy9qc29uLmpzJ1xuaW1wb3J0IHsgZ2V0UGxhdGZvcm0gfSBmcm9tICcuLi8uLi91dGlscy9wbGF0Zm9ybS5qcydcbmltcG9ydCB7IGNsaUVycm9yLCBjbGlPayB9IGZyb20gJy4uL2V4aXQuanMnXG5cbmFzeW5jIGZ1bmN0aW9uIGNoZWNrTWNwU2VydmVySGVhbHRoKFxuICBuYW1lOiBzdHJpbmcsXG4gIHNlcnZlcjogU2NvcGVkTWNwU2VydmVyQ29uZmlnLFxuKTogUHJvbWlzZTxzdHJpbmc+IHtcbiAgdHJ5IHtcbiAgICBjb25zdCByZXN1bHQgPSBhd2FpdCBjb25uZWN0VG9TZXJ2ZXIobmFtZSwgc2VydmVyKVxuICAgIGlmIChyZXN1bHQudHlwZSA9PT0gJ2Nvbm5lY3RlZCcpIHtcbiAgICAgIHJldHVybiAn4pyTIENvbm5lY3RlZCdcbiAgICB9IGVsc2UgaWYgKHJlc3VsdC50eXBlID09PSAnbmVlZHMtYXV0aCcpIHtcbiAgICAgIHJldHVybiAnISBOZWVkcyBhdXRoZW50aWNhdGlvbidcbiAgICB9IGVsc2Uge1xuICAgICAgcmV0dXJuICfinJcgRmFpbGVkIHRvIGNvbm5lY3QnXG4gICAgfVxuICB9IGNhdGNoIChfZXJyb3IpIHtcbiAgICByZXR1cm4gJ+KclyBDb25uZWN0aW9uIGVycm9yJ1xuICB9XG59XG5cbi8vIG1jcCBzZXJ2ZSAobGluZXMgNDUxMuKAkzQ1MzIpXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gbWNwU2VydmVIYW5kbGVyKHtcbiAgZGVidWcsXG4gIHZlcmJvc2UsXG59OiB7XG4gIGRlYnVnPzogYm9vbGVhblxuICB2ZXJib3NlPzogYm9vbGVhblxufSk6IFByb21pc2U8dm9pZD4ge1xuICBjb25zdCBwcm92aWRlZEN3ZCA9IGN3ZCgpXG4gIGxvZ0V2ZW50KCd0ZW5ndV9tY3Bfc3RhcnQnLCB7fSlcblxuICB0cnkge1xuICAgIGF3YWl0IHN0YXQocHJvdmlkZWRDd2QpXG4gIH0gY2F0Y2ggKGVycm9yKSB7XG4gICAgaWYgKGlzRnNJbmFjY2Vzc2libGUoZXJyb3IpKSB7XG4gICAgICBjbGlFcnJvcihgRXJyb3I6IERpcmVjdG9yeSAke3Byb3ZpZGVkQ3dkfSBkb2VzIG5vdCBleGlzdGApXG4gICAgfVxuICAgIHRocm93IGVycm9yXG4gIH1cblxuICB0cnkge1xuICAgIGNvbnN0IHsgc2V0dXAgfSA9IGF3YWl0IGltcG9ydCgnLi4vLi4vc2V0dXAuanMnKVxuICAgIGF3YWl0IHNldHVwKHByb3ZpZGVkQ3dkLCAnZGVmYXVsdCcsIGZhbHNlLCBmYWxzZSwgdW5kZWZpbmVkLCBmYWxzZSlcbiAgICBjb25zdCB7IHN0YXJ0TUNQU2VydmVyIH0gPSBhd2FpdCBpbXBvcnQoJy4uLy4uL2VudHJ5cG9pbnRzL21jcC5qcycpXG4gICAgYXdhaXQgc3RhcnRNQ1BTZXJ2ZXIocHJvdmlkZWRDd2QsIGRlYnVnID8/IGZhbHNlLCB2ZXJib3NlID8/IGZhbHNlKVxuICB9IGNhdGNoIChlcnJvcikge1xuICAgIGNsaUVycm9yKGBFcnJvcjogRmFpbGVkIHRvIHN0YXJ0IE1DUCBzZXJ2ZXI6ICR7ZXJyb3J9YClcbiAgfVxufVxuXG4vLyBtY3AgcmVtb3ZlIChsaW5lcyA0NTQ14oCTNDYzNSlcbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBtY3BSZW1vdmVIYW5kbGVyKFxuICBuYW1lOiBzdHJpbmcsXG4gIG9wdGlvbnM6IHsgc2NvcGU/OiBzdHJpbmcgfSxcbik6IFByb21pc2U8dm9pZD4ge1xuICAvLyBMb29rIHVwIGNvbmZpZyBiZWZvcmUgcmVtb3Zpbmcgc28gd2UgY2FuIGNsZWFuIHVwIHNlY3VyZSBzdG9yYWdlXG4gIGNvbnN0IHNlcnZlckJlZm9yZVJlbW92YWwgPSBnZXRNY3BDb25maWdCeU5hbWUobmFtZSlcblxuICBjb25zdCBjbGVhbnVwU2VjdXJlU3RvcmFnZSA9ICgpID0+IHtcbiAgICBpZiAoXG4gICAgICBzZXJ2ZXJCZWZvcmVSZW1vdmFsICYmXG4gICAgICAoc2VydmVyQmVmb3JlUmVtb3ZhbC50eXBlID09PSAnc3NlJyB8fFxuICAgICAgICBzZXJ2ZXJCZWZvcmVSZW1vdmFsLnR5cGUgPT09ICdodHRwJylcbiAgICApIHtcbiAgICAgIGNsZWFyU2VydmVyVG9rZW5zRnJvbUxvY2FsU3RvcmFnZShuYW1lLCBzZXJ2ZXJCZWZvcmVSZW1vdmFsKVxuICAgICAgY2xlYXJNY3BDbGllbnRDb25maWcobmFtZSwgc2VydmVyQmVmb3JlUmVtb3ZhbClcbiAgICB9XG4gIH1cblxuICB0cnkge1xuICAgIGlmIChvcHRpb25zLnNjb3BlKSB7XG4gICAgICBjb25zdCBzY29wZSA9IGVuc3VyZUNvbmZpZ1Njb3BlKG9wdGlvbnMuc2NvcGUpXG4gICAgICBsb2dFdmVudCgndGVuZ3VfbWNwX2RlbGV0ZScsIHtcbiAgICAgICAgbmFtZTogbmFtZSBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICAgICAgICBzY29wZTpcbiAgICAgICAgICBzY29wZSBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICAgICAgfSlcblxuICAgICAgYXdhaXQgcmVtb3ZlTWNwQ29uZmlnKG5hbWUsIHNjb3BlKVxuICAgICAgY2xlYW51cFNlY3VyZVN0b3JhZ2UoKVxuICAgICAgcHJvY2Vzcy5zdGRvdXQud3JpdGUoYFJlbW92ZWQgTUNQIHNlcnZlciAke25hbWV9IGZyb20gJHtzY29wZX0gY29uZmlnXFxuYClcbiAgICAgIGNsaU9rKGBGaWxlIG1vZGlmaWVkOiAke2Rlc2NyaWJlTWNwQ29uZmlnRmlsZVBhdGgoc2NvcGUpfWApXG4gICAgfVxuXG4gICAgLy8gSWYgbm8gc2NvcGUgc3BlY2lmaWVkLCBjaGVjayB3aGVyZSB0aGUgc2VydmVyIGV4aXN0c1xuICAgIGNvbnN0IHByb2plY3RDb25maWcgPSBnZXRDdXJyZW50UHJvamVjdENvbmZpZygpXG4gICAgY29uc3QgZ2xvYmFsQ29uZmlnID0gZ2V0R2xvYmFsQ29uZmlnKClcblxuICAgIC8vIENoZWNrIGlmIHNlcnZlciBleGlzdHMgaW4gcHJvamVjdCBzY29wZSAoLm1jcC5qc29uKVxuICAgIGNvbnN0IHsgc2VydmVyczogcHJvamVjdFNlcnZlcnMgfSA9IGdldE1jcENvbmZpZ3NCeVNjb3BlKCdwcm9qZWN0JylcbiAgICBjb25zdCBtY3BKc29uRXhpc3RzID0gISFwcm9qZWN0U2VydmVyc1tuYW1lXVxuXG4gICAgLy8gQ291bnQgaG93IG1hbnkgc2NvcGVzIGNvbnRhaW4gdGhpcyBzZXJ2ZXJcbiAgICBjb25zdCBzY29wZXM6IEFycmF5PEV4Y2x1ZGU8Q29uZmlnU2NvcGUsICdkeW5hbWljJz4+ID0gW11cbiAgICBpZiAocHJvamVjdENvbmZpZy5tY3BTZXJ2ZXJzPy5bbmFtZV0pIHNjb3Blcy5wdXNoKCdsb2NhbCcpXG4gICAgaWYgKG1jcEpzb25FeGlzdHMpIHNjb3Blcy5wdXNoKCdwcm9qZWN0JylcbiAgICBpZiAoZ2xvYmFsQ29uZmlnLm1jcFNlcnZlcnM/LltuYW1lXSkgc2NvcGVzLnB1c2goJ3VzZXInKVxuXG4gICAgaWYgKHNjb3Blcy5sZW5ndGggPT09IDApIHtcbiAgICAgIGNsaUVycm9yKGBObyBNQ1Agc2VydmVyIGZvdW5kIHdpdGggbmFtZTogXCIke25hbWV9XCJgKVxuICAgIH0gZWxzZSBpZiAoc2NvcGVzLmxlbmd0aCA9PT0gMSkge1xuICAgICAgLy8gU2VydmVyIGV4aXN0cyBpbiBvbmx5IG9uZSBzY29wZSwgcmVtb3ZlIGl0XG4gICAgICBjb25zdCBzY29wZSA9IHNjb3Blc1swXSFcbiAgICAgIGxvZ0V2ZW50KCd0ZW5ndV9tY3BfZGVsZXRlJywge1xuICAgICAgICBuYW1lOiBuYW1lIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgICAgIHNjb3BlOlxuICAgICAgICAgIHNjb3BlIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgICB9KVxuXG4gICAgICBhd2FpdCByZW1vdmVNY3BDb25maWcobmFtZSwgc2NvcGUpXG4gICAgICBjbGVhbnVwU2VjdXJlU3RvcmFnZSgpXG4gICAgICBwcm9jZXNzLnN0ZG91dC53cml0ZShcbiAgICAgICAgYFJlbW92ZWQgTUNQIHNlcnZlciBcIiR7bmFtZX1cIiBmcm9tICR7c2NvcGV9IGNvbmZpZ1xcbmAsXG4gICAgICApXG4gICAgICBjbGlPayhgRmlsZSBtb2RpZmllZDogJHtkZXNjcmliZU1jcENvbmZpZ0ZpbGVQYXRoKHNjb3BlKX1gKVxuICAgIH0gZWxzZSB7XG4gICAgICAvLyBTZXJ2ZXIgZXhpc3RzIGluIG11bHRpcGxlIHNjb3Blc1xuICAgICAgcHJvY2Vzcy5zdGRlcnIud3JpdGUoYE1DUCBzZXJ2ZXIgXCIke25hbWV9XCIgZXhpc3RzIGluIG11bHRpcGxlIHNjb3BlczpcXG5gKVxuICAgICAgc2NvcGVzLmZvckVhY2goc2NvcGUgPT4ge1xuICAgICAgICBwcm9jZXNzLnN0ZGVyci53cml0ZShcbiAgICAgICAgICBgICAtICR7Z2V0U2NvcGVMYWJlbChzY29wZSl9ICgke2Rlc2NyaWJlTWNwQ29uZmlnRmlsZVBhdGgoc2NvcGUpfSlcXG5gLFxuICAgICAgICApXG4gICAgICB9KVxuICAgICAgcHJvY2Vzcy5zdGRlcnIud3JpdGUoJ1xcblRvIHJlbW92ZSBmcm9tIGEgc3BlY2lmaWMgc2NvcGUsIHVzZTpcXG4nKVxuICAgICAgc2NvcGVzLmZvckVhY2goc2NvcGUgPT4ge1xuICAgICAgICBwcm9jZXNzLnN0ZGVyci53cml0ZShgICBjbGF1ZGUgbWNwIHJlbW92ZSBcIiR7bmFtZX1cIiAtcyAke3Njb3BlfVxcbmApXG4gICAgICB9KVxuICAgICAgY2xpRXJyb3IoKVxuICAgIH1cbiAgfSBjYXRjaCAoZXJyb3IpIHtcbiAgICBjbGlFcnJvcigoZXJyb3IgYXMgRXJyb3IpLm1lc3NhZ2UpXG4gIH1cbn1cblxuLy8gbWNwIGxpc3QgKGxpbmVzIDQ2NDHigJM0Njg4KVxuZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIG1jcExpc3RIYW5kbGVyKCk6IFByb21pc2U8dm9pZD4ge1xuICBsb2dFdmVudCgndGVuZ3VfbWNwX2xpc3QnLCB7fSlcbiAgY29uc3QgeyBzZXJ2ZXJzOiBjb25maWdzIH0gPSBhd2FpdCBnZXRBbGxNY3BDb25maWdzKClcbiAgaWYgKE9iamVjdC5rZXlzKGNvbmZpZ3MpLmxlbmd0aCA9PT0gMCkge1xuICAgIC8vIGJpb21lLWlnbm9yZSBsaW50L3N1c3BpY2lvdXMvbm9Db25zb2xlOjogaW50ZW50aW9uYWwgY29uc29sZSBvdXRwdXRcbiAgICBjb25zb2xlLmxvZyhcbiAgICAgICdObyBNQ1Agc2VydmVycyBjb25maWd1cmVkLiBVc2UgYGNsYXVkZSBtY3AgYWRkYCB0byBhZGQgYSBzZXJ2ZXIuJyxcbiAgICApXG4gIH0gZWxzZSB7XG4gICAgLy8gYmlvbWUtaWdub3JlIGxpbnQvc3VzcGljaW91cy9ub0NvbnNvbGU6OiBpbnRlbnRpb25hbCBjb25zb2xlIG91dHB1dFxuICAgIGNvbnNvbGUubG9nKCdDaGVja2luZyBNQ1Agc2VydmVyIGhlYWx0aC4uLlxcbicpXG5cbiAgICAvLyBDaGVjayBzZXJ2ZXJzIGNvbmN1cnJlbnRseVxuICAgIGNvbnN0IGVudHJpZXMgPSBPYmplY3QuZW50cmllcyhjb25maWdzKVxuICAgIGNvbnN0IHJlc3VsdHMgPSBhd2FpdCBwTWFwKFxuICAgICAgZW50cmllcyxcbiAgICAgIGFzeW5jIChbbmFtZSwgc2VydmVyXSkgPT4gKHtcbiAgICAgICAgbmFtZSxcbiAgICAgICAgc2VydmVyLFxuICAgICAgICBzdGF0dXM6IGF3YWl0IGNoZWNrTWNwU2VydmVySGVhbHRoKG5hbWUsIHNlcnZlciksXG4gICAgICB9KSxcbiAgICAgIHsgY29uY3VycmVuY3k6IGdldE1jcFNlcnZlckNvbm5lY3Rpb25CYXRjaFNpemUoKSB9LFxuICAgIClcblxuICAgIGZvciAoY29uc3QgeyBuYW1lLCBzZXJ2ZXIsIHN0YXR1cyB9IG9mIHJlc3VsdHMpIHtcbiAgICAgIC8vIEludGVudGlvbmFsbHkgZXhjbHVkaW5nIHNzZS1pZGUgc2VydmVycyBoZXJlIHNpbmNlIHRoZXkncmUgaW50ZXJuYWxcbiAgICAgIGlmIChzZXJ2ZXIudHlwZSA9PT0gJ3NzZScpIHtcbiAgICAgICAgLy8gYmlvbWUtaWdub3JlIGxpbnQvc3VzcGljaW91cy9ub0NvbnNvbGU6OiBpbnRlbnRpb25hbCBjb25zb2xlIG91dHB1dFxuICAgICAgICBjb25zb2xlLmxvZyhgJHtuYW1lfTogJHtzZXJ2ZXIudXJsfSAoU1NFKSAtICR7c3RhdHVzfWApXG4gICAgICB9IGVsc2UgaWYgKHNlcnZlci50eXBlID09PSAnaHR0cCcpIHtcbiAgICAgICAgLy8gYmlvbWUtaWdub3JlIGxpbnQvc3VzcGljaW91cy9ub0NvbnNvbGU6OiBpbnRlbnRpb25hbCBjb25zb2xlIG91dHB1dFxuICAgICAgICBjb25zb2xlLmxvZyhgJHtuYW1lfTogJHtzZXJ2ZXIudXJsfSAoSFRUUCkgLSAke3N0YXR1c31gKVxuICAgICAgfSBlbHNlIGlmIChzZXJ2ZXIudHlwZSA9PT0gJ2NsYXVkZWFpLXByb3h5Jykge1xuICAgICAgICAvLyBiaW9tZS1pZ25vcmUgbGludC9zdXNwaWNpb3VzL25vQ29uc29sZTo6IGludGVudGlvbmFsIGNvbnNvbGUgb3V0cHV0XG4gICAgICAgIGNvbnNvbGUubG9nKGAke25hbWV9OiAke3NlcnZlci51cmx9IC0gJHtzdGF0dXN9YClcbiAgICAgIH0gZWxzZSBpZiAoIXNlcnZlci50eXBlIHx8IHNlcnZlci50eXBlID09PSAnc3RkaW8nKSB7XG4gICAgICAgIGNvbnN0IGFyZ3MgPSBBcnJheS5pc0FycmF5KHNlcnZlci5hcmdzKSA/IHNlcnZlci5hcmdzIDogW11cbiAgICAgICAgLy8gYmlvbWUtaWdub3JlIGxpbnQvc3VzcGljaW91cy9ub0NvbnNvbGU6OiBpbnRlbnRpb25hbCBjb25zb2xlIG91dHB1dFxuICAgICAgICBjb25zb2xlLmxvZyhgJHtuYW1lfTogJHtzZXJ2ZXIuY29tbWFuZH0gJHthcmdzLmpvaW4oJyAnKX0gLSAke3N0YXR1c31gKVxuICAgICAgfVxuICAgIH1cbiAgfVxuICAvLyBVc2UgZ3JhY2VmdWxTaHV0ZG93biB0byBwcm9wZXJseSBjbGVhbiB1cCBNQ1Agc2VydmVyIGNvbm5lY3Rpb25zXG4gIC8vIChwcm9jZXNzLmV4aXQgYnlwYXNzZXMgY2xlYW51cCBoYW5kbGVycywgbGVhdmluZyBjaGlsZCBwcm9jZXNzZXMgb3JwaGFuZWQpXG4gIGF3YWl0IGdyYWNlZnVsU2h1dGRvd24oMClcbn1cblxuLy8gbWNwIGdldCAobGluZXMgNDY5NOKAkzQ3ODYpXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gbWNwR2V0SGFuZGxlcihuYW1lOiBzdHJpbmcpOiBQcm9taXNlPHZvaWQ+IHtcbiAgbG9nRXZlbnQoJ3Rlbmd1X21jcF9nZXQnLCB7XG4gICAgbmFtZTogbmFtZSBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICB9KVxuICBjb25zdCBzZXJ2ZXIgPSBnZXRNY3BDb25maWdCeU5hbWUobmFtZSlcbiAgaWYgKCFzZXJ2ZXIpIHtcbiAgICBjbGlFcnJvcihgTm8gTUNQIHNlcnZlciBmb3VuZCB3aXRoIG5hbWU6ICR7bmFtZX1gKVxuICB9XG5cbiAgLy8gYmlvbWUtaWdub3JlIGxpbnQvc3VzcGljaW91cy9ub0NvbnNvbGU6OiBpbnRlbnRpb25hbCBjb25zb2xlIG91dHB1dFxuICBjb25zb2xlLmxvZyhgJHtuYW1lfTpgKVxuICAvLyBiaW9tZS1pZ25vcmUgbGludC9zdXNwaWNpb3VzL25vQ29uc29sZTo6IGludGVudGlvbmFsIGNvbnNvbGUgb3V0cHV0XG4gIGNvbnNvbGUubG9nKGAgIFNjb3BlOiAke2dldFNjb3BlTGFiZWwoc2VydmVyLnNjb3BlKX1gKVxuXG4gIC8vIENoZWNrIHNlcnZlciBoZWFsdGhcbiAgY29uc3Qgc3RhdHVzID0gYXdhaXQgY2hlY2tNY3BTZXJ2ZXJIZWFsdGgobmFtZSwgc2VydmVyKVxuICAvLyBiaW9tZS1pZ25vcmUgbGludC9zdXNwaWNpb3VzL25vQ29uc29sZTo6IGludGVudGlvbmFsIGNvbnNvbGUgb3V0cHV0XG4gIGNvbnNvbGUubG9nKGAgIFN0YXR1czogJHtzdGF0dXN9YClcblxuICAvLyBJbnRlbnRpb25hbGx5IGV4Y2x1ZGluZyBzc2UtaWRlIHNlcnZlcnMgaGVyZSBzaW5jZSB0aGV5J3JlIGludGVybmFsXG4gIGlmIChzZXJ2ZXIudHlwZSA9PT0gJ3NzZScpIHtcbiAgICAvLyBiaW9tZS1pZ25vcmUgbGludC9zdXNwaWNpb3VzL25vQ29uc29sZTo6IGludGVudGlvbmFsIGNvbnNvbGUgb3V0cHV0XG4gICAgY29uc29sZS5sb2coYCAgVHlwZTogc3NlYClcbiAgICAvLyBiaW9tZS1pZ25vcmUgbGludC9zdXNwaWNpb3VzL25vQ29uc29sZTo6IGludGVudGlvbmFsIGNvbnNvbGUgb3V0cHV0XG4gICAgY29uc29sZS5sb2coYCAgVVJMOiAke3NlcnZlci51cmx9YClcbiAgICBpZiAoc2VydmVyLmhlYWRlcnMpIHtcbiAgICAgIC8vIGJpb21lLWlnbm9yZSBsaW50L3N1c3BpY2lvdXMvbm9Db25zb2xlOjogaW50ZW50aW9uYWwgY29uc29sZSBvdXRwdXRcbiAgICAgIGNvbnNvbGUubG9nKCcgIEhlYWRlcnM6JylcbiAgICAgIGZvciAoY29uc3QgW2tleSwgdmFsdWVdIG9mIE9iamVjdC5lbnRyaWVzKHNlcnZlci5oZWFkZXJzKSkge1xuICAgICAgICAvLyBiaW9tZS1pZ25vcmUgbGludC9zdXNwaWNpb3VzL25vQ29uc29sZTo6IGludGVudGlvbmFsIGNvbnNvbGUgb3V0cHV0XG4gICAgICAgIGNvbnNvbGUubG9nKGAgICAgJHtrZXl9OiAke3ZhbHVlfWApXG4gICAgICB9XG4gICAgfVxuICAgIGlmIChzZXJ2ZXIub2F1dGg/LmNsaWVudElkIHx8IHNlcnZlci5vYXV0aD8uY2FsbGJhY2tQb3J0KSB7XG4gICAgICBjb25zdCBwYXJ0czogc3RyaW5nW10gPSBbXVxuICAgICAgaWYgKHNlcnZlci5vYXV0aC5jbGllbnRJZCkge1xuICAgICAgICBwYXJ0cy5wdXNoKCdjbGllbnRfaWQgY29uZmlndXJlZCcpXG4gICAgICAgIGNvbnN0IGNsaWVudENvbmZpZyA9IGdldE1jcENsaWVudENvbmZpZyhuYW1lLCBzZXJ2ZXIpXG4gICAgICAgIGlmIChjbGllbnRDb25maWc/LmNsaWVudFNlY3JldCkgcGFydHMucHVzaCgnY2xpZW50X3NlY3JldCBjb25maWd1cmVkJylcbiAgICAgIH1cbiAgICAgIGlmIChzZXJ2ZXIub2F1dGguY2FsbGJhY2tQb3J0KVxuICAgICAgICBwYXJ0cy5wdXNoKGBjYWxsYmFja19wb3J0ICR7c2VydmVyLm9hdXRoLmNhbGxiYWNrUG9ydH1gKVxuICAgICAgLy8gYmlvbWUtaWdub3JlIGxpbnQvc3VzcGljaW91cy9ub0NvbnNvbGU6OiBpbnRlbnRpb25hbCBjb25zb2xlIG91dHB1dFxuICAgICAgY29uc29sZS5sb2coYCAgT0F1dGg6ICR7cGFydHMuam9pbignLCAnKX1gKVxuICAgIH1cbiAgfSBlbHNlIGlmIChzZXJ2ZXIudHlwZSA9PT0gJ2h0dHAnKSB7XG4gICAgLy8gYmlvbWUtaWdub3JlIGxpbnQvc3VzcGljaW91cy9ub0NvbnNvbGU6OiBpbnRlbnRpb25hbCBjb25zb2xlIG91dHB1dFxuICAgIGNvbnNvbGUubG9nKGAgIFR5cGU6IGh0dHBgKVxuICAgIC8vIGJpb21lLWlnbm9yZSBsaW50L3N1c3BpY2lvdXMvbm9Db25zb2xlOjogaW50ZW50aW9uYWwgY29uc29sZSBvdXRwdXRcbiAgICBjb25zb2xlLmxvZyhgICBVUkw6ICR7c2VydmVyLnVybH1gKVxuICAgIGlmIChzZXJ2ZXIuaGVhZGVycykge1xuICAgICAgLy8gYmlvbWUtaWdub3JlIGxpbnQvc3VzcGljaW91cy9ub0NvbnNvbGU6OiBpbnRlbnRpb25hbCBjb25zb2xlIG91dHB1dFxuICAgICAgY29uc29sZS5sb2coJyAgSGVhZGVyczonKVxuICAgICAgZm9yIChjb25zdCBba2V5LCB2YWx1ZV0gb2YgT2JqZWN0LmVudHJpZXMoc2VydmVyLmhlYWRlcnMpKSB7XG4gICAgICAgIC8vIGJpb21lLWlnbm9yZSBsaW50L3N1c3BpY2lvdXMvbm9Db25zb2xlOjogaW50ZW50aW9uYWwgY29uc29sZSBvdXRwdXRcbiAgICAgICAgY29uc29sZS5sb2coYCAgICAke2tleX06ICR7dmFsdWV9YClcbiAgICAgIH1cbiAgICB9XG4gICAgaWYgKHNlcnZlci5vYXV0aD8uY2xpZW50SWQgfHwgc2VydmVyLm9hdXRoPy5jYWxsYmFja1BvcnQpIHtcbiAgICAgIGNvbnN0IHBhcnRzOiBzdHJpbmdbXSA9IFtdXG4gICAgICBpZiAoc2VydmVyLm9hdXRoLmNsaWVudElkKSB7XG4gICAgICAgIHBhcnRzLnB1c2goJ2NsaWVudF9pZCBjb25maWd1cmVkJylcbiAgICAgICAgY29uc3QgY2xpZW50Q29uZmlnID0gZ2V0TWNwQ2xpZW50Q29uZmlnKG5hbWUsIHNlcnZlcilcbiAgICAgICAgaWYgKGNsaWVudENvbmZpZz8uY2xpZW50U2VjcmV0KSBwYXJ0cy5wdXNoKCdjbGllbnRfc2VjcmV0IGNvbmZpZ3VyZWQnKVxuICAgICAgfVxuICAgICAgaWYgKHNlcnZlci5vYXV0aC5jYWxsYmFja1BvcnQpXG4gICAgICAgIHBhcnRzLnB1c2goYGNhbGxiYWNrX3BvcnQgJHtzZXJ2ZXIub2F1dGguY2FsbGJhY2tQb3J0fWApXG4gICAgICAvLyBiaW9tZS1pZ25vcmUgbGludC9zdXNwaWNpb3VzL25vQ29uc29sZTo6IGludGVudGlvbmFsIGNvbnNvbGUgb3V0cHV0XG4gICAgICBjb25zb2xlLmxvZyhgICBPQXV0aDogJHtwYXJ0cy5qb2luKCcsICcpfWApXG4gICAgfVxuICB9IGVsc2UgaWYgKHNlcnZlci50eXBlID09PSAnc3RkaW8nKSB7XG4gICAgLy8gYmlvbWUtaWdub3JlIGxpbnQvc3VzcGljaW91cy9ub0NvbnNvbGU6OiBpbnRlbnRpb25hbCBjb25zb2xlIG91dHB1dFxuICAgIGNvbnNvbGUubG9nKGAgIFR5cGU6IHN0ZGlvYClcbiAgICAvLyBiaW9tZS1pZ25vcmUgbGludC9zdXNwaWNpb3VzL25vQ29uc29sZTo6IGludGVudGlvbmFsIGNvbnNvbGUgb3V0cHV0XG4gICAgY29uc29sZS5sb2coYCAgQ29tbWFuZDogJHtzZXJ2ZXIuY29tbWFuZH1gKVxuICAgIGNvbnN0IGFyZ3MgPSBBcnJheS5pc0FycmF5KHNlcnZlci5hcmdzKSA/IHNlcnZlci5hcmdzIDogW11cbiAgICAvLyBiaW9tZS1pZ25vcmUgbGludC9zdXNwaWNpb3VzL25vQ29uc29sZTo6IGludGVudGlvbmFsIGNvbnNvbGUgb3V0cHV0XG4gICAgY29uc29sZS5sb2coYCAgQXJnczogJHthcmdzLmpvaW4oJyAnKX1gKVxuICAgIGlmIChzZXJ2ZXIuZW52KSB7XG4gICAgICAvLyBiaW9tZS1pZ25vcmUgbGludC9zdXNwaWNpb3VzL25vQ29uc29sZTo6IGludGVudGlvbmFsIGNvbnNvbGUgb3V0cHV0XG4gICAgICBjb25zb2xlLmxvZygnICBFbnZpcm9ubWVudDonKVxuICAgICAgZm9yIChjb25zdCBba2V5LCB2YWx1ZV0gb2YgT2JqZWN0LmVudHJpZXMoc2VydmVyLmVudikpIHtcbiAgICAgICAgLy8gYmlvbWUtaWdub3JlIGxpbnQvc3VzcGljaW91cy9ub0NvbnNvbGU6OiBpbnRlbnRpb25hbCBjb25zb2xlIG91dHB1dFxuICAgICAgICBjb25zb2xlLmxvZyhgICAgICR7a2V5fT0ke3ZhbHVlfWApXG4gICAgICB9XG4gICAgfVxuICB9XG4gIC8vIGJpb21lLWlnbm9yZSBsaW50L3N1c3BpY2lvdXMvbm9Db25zb2xlOjogaW50ZW50aW9uYWwgY29uc29sZSBvdXRwdXRcbiAgY29uc29sZS5sb2coXG4gICAgYFxcblRvIHJlbW92ZSB0aGlzIHNlcnZlciwgcnVuOiBjbGF1ZGUgbWNwIHJlbW92ZSBcIiR7bmFtZX1cIiAtcyAke3NlcnZlci5zY29wZX1gLFxuICApXG4gIC8vIFVzZSBncmFjZWZ1bFNodXRkb3duIHRvIHByb3Blcmx5IGNsZWFuIHVwIE1DUCBzZXJ2ZXIgY29ubmVjdGlvbnNcbiAgLy8gKHByb2Nlc3MuZXhpdCBieXBhc3NlcyBjbGVhbnVwIGhhbmRsZXJzLCBsZWF2aW5nIGNoaWxkIHByb2Nlc3NlcyBvcnBoYW5lZClcbiAgYXdhaXQgZ3JhY2VmdWxTaHV0ZG93bigwKVxufVxuXG4vLyBtY3AgYWRkLWpzb24gKGxpbmVzIDQ4MDHigJM0ODcwKVxuZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIG1jcEFkZEpzb25IYW5kbGVyKFxuICBuYW1lOiBzdHJpbmcsXG4gIGpzb246IHN0cmluZyxcbiAgb3B0aW9uczogeyBzY29wZT86IHN0cmluZzsgY2xpZW50U2VjcmV0PzogdHJ1ZSB9LFxuKTogUHJvbWlzZTx2b2lkPiB7XG4gIHRyeSB7XG4gICAgY29uc3Qgc2NvcGUgPSBlbnN1cmVDb25maWdTY29wZShvcHRpb25zLnNjb3BlKVxuICAgIGNvbnN0IHBhcnNlZEpzb24gPSBzYWZlUGFyc2VKU09OKGpzb24pXG5cbiAgICAvLyBSZWFkIHNlY3JldCBiZWZvcmUgd3JpdGluZyBjb25maWcgc28gY2FuY2VsbGF0aW9uIGRvZXNuJ3QgbGVhdmUgcGFydGlhbCBzdGF0ZVxuICAgIGNvbnN0IG5lZWRzU2VjcmV0ID1cbiAgICAgIG9wdGlvbnMuY2xpZW50U2VjcmV0ICYmXG4gICAgICBwYXJzZWRKc29uICYmXG4gICAgICB0eXBlb2YgcGFyc2VkSnNvbiA9PT0gJ29iamVjdCcgJiZcbiAgICAgICd0eXBlJyBpbiBwYXJzZWRKc29uICYmXG4gICAgICAocGFyc2VkSnNvbi50eXBlID09PSAnc3NlJyB8fCBwYXJzZWRKc29uLnR5cGUgPT09ICdodHRwJykgJiZcbiAgICAgICd1cmwnIGluIHBhcnNlZEpzb24gJiZcbiAgICAgIHR5cGVvZiBwYXJzZWRKc29uLnVybCA9PT0gJ3N0cmluZycgJiZcbiAgICAgICdvYXV0aCcgaW4gcGFyc2VkSnNvbiAmJlxuICAgICAgcGFyc2VkSnNvbi5vYXV0aCAmJlxuICAgICAgdHlwZW9mIHBhcnNlZEpzb24ub2F1dGggPT09ICdvYmplY3QnICYmXG4gICAgICAnY2xpZW50SWQnIGluIHBhcnNlZEpzb24ub2F1dGhcbiAgICBjb25zdCBjbGllbnRTZWNyZXQgPSBuZWVkc1NlY3JldCA/IGF3YWl0IHJlYWRDbGllbnRTZWNyZXQoKSA6IHVuZGVmaW5lZFxuXG4gICAgYXdhaXQgYWRkTWNwQ29uZmlnKG5hbWUsIHBhcnNlZEpzb24sIHNjb3BlKVxuXG4gICAgY29uc3QgdHJhbnNwb3J0VHlwZSA9XG4gICAgICBwYXJzZWRKc29uICYmIHR5cGVvZiBwYXJzZWRKc29uID09PSAnb2JqZWN0JyAmJiAndHlwZScgaW4gcGFyc2VkSnNvblxuICAgICAgICA/IFN0cmluZyhwYXJzZWRKc29uLnR5cGUgfHwgJ3N0ZGlvJylcbiAgICAgICAgOiAnc3RkaW8nXG5cbiAgICBpZiAoXG4gICAgICBjbGllbnRTZWNyZXQgJiZcbiAgICAgIHBhcnNlZEpzb24gJiZcbiAgICAgIHR5cGVvZiBwYXJzZWRKc29uID09PSAnb2JqZWN0JyAmJlxuICAgICAgJ3R5cGUnIGluIHBhcnNlZEpzb24gJiZcbiAgICAgIChwYXJzZWRKc29uLnR5cGUgPT09ICdzc2UnIHx8IHBhcnNlZEpzb24udHlwZSA9PT0gJ2h0dHAnKSAmJlxuICAgICAgJ3VybCcgaW4gcGFyc2VkSnNvbiAmJlxuICAgICAgdHlwZW9mIHBhcnNlZEpzb24udXJsID09PSAnc3RyaW5nJ1xuICAgICkge1xuICAgICAgc2F2ZU1jcENsaWVudFNlY3JldChcbiAgICAgICAgbmFtZSxcbiAgICAgICAgeyB0eXBlOiBwYXJzZWRKc29uLnR5cGUsIHVybDogcGFyc2VkSnNvbi51cmwgfSxcbiAgICAgICAgY2xpZW50U2VjcmV0LFxuICAgICAgKVxuICAgIH1cblxuICAgIGxvZ0V2ZW50KCd0ZW5ndV9tY3BfYWRkJywge1xuICAgICAgc2NvcGU6XG4gICAgICAgIHNjb3BlIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgICBzb3VyY2U6XG4gICAgICAgICdqc29uJyBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICAgICAgdHlwZTogdHJhbnNwb3J0VHlwZSBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICAgIH0pXG5cbiAgICBjbGlPayhgQWRkZWQgJHt0cmFuc3BvcnRUeXBlfSBNQ1Agc2VydmVyICR7bmFtZX0gdG8gJHtzY29wZX0gY29uZmlnYClcbiAgfSBjYXRjaCAoZXJyb3IpIHtcbiAgICBjbGlFcnJvcigoZXJyb3IgYXMgRXJyb3IpLm1lc3NhZ2UpXG4gIH1cbn1cblxuLy8gbWNwIGFkZC1mcm9tLWNsYXVkZS1kZXNrdG9wIChsaW5lcyA0ODgx4oCTNDkyNylcbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBtY3BBZGRGcm9tRGVza3RvcEhhbmRsZXIob3B0aW9uczoge1xuICBzY29wZT86IHN0cmluZ1xufSk6IFByb21pc2U8dm9pZD4ge1xuICB0cnkge1xuICAgIGNvbnN0IHNjb3BlID0gZW5zdXJlQ29uZmlnU2NvcGUob3B0aW9ucy5zY29wZSlcbiAgICBjb25zdCBwbGF0Zm9ybSA9IGdldFBsYXRmb3JtKClcblxuICAgIGxvZ0V2ZW50KCd0ZW5ndV9tY3BfYWRkJywge1xuICAgICAgc2NvcGU6XG4gICAgICAgIHNjb3BlIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgICBwbGF0Zm9ybTpcbiAgICAgICAgcGxhdGZvcm0gYXMgQW5hbHl0aWNzTWV0YWRhdGFfSV9WRVJJRklFRF9USElTX0lTX05PVF9DT0RFX09SX0ZJTEVQQVRIUyxcbiAgICAgIHNvdXJjZTpcbiAgICAgICAgJ2Rlc2t0b3AnIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgfSlcblxuICAgIGNvbnN0IHsgcmVhZENsYXVkZURlc2t0b3BNY3BTZXJ2ZXJzIH0gPSBhd2FpdCBpbXBvcnQoXG4gICAgICAnLi4vLi4vdXRpbHMvY2xhdWRlRGVza3RvcC5qcydcbiAgICApXG4gICAgY29uc3Qgc2VydmVycyA9IGF3YWl0IHJlYWRDbGF1ZGVEZXNrdG9wTWNwU2VydmVycygpXG5cbiAgICBpZiAoT2JqZWN0LmtleXMoc2VydmVycykubGVuZ3RoID09PSAwKSB7XG4gICAgICBjbGlPayhcbiAgICAgICAgJ05vIE1DUCBzZXJ2ZXJzIGZvdW5kIGluIENsYXVkZSBEZXNrdG9wIGNvbmZpZ3VyYXRpb24gb3IgY29uZmlndXJhdGlvbiBmaWxlIGRvZXMgbm90IGV4aXN0LicsXG4gICAgICApXG4gICAgfVxuXG4gICAgY29uc3QgeyB1bm1vdW50IH0gPSBhd2FpdCByZW5kZXIoXG4gICAgICA8QXBwU3RhdGVQcm92aWRlcj5cbiAgICAgICAgPEtleWJpbmRpbmdTZXR1cD5cbiAgICAgICAgICA8TUNQU2VydmVyRGVza3RvcEltcG9ydERpYWxvZ1xuICAgICAgICAgICAgc2VydmVycz17c2VydmVyc31cbiAgICAgICAgICAgIHNjb3BlPXtzY29wZX1cbiAgICAgICAgICAgIG9uRG9uZT17KCkgPT4ge1xuICAgICAgICAgICAgICB1bm1vdW50KClcbiAgICAgICAgICAgIH19XG4gICAgICAgICAgLz5cbiAgICAgICAgPC9LZXliaW5kaW5nU2V0dXA+XG4gICAgICA8L0FwcFN0YXRlUHJvdmlkZXI+LFxuICAgICAgeyBleGl0T25DdHJsQzogdHJ1ZSB9LFxuICAgIClcbiAgfSBjYXRjaCAoZXJyb3IpIHtcbiAgICBjbGlFcnJvcigoZXJyb3IgYXMgRXJyb3IpLm1lc3NhZ2UpXG4gIH1cbn1cblxuLy8gbWNwIHJlc2V0LXByb2plY3QtY2hvaWNlcyAobGluZXMgNDkzNeKAkzQ5NTIpXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gbWNwUmVzZXRDaG9pY2VzSGFuZGxlcigpOiBQcm9taXNlPHZvaWQ+IHtcbiAgbG9nRXZlbnQoJ3Rlbmd1X21jcF9yZXNldF9tY3Bqc29uX2Nob2ljZXMnLCB7fSlcbiAgc2F2ZUN1cnJlbnRQcm9qZWN0Q29uZmlnKGN1cnJlbnQgPT4gKHtcbiAgICAuLi5jdXJyZW50LFxuICAgIGVuYWJsZWRNY3Bqc29uU2VydmVyczogW10sXG4gICAgZGlzYWJsZWRNY3Bqc29uU2VydmVyczogW10sXG4gICAgZW5hYmxlQWxsUHJvamVjdE1jcFNlcnZlcnM6IGZhbHNlLFxuICB9KSlcbiAgY2xpT2soXG4gICAgJ0FsbCBwcm9qZWN0LXNjb3BlZCAoLm1jcC5qc29uKSBzZXJ2ZXIgYXBwcm92YWxzIGFuZCByZWplY3Rpb25zIGhhdmUgYmVlbiByZXNldC5cXG4nICtcbiAgICAgICdZb3Ugd2lsbCBiZSBwcm9tcHRlZCBmb3IgYXBwcm92YWwgbmV4dCB0aW1lIHlvdSBzdGFydCBDbGF1ZGUgQ29kZS4nLFxuICApXG59XG4iXSwibWFwcGluZ3MiOiJBQUFBO0FBQ0E7QUFDQTtBQUNBOztBQUVBLFNBQVNBLElBQUksUUFBUSxhQUFhO0FBQ2xDLE9BQU9DLElBQUksTUFBTSxPQUFPO0FBQ3hCLFNBQVNDLEdBQUcsUUFBUSxTQUFTO0FBQzdCLE9BQU9DLEtBQUssTUFBTSxPQUFPO0FBQ3pCLFNBQVNDLDRCQUE0QixRQUFRLGtEQUFrRDtBQUMvRixTQUFTQyxNQUFNLFFBQVEsY0FBYztBQUNyQyxTQUFTQyxlQUFlLFFBQVEsOENBQThDO0FBQzlFLFNBQ0UsS0FBS0MsMERBQTBELEVBQy9EQyxRQUFRLFFBQ0gsbUNBQW1DO0FBQzFDLFNBQ0VDLG9CQUFvQixFQUNwQkMsaUNBQWlDLEVBQ2pDQyxrQkFBa0IsRUFDbEJDLGdCQUFnQixFQUNoQkMsbUJBQW1CLFFBQ2QsNEJBQTRCO0FBQ25DLFNBQ0VDLGVBQWUsRUFDZkMsK0JBQStCLFFBQzFCLDhCQUE4QjtBQUNyQyxTQUNFQyxZQUFZLEVBQ1pDLGdCQUFnQixFQUNoQkMsa0JBQWtCLEVBQ2xCQyxvQkFBb0IsRUFDcEJDLGVBQWUsUUFDViw4QkFBOEI7QUFDckMsY0FDRUMsV0FBVyxFQUNYQyxxQkFBcUIsUUFDaEIsNkJBQTZCO0FBQ3BDLFNBQ0VDLHlCQUF5QixFQUN6QkMsaUJBQWlCLEVBQ2pCQyxhQUFhLFFBQ1IsNkJBQTZCO0FBQ3BDLFNBQVNDLGdCQUFnQixRQUFRLHlCQUF5QjtBQUMxRCxTQUNFQyx1QkFBdUIsRUFDdkJDLGVBQWUsRUFDZkMsd0JBQXdCLFFBQ25CLHVCQUF1QjtBQUM5QixTQUFTQyxnQkFBZ0IsUUFBUSx1QkFBdUI7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 \ No newline at end of file +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJzdGF0IiwicE1hcCIsImN3ZCIsIlJlYWN0IiwiTUNQU2VydmVyRGVza3RvcEltcG9ydERpYWxvZyIsInJlbmRlciIsIktleWJpbmRpbmdTZXR1cCIsIkFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMiLCJsb2dFdmVudCIsImNsZWFyTWNwQ2xpZW50Q29uZmlnIiwiY2xlYXJTZXJ2ZXJUb2tlbnNGcm9tTG9jYWxTdG9yYWdlIiwiZ2V0TWNwQ2xpZW50Q29uZmlnIiwicmVhZENsaWVudFNlY3JldCIsInNhdmVNY3BDbGllbnRTZWNyZXQiLCJjb25uZWN0VG9TZXJ2ZXIiLCJnZXRNY3BTZXJ2ZXJDb25uZWN0aW9uQmF0Y2hTaXplIiwiYWRkTWNwQ29uZmlnIiwiZ2V0QWxsTWNwQ29uZmlncyIsImdldE1jcENvbmZpZ0J5TmFtZSIsImdldE1jcENvbmZpZ3NCeVNjb3BlIiwicmVtb3ZlTWNwQ29uZmlnIiwiQ29uZmlnU2NvcGUiLCJTY29wZWRNY3BTZXJ2ZXJDb25maWciLCJkZXNjcmliZU1jcENvbmZpZ0ZpbGVQYXRoIiwiZW5zdXJlQ29uZmlnU2NvcGUiLCJnZXRTY29wZUxhYmVsIiwiQXBwU3RhdGVQcm92aWRlciIsImdldEN1cnJlbnRQcm9qZWN0Q29uZmlnIiwiZ2V0R2xvYmFsQ29uZmlnIiwic2F2ZUN1cnJlbnRQcm9qZWN0Q29uZmlnIiwiaXNGc0luYWNjZXNzaWJsZSIsImdyYWNlZnVsU2h1dGRvd24iLCJzYWZlUGFyc2VKU09OIiwiZ2V0UGxhdGZvcm0iLCJjbGlFcnJvciIsImNsaU9rIiwiY2hlY2tNY3BTZXJ2ZXJIZWFsdGgiLCJuYW1lIiwic2VydmVyIiwiUHJvbWlzZSIsInJlc3VsdCIsInR5cGUiLCJfZXJyb3IiLCJtY3BTZXJ2ZUhhbmRsZXIiLCJkZWJ1ZyIsInZlcmJvc2UiLCJwcm92aWRlZEN3ZCIsImVycm9yIiwic2V0dXAiLCJ1bmRlZmluZWQiLCJzdGFydE1DUFNlcnZlciIsIm1jcFJlbW92ZUhhbmRsZXIiLCJvcHRpb25zIiwic2NvcGUiLCJzZXJ2ZXJCZWZvcmVSZW1vdmFsIiwiY2xlYW51cFNlY3VyZVN0b3JhZ2UiLCJwcm9jZXNzIiwic3Rkb3V0Iiwid3JpdGUiLCJwcm9qZWN0Q29uZmlnIiwiZ2xvYmFsQ29uZmlnIiwic2VydmVycyIsInByb2plY3RTZXJ2ZXJzIiwibWNwSnNvbkV4aXN0cyIsInNjb3BlcyIsIkFycmF5IiwiRXhjbHVkZSIsIm1jcFNlcnZlcnMiLCJwdXNoIiwibGVuZ3RoIiwic3RkZXJyIiwiZm9yRWFjaCIsIkVycm9yIiwibWVzc2FnZSIsIm1jcExpc3RIYW5kbGVyIiwiY29uZmlncyIsIk9iamVjdCIsImtleXMiLCJjb25zb2xlIiwibG9nIiwiZW50cmllcyIsInJlc3VsdHMiLCJzdGF0dXMiLCJjb25jdXJyZW5jeSIsInVybCIsImFyZ3MiLCJpc0FycmF5IiwiY29tbWFuZCIsImpvaW4iLCJtY3BHZXRIYW5kbGVyIiwiaGVhZGVycyIsImtleSIsInZhbHVlIiwib2F1dGgiLCJjbGllbnRJZCIsImNhbGxiYWNrUG9ydCIsInBhcnRzIiwiY2xpZW50Q29uZmlnIiwiY2xpZW50U2VjcmV0IiwiZW52IiwibWNwQWRkSnNvbkhhbmRsZXIiLCJqc29uIiwicGFyc2VkSnNvbiIsIm5lZWRzU2VjcmV0IiwidHJhbnNwb3J0VHlwZSIsIlN0cmluZyIsInNvdXJjZSIsIm1jcEFkZEZyb21EZXNrdG9wSGFuZGxlciIsInBsYXRmb3JtIiwicmVhZENsYXVkZURlc2t0b3BNY3BTZXJ2ZXJzIiwidW5tb3VudCIsImV4aXRPbkN0cmxDIiwibWNwUmVzZXRDaG9pY2VzSGFuZGxlciIsImN1cnJlbnQiLCJlbmFibGVkTWNwanNvblNlcnZlcnMiLCJkaXNhYmxlZE1jcGpzb25TZXJ2ZXJzIiwiZW5hYmxlQWxsUHJvamVjdE1jcFNlcnZlcnMiXSwic291cmNlcyI6WyJtY3AudHN4Il0sInNvdXJjZXNDb250ZW50IjpbIi8qKlxuICogTUNQIHN1YmNvbW1hbmQgaGFuZGxlcnMg4oCUIGV4dHJhY3RlZCBmcm9tIG1haW4udHN4IGZvciBsYXp5IGxvYWRpbmcuXG4gKiBUaGVzZSBhcmUgZHluYW1pY2FsbHkgaW1wb3J0ZWQgb25seSB3aGVuIHRoZSBjb3JyZXNwb25kaW5nIGBjbGF1ZGUgbWNwICpgIGNvbW1hbmQgcnVucy5cbiAqL1xuXG5pbXBvcnQgeyBzdGF0IH0gZnJvbSAnZnMvcHJvbWlzZXMnXG5pbXBvcnQgcE1hcCBmcm9tICdwLW1hcCdcbmltcG9ydCB7IGN3ZCB9IGZyb20gJ3Byb2Nlc3MnXG5pbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBNQ1BTZXJ2ZXJEZXNrdG9wSW1wb3J0RGlhbG9nIH0gZnJvbSAnLi4vLi4vY29tcG9uZW50cy9NQ1BTZXJ2ZXJEZXNrdG9wSW1wb3J0RGlhbG9nLmpzJ1xuaW1wb3J0IHsgcmVuZGVyIH0gZnJvbSAnLi4vLi4vaW5rLmpzJ1xuaW1wb3J0IHsgS2V5YmluZGluZ1NldHVwIH0gZnJvbSAnLi4vLi4va2V5YmluZGluZ3MvS2V5YmluZGluZ1Byb3ZpZGVyU2V0dXAuanMnXG5pbXBvcnQge1xuICB0eXBlIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gIGxvZ0V2ZW50LFxufSBmcm9tICcuLi8uLi9zZXJ2aWNlcy9hbmFseXRpY3MvaW5kZXguanMnXG5pbXBvcnQge1xuICBjbGVhck1jcENsaWVudENvbmZpZyxcbiAgY2xlYXJTZXJ2ZXJUb2tlbnNGcm9tTG9jYWxTdG9yYWdlLFxuICBnZXRNY3BDbGllbnRDb25maWcsXG4gIHJlYWRDbGllbnRTZWNyZXQsXG4gIHNhdmVNY3BDbGllbnRTZWNyZXQsXG59IGZyb20gJy4uLy4uL3NlcnZpY2VzL21jcC9hdXRoLmpzJ1xuaW1wb3J0IHtcbiAgY29ubmVjdFRvU2VydmVyLFxuICBnZXRNY3BTZXJ2ZXJDb25uZWN0aW9uQmF0Y2hTaXplLFxufSBmcm9tICcuLi8uLi9zZXJ2aWNlcy9tY3AvY2xpZW50LmpzJ1xuaW1wb3J0IHtcbiAgYWRkTWNwQ29uZmlnLFxuICBnZXRBbGxNY3BDb25maWdzLFxuICBnZXRNY3BDb25maWdCeU5hbWUsXG4gIGdldE1jcENvbmZpZ3NCeVNjb3BlLFxuICByZW1vdmVNY3BDb25maWcsXG59IGZyb20gJy4uLy4uL3NlcnZpY2VzL21jcC9jb25maWcuanMnXG5pbXBvcnQgdHlwZSB7XG4gIENvbmZpZ1Njb3BlLFxuICBTY29wZWRNY3BTZXJ2ZXJDb25maWcsXG59IGZyb20gJy4uLy4uL3NlcnZpY2VzL21jcC90eXBlcy5qcydcbmltcG9ydCB7XG4gIGRlc2NyaWJlTWNwQ29uZmlnRmlsZVBhdGgsXG4gIGVuc3VyZUNvbmZpZ1Njb3BlLFxuICBnZXRTY29wZUxhYmVsLFxufSBmcm9tICcuLi8uLi9zZXJ2aWNlcy9tY3AvdXRpbHMuanMnXG5pbXBvcnQgeyBBcHBTdGF0ZVByb3ZpZGVyIH0gZnJvbSAnLi4vLi4vc3RhdGUvQXBwU3RhdGUuanMnXG5pbXBvcnQge1xuICBnZXRDdXJyZW50UHJvamVjdENvbmZpZyxcbiAgZ2V0R2xvYmFsQ29uZmlnLFxuICBzYXZlQ3VycmVudFByb2plY3RDb25maWcsXG59IGZyb20gJy4uLy4uL3V0aWxzL2NvbmZpZy5qcydcbmltcG9ydCB7IGlzRnNJbmFjY2Vzc2libGUgfSBmcm9tICcuLi8uLi91dGlscy9lcnJvcnMuanMnXG5pbXBvcnQgeyBncmFjZWZ1bFNodXRkb3duIH0gZnJvbSAnLi4vLi4vdXRpbHMvZ3JhY2VmdWxTaHV0ZG93bi5qcydcbmltcG9ydCB7IHNhZmVQYXJzZUpTT04gfSBmcm9tICcuLi8uLi91dGlscy9qc29uLmpzJ1xuaW1wb3J0IHsgZ2V0UGxhdGZvcm0gfSBmcm9tICcuLi8uLi91dGlscy9wbGF0Zm9ybS5qcydcbmltcG9ydCB7IGNsaUVycm9yLCBjbGlPayB9IGZyb20gJy4uL2V4aXQuanMnXG5cbmFzeW5jIGZ1bmN0aW9uIGNoZWNrTWNwU2VydmVySGVhbHRoKFxuICBuYW1lOiBzdHJpbmcsXG4gIHNlcnZlcjogU2NvcGVkTWNwU2VydmVyQ29uZmlnLFxuKTogUHJvbWlzZTxzdHJpbmc+IHtcbiAgdHJ5IHtcbiAgICBjb25zdCByZXN1bHQgPSBhd2FpdCBjb25uZWN0VG9TZXJ2ZXIobmFtZSwgc2VydmVyKVxuICAgIGlmIChyZXN1bHQudHlwZSA9PT0gJ2Nvbm5lY3RlZCcpIHtcbiAgICAgIHJldHVybiAn4pyTIENvbm5lY3RlZCdcbiAgICB9IGVsc2UgaWYgKHJlc3VsdC50eXBlID09PSAnbmVlZHMtYXV0aCcpIHtcbiAgICAgIHJldHVybiAnISBOZWVkcyBhdXRoZW50aWNhdGlvbidcbiAgICB9IGVsc2Uge1xuICAgICAgcmV0dXJuICfinJcgRmFpbGVkIHRvIGNvbm5lY3QnXG4gICAgfVxuICB9IGNhdGNoIChfZXJyb3IpIHtcbiAgICByZXR1cm4gJ+KclyBDb25uZWN0aW9uIGVycm9yJ1xuICB9XG59XG5cbi8vIG1jcCBzZXJ2ZSAobGluZXMgNDUxMuKAkzQ1MzIpXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gbWNwU2VydmVIYW5kbGVyKHtcbiAgZGVidWcsXG4gIHZlcmJvc2UsXG59OiB7XG4gIGRlYnVnPzogYm9vbGVhblxuICB2ZXJib3NlPzogYm9vbGVhblxufSk6IFByb21pc2U8dm9pZD4ge1xuICBjb25zdCBwcm92aWRlZEN3ZCA9IGN3ZCgpXG4gIGxvZ0V2ZW50KCd0ZW5ndV9tY3Bfc3RhcnQnLCB7fSlcblxuICB0cnkge1xuICAgIGF3YWl0IHN0YXQocHJvdmlkZWRDd2QpXG4gIH0gY2F0Y2ggKGVycm9yKSB7XG4gICAgaWYgKGlzRnNJbmFjY2Vzc2libGUoZXJyb3IpKSB7XG4gICAgICBjbGlFcnJvcihgRXJyb3I6IERpcmVjdG9yeSAke3Byb3ZpZGVkQ3dkfSBkb2VzIG5vdCBleGlzdGApXG4gICAgfVxuICAgIHRocm93IGVycm9yXG4gIH1cblxuICB0cnkge1xuICAgIGNvbnN0IHsgc2V0dXAgfSA9IGF3YWl0IGltcG9ydCgnLi4vLi4vc2V0dXAuanMnKVxuICAgIGF3YWl0IHNldHVwKHByb3ZpZGVkQ3dkLCAnZGVmYXVsdCcsIGZhbHNlLCBmYWxzZSwgdW5kZWZpbmVkLCBmYWxzZSlcbiAgICBjb25zdCB7IHN0YXJ0TUNQU2VydmVyIH0gPSBhd2FpdCBpbXBvcnQoJy4uLy4uL2VudHJ5cG9pbnRzL21jcC5qcycpXG4gICAgYXdhaXQgc3RhcnRNQ1BTZXJ2ZXIocHJvdmlkZWRDd2QsIGRlYnVnID8/IGZhbHNlLCB2ZXJib3NlID8/IGZhbHNlKVxuICB9IGNhdGNoIChlcnJvcikge1xuICAgIGNsaUVycm9yKGBFcnJvcjogRmFpbGVkIHRvIHN0YXJ0IE1DUCBzZXJ2ZXI6ICR7ZXJyb3J9YClcbiAgfVxufVxuXG4vLyBtY3AgcmVtb3ZlIChsaW5lcyA0NTQ14oCTNDYzNSlcbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBtY3BSZW1vdmVIYW5kbGVyKFxuICBuYW1lOiBzdHJpbmcsXG4gIG9wdGlvbnM6IHsgc2NvcGU/OiBzdHJpbmcgfSxcbik6IFByb21pc2U8dm9pZD4ge1xuICAvLyBMb29rIHVwIGNvbmZpZyBiZWZvcmUgcmVtb3Zpbmcgc28gd2UgY2FuIGNsZWFuIHVwIHNlY3VyZSBzdG9yYWdlXG4gIGNvbnN0IHNlcnZlckJlZm9yZVJlbW92YWwgPSBnZXRNY3BDb25maWdCeU5hbWUobmFtZSlcblxuICBjb25zdCBjbGVhbnVwU2VjdXJlU3RvcmFnZSA9ICgpID0+IHtcbiAgICBpZiAoXG4gICAgICBzZXJ2ZXJCZWZvcmVSZW1vdmFsICYmXG4gICAgICAoc2VydmVyQmVmb3JlUmVtb3ZhbC50eXBlID09PSAnc3NlJyB8fFxuICAgICAgICBzZXJ2ZXJCZWZvcmVSZW1vdmFsLnR5cGUgPT09ICdodHRwJylcbiAgICApIHtcbiAgICAgIGNsZWFyU2VydmVyVG9rZW5zRnJvbUxvY2FsU3RvcmFnZShuYW1lLCBzZXJ2ZXJCZWZvcmVSZW1vdmFsKVxuICAgICAgY2xlYXJNY3BDbGllbnRDb25maWcobmFtZSwgc2VydmVyQmVmb3JlUmVtb3ZhbClcbiAgICB9XG4gIH1cblxuICB0cnkge1xuICAgIGlmIChvcHRpb25zLnNjb3BlKSB7XG4gICAgICBjb25zdCBzY29wZSA9IGVuc3VyZUNvbmZpZ1Njb3BlKG9wdGlvbnMuc2NvcGUpXG4gICAgICBsb2dFdmVudCgndGVuZ3VfbWNwX2RlbGV0ZScsIHtcbiAgICAgICAgbmFtZTogbmFtZSBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICAgICAgICBzY29wZTpcbiAgICAgICAgICBzY29wZSBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICAgICAgfSlcblxuICAgICAgYXdhaXQgcmVtb3ZlTWNwQ29uZmlnKG5hbWUsIHNjb3BlKVxuICAgICAgY2xlYW51cFNlY3VyZVN0b3JhZ2UoKVxuICAgICAgcHJvY2Vzcy5zdGRvdXQud3JpdGUoYFJlbW92ZWQgTUNQIHNlcnZlciAke25hbWV9IGZyb20gJHtzY29wZX0gY29uZmlnXFxuYClcbiAgICAgIGNsaU9rKGBGaWxlIG1vZGlmaWVkOiAke2Rlc2NyaWJlTWNwQ29uZmlnRmlsZVBhdGgoc2NvcGUpfWApXG4gICAgfVxuXG4gICAgLy8gSWYgbm8gc2NvcGUgc3BlY2lmaWVkLCBjaGVjayB3aGVyZSB0aGUgc2VydmVyIGV4aXN0c1xuICAgIGNvbnN0IHByb2plY3RDb25maWcgPSBnZXRDdXJyZW50UHJvamVjdENvbmZpZygpXG4gICAgY29uc3QgZ2xvYmFsQ29uZmlnID0gZ2V0R2xvYmFsQ29uZmlnKClcblxuICAgIC8vIENoZWNrIGlmIHNlcnZlciBleGlzdHMgaW4gcHJvamVjdCBzY29wZSAoLm1jcC5qc29uKVxuICAgIGNvbnN0IHsgc2VydmVyczogcHJvamVjdFNlcnZlcnMgfSA9IGdldE1jcENvbmZpZ3NCeVNjb3BlKCdwcm9qZWN0JylcbiAgICBjb25zdCBtY3BKc29uRXhpc3RzID0gISFwcm9qZWN0U2VydmVyc1tuYW1lXVxuXG4gICAgLy8gQ291bnQgaG93IG1hbnkgc2NvcGVzIGNvbnRhaW4gdGhpcyBzZXJ2ZXJcbiAgICBjb25zdCBzY29wZXM6IEFycmF5PEV4Y2x1ZGU8Q29uZmlnU2NvcGUsICdkeW5hbWljJz4+ID0gW11cbiAgICBpZiAocHJvamVjdENvbmZpZy5tY3BTZXJ2ZXJzPy5bbmFtZV0pIHNjb3Blcy5wdXNoKCdsb2NhbCcpXG4gICAgaWYgKG1jcEpzb25FeGlzdHMpIHNjb3Blcy5wdXNoKCdwcm9qZWN0JylcbiAgICBpZiAoZ2xvYmFsQ29uZmlnLm1jcFNlcnZlcnM/LltuYW1lXSkgc2NvcGVzLnB1c2goJ3VzZXInKVxuXG4gICAgaWYgKHNjb3Blcy5sZW5ndGggPT09IDApIHtcbiAgICAgIGNsaUVycm9yKGBObyBNQ1Agc2VydmVyIGZvdW5kIHdpdGggbmFtZTogXCIke25hbWV9XCJgKVxuICAgIH0gZWxzZSBpZiAoc2NvcGVzLmxlbmd0aCA9PT0gMSkge1xuICAgICAgLy8gU2VydmVyIGV4aXN0cyBpbiBvbmx5IG9uZSBzY29wZSwgcmVtb3ZlIGl0XG4gICAgICBjb25zdCBzY29wZSA9IHNjb3Blc1swXSFcbiAgICAgIGxvZ0V2ZW50KCd0ZW5ndV9tY3BfZGVsZXRlJywge1xuICAgICAgICBuYW1lOiBuYW1lIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgICAgIHNjb3BlOlxuICAgICAgICAgIHNjb3BlIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgICB9KVxuXG4gICAgICBhd2FpdCByZW1vdmVNY3BDb25maWcobmFtZSwgc2NvcGUpXG4gICAgICBjbGVhbnVwU2VjdXJlU3RvcmFnZSgpXG4gICAgICBwcm9jZXNzLnN0ZG91dC53cml0ZShcbiAgICAgICAgYFJlbW92ZWQgTUNQIHNlcnZlciBcIiR7bmFtZX1cIiBmcm9tICR7c2NvcGV9IGNvbmZpZ1xcbmAsXG4gICAgICApXG4gICAgICBjbGlPayhgRmlsZSBtb2RpZmllZDogJHtkZXNjcmliZU1jcENvbmZpZ0ZpbGVQYXRoKHNjb3BlKX1gKVxuICAgIH0gZWxzZSB7XG4gICAgICAvLyBTZXJ2ZXIgZXhpc3RzIGluIG11bHRpcGxlIHNjb3Blc1xuICAgICAgcHJvY2Vzcy5zdGRlcnIud3JpdGUoYE1DUCBzZXJ2ZXIgXCIke25hbWV9XCIgZXhpc3RzIGluIG11bHRpcGxlIHNjb3BlczpcXG5gKVxuICAgICAgc2NvcGVzLmZvckVhY2goc2NvcGUgPT4ge1xuICAgICAgICBwcm9jZXNzLnN0ZGVyci53cml0ZShcbiAgICAgICAgICBgICAtICR7Z2V0U2NvcGVMYWJlbChzY29wZSl9ICgke2Rlc2NyaWJlTWNwQ29uZmlnRmlsZVBhdGgoc2NvcGUpfSlcXG5gLFxuICAgICAgICApXG4gICAgICB9KVxuICAgICAgcHJvY2Vzcy5zdGRlcnIud3JpdGUoJ1xcblRvIHJlbW92ZSBmcm9tIGEgc3BlY2lmaWMgc2NvcGUsIHVzZTpcXG4nKVxuICAgICAgc2NvcGVzLmZvckVhY2goc2NvcGUgPT4ge1xuICAgICAgICBwcm9jZXNzLnN0ZGVyci53cml0ZShgICBjbGF1ZGUgbWNwIHJlbW92ZSBcIiR7bmFtZX1cIiAtcyAke3Njb3BlfVxcbmApXG4gICAgICB9KVxuICAgICAgY2xpRXJyb3IoKVxuICAgIH1cbiAgfSBjYXRjaCAoZXJyb3IpIHtcbiAgICBjbGlFcnJvcigoZXJyb3IgYXMgRXJyb3IpLm1lc3NhZ2UpXG4gIH1cbn1cblxuLy8gbWNwIGxpc3QgKGxpbmVzIDQ2NDHigJM0Njg4KVxuZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIG1jcExpc3RIYW5kbGVyKCk6IFByb21pc2U8dm9pZD4ge1xuICBsb2dFdmVudCgndGVuZ3VfbWNwX2xpc3QnLCB7fSlcbiAgY29uc3QgeyBzZXJ2ZXJzOiBjb25maWdzIH0gPSBhd2FpdCBnZXRBbGxNY3BDb25maWdzKClcbiAgaWYgKE9iamVjdC5rZXlzKGNvbmZpZ3MpLmxlbmd0aCA9PT0gMCkge1xuICAgIC8vIGJpb21lLWlnbm9yZSBsaW50L3N1c3BpY2lvdXMvbm9Db25zb2xlOjogaW50ZW50aW9uYWwgY29uc29sZSBvdXRwdXRcbiAgICBjb25zb2xlLmxvZyhcbiAgICAgICdObyBNQ1Agc2VydmVycyBjb25maWd1cmVkLiBVc2UgYGNsYXVkZSBtY3AgYWRkYCB0byBhZGQgYSBzZXJ2ZXIuJyxcbiAgICApXG4gIH0gZWxzZSB7XG4gICAgLy8gYmlvbWUtaWdub3JlIGxpbnQvc3VzcGljaW91cy9ub0NvbnNvbGU6OiBpbnRlbnRpb25hbCBjb25zb2xlIG91dHB1dFxuICAgIGNvbnNvbGUubG9nKCdDaGVja2luZyBNQ1Agc2VydmVyIGhlYWx0aC4uLlxcbicpXG5cbiAgICAvLyBDaGVjayBzZXJ2ZXJzIGNvbmN1cnJlbnRseVxuICAgIGNvbnN0IGVudHJpZXMgPSBPYmplY3QuZW50cmllcyhjb25maWdzKVxuICAgIGNvbnN0IHJlc3VsdHMgPSBhd2FpdCBwTWFwKFxuICAgICAgZW50cmllcyxcbiAgICAgIGFzeW5jIChbbmFtZSwgc2VydmVyXSkgPT4gKHtcbiAgICAgICAgbmFtZSxcbiAgICAgICAgc2VydmVyLFxuICAgICAgICBzdGF0dXM6IGF3YWl0IGNoZWNrTWNwU2VydmVySGVhbHRoKG5hbWUsIHNlcnZlciksXG4gICAgICB9KSxcbiAgICAgIHsgY29uY3VycmVuY3k6IGdldE1jcFNlcnZlckNvbm5lY3Rpb25CYXRjaFNpemUoKSB9LFxuICAgIClcblxuICAgIGZvciAoY29uc3QgeyBuYW1lLCBzZXJ2ZXIsIHN0YXR1cyB9IG9mIHJlc3VsdHMpIHtcbiAgICAgIC8vIEludGVudGlvbmFsbHkgZXhjbHVkaW5nIHNzZS1pZGUgc2VydmVycyBoZXJlIHNpbmNlIHRoZXkncmUgaW50ZXJuYWxcbiAgICAgIGlmIChzZXJ2ZXIudHlwZSA9PT0gJ3NzZScpIHtcbiAgICAgICAgLy8gYmlvbWUtaWdub3JlIGxpbnQvc3VzcGljaW91cy9ub0NvbnNvbGU6OiBpbnRlbnRpb25hbCBjb25zb2xlIG91dHB1dFxuICAgICAgICBjb25zb2xlLmxvZyhgJHtuYW1lfTogJHtzZXJ2ZXIudXJsfSAoU1NFKSAtICR7c3RhdHVzfWApXG4gICAgICB9IGVsc2UgaWYgKHNlcnZlci50eXBlID09PSAnaHR0cCcpIHtcbiAgICAgICAgLy8gYmlvbWUtaWdub3JlIGxpbnQvc3VzcGljaW91cy9ub0NvbnNvbGU6OiBpbnRlbnRpb25hbCBjb25zb2xlIG91dHB1dFxuICAgICAgICBjb25zb2xlLmxvZyhgJHtuYW1lfTogJHtzZXJ2ZXIudXJsfSAoSFRUUCkgLSAke3N0YXR1c31gKVxuICAgICAgfSBlbHNlIGlmIChzZXJ2ZXIudHlwZSA9PT0gJ2NsYXVkZWFpLXByb3h5Jykge1xuICAgICAgICAvLyBiaW9tZS1pZ25vcmUgbGludC9zdXNwaWNpb3VzL25vQ29uc29sZTo6IGludGVudGlvbmFsIGNvbnNvbGUgb3V0cHV0XG4gICAgICAgIGNvbnNvbGUubG9nKGAke25hbWV9OiAke3NlcnZlci51cmx9IC0gJHtzdGF0dXN9YClcbiAgICAgIH0gZWxzZSBpZiAoIXNlcnZlci50eXBlIHx8IHNlcnZlci50eXBlID09PSAnc3RkaW8nKSB7XG4gICAgICAgIGNvbnN0IGFyZ3MgPSBBcnJheS5pc0FycmF5KHNlcnZlci5hcmdzKSA/IHNlcnZlci5hcmdzIDogW11cbiAgICAgICAgLy8gYmlvbWUtaWdub3JlIGxpbnQvc3VzcGljaW91cy9ub0NvbnNvbGU6OiBpbnRlbnRpb25hbCBjb25zb2xlIG91dHB1dFxuICAgICAgICBjb25zb2xlLmxvZyhgJHtuYW1lfTogJHtzZXJ2ZXIuY29tbWFuZH0gJHthcmdzLmpvaW4oJyAnKX0gLSAke3N0YXR1c31gKVxuICAgICAgfVxuICAgIH1cbiAgfVxuICAvLyBVc2UgZ3JhY2VmdWxTaHV0ZG93biB0byBwcm9wZXJseSBjbGVhbiB1cCBNQ1Agc2VydmVyIGNvbm5lY3Rpb25zXG4gIC8vIChwcm9jZXNzLmV4aXQgYnlwYXNzZXMgY2xlYW51cCBoYW5kbGVycywgbGVhdmluZyBjaGlsZCBwcm9jZXNzZXMgb3JwaGFuZWQpXG4gIGF3YWl0IGdyYWNlZnVsU2h1dGRvd24oMClcbn1cblxuLy8gbWNwIGdldCAobGluZXMgNDY5NOKAkzQ3ODYpXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gbWNwR2V0SGFuZGxlcihuYW1lOiBzdHJpbmcpOiBQcm9taXNlPHZvaWQ+IHtcbiAgbG9nRXZlbnQoJ3Rlbmd1X21jcF9nZXQnLCB7XG4gICAgbmFtZTogbmFtZSBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICB9KVxuICBjb25zdCBzZXJ2ZXIgPSBnZXRNY3BDb25maWdCeU5hbWUobmFtZSlcbiAgaWYgKCFzZXJ2ZXIpIHtcbiAgICBjbGlFcnJvcihgTm8gTUNQIHNlcnZlciBmb3VuZCB3aXRoIG5hbWU6ICR7bmFtZX1gKVxuICB9XG5cbiAgLy8gYmlvbWUtaWdub3JlIGxpbnQvc3VzcGljaW91cy9ub0NvbnNvbGU6OiBpbnRlbnRpb25hbCBjb25zb2xlIG91dHB1dFxuICBjb25zb2xlLmxvZyhgJHtuYW1lfTpgKVxuICAvLyBiaW9tZS1pZ25vcmUgbGludC9zdXNwaWNpb3VzL25vQ29uc29sZTo6IGludGVudGlvbmFsIGNvbnNvbGUgb3V0cHV0XG4gIGNvbnNvbGUubG9nKGAgIFNjb3BlOiAke2dldFNjb3BlTGFiZWwoc2VydmVyLnNjb3BlKX1gKVxuXG4gIC8vIENoZWNrIHNlcnZlciBoZWFsdGhcbiAgY29uc3Qgc3RhdHVzID0gYXdhaXQgY2hlY2tNY3BTZXJ2ZXJIZWFsdGgobmFtZSwgc2VydmVyKVxuICAvLyBiaW9tZS1pZ25vcmUgbGludC9zdXNwaWNpb3VzL25vQ29uc29sZTo6IGludGVudGlvbmFsIGNvbnNvbGUgb3V0cHV0XG4gIGNvbnNvbGUubG9nKGAgIFN0YXR1czogJHtzdGF0dXN9YClcblxuICAvLyBJbnRlbnRpb25hbGx5IGV4Y2x1ZGluZyBzc2UtaWRlIHNlcnZlcnMgaGVyZSBzaW5jZSB0aGV5J3JlIGludGVybmFsXG4gIGlmIChzZXJ2ZXIudHlwZSA9PT0gJ3NzZScpIHtcbiAgICAvLyBiaW9tZS1pZ25vcmUgbGludC9zdXNwaWNpb3VzL25vQ29uc29sZTo6IGludGVudGlvbmFsIGNvbnNvbGUgb3V0cHV0XG4gICAgY29uc29sZS5sb2coYCAgVHlwZTogc3NlYClcbiAgICAvLyBiaW9tZS1pZ25vcmUgbGludC9zdXNwaWNpb3VzL25vQ29uc29sZTo6IGludGVudGlvbmFsIGNvbnNvbGUgb3V0cHV0XG4gICAgY29uc29sZS5sb2coYCAgVVJMOiAke3NlcnZlci51cmx9YClcbiAgICBpZiAoc2VydmVyLmhlYWRlcnMpIHtcbiAgICAgIC8vIGJpb21lLWlnbm9yZSBsaW50L3N1c3BpY2lvdXMvbm9Db25zb2xlOjogaW50ZW50aW9uYWwgY29uc29sZSBvdXRwdXRcbiAgICAgIGNvbnNvbGUubG9nKCcgIEhlYWRlcnM6JylcbiAgICAgIGZvciAoY29uc3QgW2tleSwgdmFsdWVdIG9mIE9iamVjdC5lbnRyaWVzKHNlcnZlci5oZWFkZXJzKSkge1xuICAgICAgICAvLyBiaW9tZS1pZ25vcmUgbGludC9zdXNwaWNpb3VzL25vQ29uc29sZTo6IGludGVudGlvbmFsIGNvbnNvbGUgb3V0cHV0XG4gICAgICAgIGNvbnNvbGUubG9nKGAgICAgJHtrZXl9OiAke3ZhbHVlfWApXG4gICAgICB9XG4gICAgfVxuICAgIGlmIChzZXJ2ZXIub2F1dGg/LmNsaWVudElkIHx8IHNlcnZlci5vYXV0aD8uY2FsbGJhY2tQb3J0KSB7XG4gICAgICBjb25zdCBwYXJ0czogc3RyaW5nW10gPSBbXVxuICAgICAgaWYgKHNlcnZlci5vYXV0aC5jbGllbnRJZCkge1xuICAgICAgICBwYXJ0cy5wdXNoKCdjbGllbnRfaWQgY29uZmlndXJlZCcpXG4gICAgICAgIGNvbnN0IGNsaWVudENvbmZpZyA9IGdldE1jcENsaWVudENvbmZpZyhuYW1lLCBzZXJ2ZXIpXG4gICAgICAgIGlmIChjbGllbnRDb25maWc/LmNsaWVudFNlY3JldCkgcGFydHMucHVzaCgnY2xpZW50X3NlY3JldCBjb25maWd1cmVkJylcbiAgICAgIH1cbiAgICAgIGlmIChzZXJ2ZXIub2F1dGguY2FsbGJhY2tQb3J0KVxuICAgICAgICBwYXJ0cy5wdXNoKGBjYWxsYmFja19wb3J0ICR7c2VydmVyLm9hdXRoLmNhbGxiYWNrUG9ydH1gKVxuICAgICAgLy8gYmlvbWUtaWdub3JlIGxpbnQvc3VzcGljaW91cy9ub0NvbnNvbGU6OiBpbnRlbnRpb25hbCBjb25zb2xlIG91dHB1dFxuICAgICAgY29uc29sZS5sb2coYCAgT0F1dGg6ICR7cGFydHMuam9pbignLCAnKX1gKVxuICAgIH1cbiAgfSBlbHNlIGlmIChzZXJ2ZXIudHlwZSA9PT0gJ2h0dHAnKSB7XG4gICAgLy8gYmlvbWUtaWdub3JlIGxpbnQvc3VzcGljaW91cy9ub0NvbnNvbGU6OiBpbnRlbnRpb25hbCBjb25zb2xlIG91dHB1dFxuICAgIGNvbnNvbGUubG9nKGAgIFR5cGU6IGh0dHBgKVxuICAgIC8vIGJpb21lLWlnbm9yZSBsaW50L3N1c3BpY2lvdXMvbm9Db25zb2xlOjogaW50ZW50aW9uYWwgY29uc29sZSBvdXRwdXRcbiAgICBjb25zb2xlLmxvZyhgICBVUkw6ICR7c2VydmVyLnVybH1gKVxuICAgIGlmIChzZXJ2ZXIuaGVhZGVycykge1xuICAgICAgLy8gYmlvbWUtaWdub3JlIGxpbnQvc3VzcGljaW91cy9ub0NvbnNvbGU6OiBpbnRlbnRpb25hbCBjb25zb2xlIG91dHB1dFxuICAgICAgY29uc29sZS5sb2coJyAgSGVhZGVyczonKVxuICAgICAgZm9yIChjb25zdCBba2V5LCB2YWx1ZV0gb2YgT2JqZWN0LmVudHJpZXMoc2VydmVyLmhlYWRlcnMpKSB7XG4gICAgICAgIC8vIGJpb21lLWlnbm9yZSBsaW50L3N1c3BpY2lvdXMvbm9Db25zb2xlOjogaW50ZW50aW9uYWwgY29uc29sZSBvdXRwdXRcbiAgICAgICAgY29uc29sZS5sb2coYCAgICAke2tleX06ICR7dmFsdWV9YClcbiAgICAgIH1cbiAgICB9XG4gICAgaWYgKHNlcnZlci5vYXV0aD8uY2xpZW50SWQgfHwgc2VydmVyLm9hdXRoPy5jYWxsYmFja1BvcnQpIHtcbiAgICAgIGNvbnN0IHBhcnRzOiBzdHJpbmdbXSA9IFtdXG4gICAgICBpZiAoc2VydmVyLm9hdXRoLmNsaWVudElkKSB7XG4gICAgICAgIHBhcnRzLnB1c2goJ2NsaWVudF9pZCBjb25maWd1cmVkJylcbiAgICAgICAgY29uc3QgY2xpZW50Q29uZmlnID0gZ2V0TWNwQ2xpZW50Q29uZmlnKG5hbWUsIHNlcnZlcilcbiAgICAgICAgaWYgKGNsaWVudENvbmZpZz8uY2xpZW50U2VjcmV0KSBwYXJ0cy5wdXNoKCdjbGllbnRfc2VjcmV0IGNvbmZpZ3VyZWQnKVxuICAgICAgfVxuICAgICAgaWYgKHNlcnZlci5vYXV0aC5jYWxsYmFja1BvcnQpXG4gICAgICAgIHBhcnRzLnB1c2goYGNhbGxiYWNrX3BvcnQgJHtzZXJ2ZXIub2F1dGguY2FsbGJhY2tQb3J0fWApXG4gICAgICAvLyBiaW9tZS1pZ25vcmUgbGludC9zdXNwaWNpb3VzL25vQ29uc29sZTo6IGludGVudGlvbmFsIGNvbnNvbGUgb3V0cHV0XG4gICAgICBjb25zb2xlLmxvZyhgICBPQXV0aDogJHtwYXJ0cy5qb2luKCcsICcpfWApXG4gICAgfVxuICB9IGVsc2UgaWYgKHNlcnZlci50eXBlID09PSAnc3RkaW8nKSB7XG4gICAgLy8gYmlvbWUtaWdub3JlIGxpbnQvc3VzcGljaW91cy9ub0NvbnNvbGU6OiBpbnRlbnRpb25hbCBjb25zb2xlIG91dHB1dFxuICAgIGNvbnNvbGUubG9nKGAgIFR5cGU6IHN0ZGlvYClcbiAgICAvLyBiaW9tZS1pZ25vcmUgbGludC9zdXNwaWNpb3VzL25vQ29uc29sZTo6IGludGVudGlvbmFsIGNvbnNvbGUgb3V0cHV0XG4gICAgY29uc29sZS5sb2coYCAgQ29tbWFuZDogJHtzZXJ2ZXIuY29tbWFuZH1gKVxuICAgIGNvbnN0IGFyZ3MgPSBBcnJheS5pc0FycmF5KHNlcnZlci5hcmdzKSA/IHNlcnZlci5hcmdzIDogW11cbiAgICAvLyBiaW9tZS1pZ25vcmUgbGludC9zdXNwaWNpb3VzL25vQ29uc29sZTo6IGludGVudGlvbmFsIGNvbnNvbGUgb3V0cHV0XG4gICAgY29uc29sZS5sb2coYCAgQXJnczogJHthcmdzLmpvaW4oJyAnKX1gKVxuICAgIGlmIChzZXJ2ZXIuZW52KSB7XG4gICAgICAvLyBiaW9tZS1pZ25vcmUgbGludC9zdXNwaWNpb3VzL25vQ29uc29sZTo6IGludGVudGlvbmFsIGNvbnNvbGUgb3V0cHV0XG4gICAgICBjb25zb2xlLmxvZygnICBFbnZpcm9ubWVudDonKVxuICAgICAgZm9yIChjb25zdCBba2V5LCB2YWx1ZV0gb2YgT2JqZWN0LmVudHJpZXMoc2VydmVyLmVudikpIHtcbiAgICAgICAgLy8gYmlvbWUtaWdub3JlIGxpbnQvc3VzcGljaW91cy9ub0NvbnNvbGU6OiBpbnRlbnRpb25hbCBjb25zb2xlIG91dHB1dFxuICAgICAgICBjb25zb2xlLmxvZyhgICAgICR7a2V5fT0ke3ZhbHVlfWApXG4gICAgICB9XG4gICAgfVxuICB9XG4gIC8vIGJpb21lLWlnbm9yZSBsaW50L3N1c3BpY2lvdXMvbm9Db25zb2xlOjogaW50ZW50aW9uYWwgY29uc29sZSBvdXRwdXRcbiAgY29uc29sZS5sb2coXG4gICAgYFxcblRvIHJlbW92ZSB0aGlzIHNlcnZlciwgcnVuOiBjbGF1ZGUgbWNwIHJlbW92ZSBcIiR7bmFtZX1cIiAtcyAke3NlcnZlci5zY29wZX1gLFxuICApXG4gIC8vIFVzZSBncmFjZWZ1bFNodXRkb3duIHRvIHByb3Blcmx5IGNsZWFuIHVwIE1DUCBzZXJ2ZXIgY29ubmVjdGlvbnNcbiAgLy8gKHByb2Nlc3MuZXhpdCBieXBhc3NlcyBjbGVhbnVwIGhhbmRsZXJzLCBsZWF2aW5nIGNoaWxkIHByb2Nlc3NlcyBvcnBoYW5lZClcbiAgYXdhaXQgZ3JhY2VmdWxTaHV0ZG93bigwKVxufVxuXG4vLyBtY3AgYWRkLWpzb24gKGxpbmVzIDQ4MDHigJM0ODcwKVxuZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIG1jcEFkZEpzb25IYW5kbGVyKFxuICBuYW1lOiBzdHJpbmcsXG4gIGpzb246IHN0cmluZyxcbiAgb3B0aW9uczogeyBzY29wZT86IHN0cmluZzsgY2xpZW50U2VjcmV0PzogdHJ1ZSB9LFxuKTogUHJvbWlzZTx2b2lkPiB7XG4gIHRyeSB7XG4gICAgY29uc3Qgc2NvcGUgPSBlbnN1cmVDb25maWdTY29wZShvcHRpb25zLnNjb3BlKVxuICAgIGNvbnN0IHBhcnNlZEpzb24gPSBzYWZlUGFyc2VKU09OKGpzb24pXG5cbiAgICAvLyBSZWFkIHNlY3JldCBiZWZvcmUgd3JpdGluZyBjb25maWcgc28gY2FuY2VsbGF0aW9uIGRvZXNuJ3QgbGVhdmUgcGFydGlhbCBzdGF0ZVxuICAgIGNvbnN0IG5lZWRzU2VjcmV0ID1cbiAgICAgIG9wdGlvbnMuY2xpZW50U2VjcmV0ICYmXG4gICAgICBwYXJzZWRKc29uICYmXG4gICAgICB0eXBlb2YgcGFyc2VkSnNvbiA9PT0gJ29iamVjdCcgJiZcbiAgICAgICd0eXBlJyBpbiBwYXJzZWRKc29uICYmXG4gICAgICAocGFyc2VkSnNvbi50eXBlID09PSAnc3NlJyB8fCBwYXJzZWRKc29uLnR5cGUgPT09ICdodHRwJykgJiZcbiAgICAgICd1cmwnIGluIHBhcnNlZEpzb24gJiZcbiAgICAgIHR5cGVvZiBwYXJzZWRKc29uLnVybCA9PT0gJ3N0cmluZycgJiZcbiAgICAgICdvYXV0aCcgaW4gcGFyc2VkSnNvbiAmJlxuICAgICAgcGFyc2VkSnNvbi5vYXV0aCAmJlxuICAgICAgdHlwZW9mIHBhcnNlZEpzb24ub2F1dGggPT09ICdvYmplY3QnICYmXG4gICAgICAnY2xpZW50SWQnIGluIHBhcnNlZEpzb24ub2F1dGhcbiAgICBjb25zdCBjbGllbnRTZWNyZXQgPSBuZWVkc1NlY3JldCA/IGF3YWl0IHJlYWRDbGllbnRTZWNyZXQoKSA6IHVuZGVmaW5lZFxuXG4gICAgYXdhaXQgYWRkTWNwQ29uZmlnKG5hbWUsIHBhcnNlZEpzb24sIHNjb3BlKVxuXG4gICAgY29uc3QgdHJhbnNwb3J0VHlwZSA9XG4gICAgICBwYXJzZWRKc29uICYmIHR5cGVvZiBwYXJzZWRKc29uID09PSAnb2JqZWN0JyAmJiAndHlwZScgaW4gcGFyc2VkSnNvblxuICAgICAgICA/IFN0cmluZyhwYXJzZWRKc29uLnR5cGUgfHwgJ3N0ZGlvJylcbiAgICAgICAgOiAnc3RkaW8nXG5cbiAgICBpZiAoXG4gICAgICBjbGllbnRTZWNyZXQgJiZcbiAgICAgIHBhcnNlZEpzb24gJiZcbiAgICAgIHR5cGVvZiBwYXJzZWRKc29uID09PSAnb2JqZWN0JyAmJlxuICAgICAgJ3R5cGUnIGluIHBhcnNlZEpzb24gJiZcbiAgICAgIChwYXJzZWRKc29uLnR5cGUgPT09ICdzc2UnIHx8IHBhcnNlZEpzb24udHlwZSA9PT0gJ2h0dHAnKSAmJlxuICAgICAgJ3VybCcgaW4gcGFyc2VkSnNvbiAmJlxuICAgICAgdHlwZW9mIHBhcnNlZEpzb24udXJsID09PSAnc3RyaW5nJ1xuICAgICkge1xuICAgICAgc2F2ZU1jcENsaWVudFNlY3JldChcbiAgICAgICAgbmFtZSxcbiAgICAgICAgeyB0eXBlOiBwYXJzZWRKc29uLnR5cGUsIHVybDogcGFyc2VkSnNvbi51cmwgfSxcbiAgICAgICAgY2xpZW50U2VjcmV0LFxuICAgICAgKVxuICAgIH1cblxuICAgIGxvZ0V2ZW50KCd0ZW5ndV9tY3BfYWRkJywge1xuICAgICAgc2NvcGU6XG4gICAgICAgIHNjb3BlIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgICBzb3VyY2U6XG4gICAgICAgICdqc29uJyBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICAgICAgdHlwZTogdHJhbnNwb3J0VHlwZSBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICAgIH0pXG5cbiAgICBjbGlPayhgQWRkZWQgJHt0cmFuc3BvcnRUeXBlfSBNQ1Agc2VydmVyICR7bmFtZX0gdG8gJHtzY29wZX0gY29uZmlnYClcbiAgfSBjYXRjaCAoZXJyb3IpIHtcbiAgICBjbGlFcnJvcigoZXJyb3IgYXMgRXJyb3IpLm1lc3NhZ2UpXG4gIH1cbn1cblxuLy8gbWNwIGFkZC1mcm9tLWNsYXVkZS1kZXNrdG9wIChsaW5lcyA0ODgx4oCTNDkyNylcbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBtY3BBZGRGcm9tRGVza3RvcEhhbmRsZXIob3B0aW9uczoge1xuICBzY29wZT86IHN0cmluZ1xufSk6IFByb21pc2U8dm9pZD4ge1xuICB0cnkge1xuICAgIGNvbnN0IHNjb3BlID0gZW5zdXJlQ29uZmlnU2NvcGUob3B0aW9ucy5zY29wZSlcbiAgICBjb25zdCBwbGF0Zm9ybSA9IGdldFBsYXRmb3JtKClcblxuICAgIGxvZ0V2ZW50KCd0ZW5ndV9tY3BfYWRkJywge1xuICAgICAgc2NvcGU6XG4gICAgICAgIHNjb3BlIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgICBwbGF0Zm9ybTpcbiAgICAgICAgcGxhdGZvcm0gYXMgQW5hbHl0aWNzTWV0YWRhdGFfSV9WRVJJRklFRF9USElTX0lTX05PVF9DT0RFX09SX0ZJTEVQQVRIUyxcbiAgICAgIHNvdXJjZTpcbiAgICAgICAgJ2Rlc2t0b3AnIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgfSlcblxuICAgIGNvbnN0IHsgcmVhZENsYXVkZURlc2t0b3BNY3BTZXJ2ZXJzIH0gPSBhd2FpdCBpbXBvcnQoXG4gICAgICAnLi4vLi4vdXRpbHMvY2xhdWRlRGVza3RvcC5qcydcbiAgICApXG4gICAgY29uc3Qgc2VydmVycyA9IGF3YWl0IHJlYWRDbGF1ZGVEZXNrdG9wTWNwU2VydmVycygpXG5cbiAgICBpZiAoT2JqZWN0LmtleXMoc2VydmVycykubGVuZ3RoID09PSAwKSB7XG4gICAgICBjbGlPayhcbiAgICAgICAgJ05vIE1DUCBzZXJ2ZXJzIGZvdW5kIGluIENsYXVkZSBEZXNrdG9wIGNvbmZpZ3VyYXRpb24gb3IgY29uZmlndXJhdGlvbiBmaWxlIGRvZXMgbm90IGV4aXN0LicsXG4gICAgICApXG4gICAgfVxuXG4gICAgY29uc3QgeyB1bm1vdW50IH0gPSBhd2FpdCByZW5kZXIoXG4gICAgICA8QXBwU3RhdGVQcm92aWRlcj5cbiAgICAgICAgPEtleWJpbmRpbmdTZXR1cD5cbiAgICAgICAgICA8TUNQU2VydmVyRGVza3RvcEltcG9ydERpYWxvZ1xuICAgICAgICAgICAgc2VydmVycz17c2VydmVyc31cbiAgICAgICAgICAgIHNjb3BlPXtzY29wZX1cbiAgICAgICAgICAgIG9uRG9uZT17KCkgPT4ge1xuICAgICAgICAgICAgICB1bm1vdW50KClcbiAgICAgICAgICAgIH19XG4gICAgICAgICAgLz5cbiAgICAgICAgPC9LZXliaW5kaW5nU2V0dXA+XG4gICAgICA8L0FwcFN0YXRlUHJvdmlkZXI+LFxuICAgICAgeyBleGl0T25DdHJsQzogdHJ1ZSB9LFxuICAgIClcbiAgfSBjYXRjaCAoZXJyb3IpIHtcbiAgICBjbGlFcnJvcigoZXJyb3IgYXMgRXJyb3IpLm1lc3NhZ2UpXG4gIH1cbn1cblxuLy8gbWNwIHJlc2V0LXByb2plY3QtY2hvaWNlcyAobGluZXMgNDkzNeKAkzQ5NTIpXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gbWNwUmVzZXRDaG9pY2VzSGFuZGxlcigpOiBQcm9taXNlPHZvaWQ+IHtcbiAgbG9nRXZlbnQoJ3Rlbmd1X21jcF9yZXNldF9tY3Bqc29uX2Nob2ljZXMnLCB7fSlcbiAgc2F2ZUN1cnJlbnRQcm9qZWN0Q29uZmlnKGN1cnJlbnQgPT4gKHtcbiAgICAuLi5jdXJyZW50LFxuICAgIGVuYWJsZWRNY3Bqc29uU2VydmVyczogW10sXG4gICAgZGlzYWJsZWRNY3Bqc29uU2VydmVyczogW10sXG4gICAgZW5hYmxlQWxsUHJvamVjdE1jcFNlcnZlcnM6IGZhbHNlLFxuICB9KSlcbiAgY2xpT2soXG4gICAgJ0FsbCBwcm9qZWN0LXNjb3BlZCAoLm1jcC5qc29uKSBzZXJ2ZXIgYXBwcm92YWxzIGFuZCByZWplY3Rpb25zIGhhdmUgYmVlbiByZXNldC5cXG4nICtcbiAgICAgICdZb3Ugd2lsbCBiZSBwcm9tcHRlZCBmb3IgYXBwcm92YWwgbmV4dCB0aW1lIHlvdSBzdGFydCBDbGF1ZGUgQ29kZS4nLFxuICApXG59XG4iXSwibWFwcGluZ3MiOiJBQUFBO0FBQ0E7QUFDQTtBQUNBOztBQUVBLFNBQVNBLElBQUksUUFBUSxhQUFhO0FBQ2xDLE9BQU9DLElBQUksTUFBTSxPQUFPO0FBQ3hCLFNBQVNDLEdBQUcsUUFBUSxTQUFTO0FBQzdCLE9BQU9DLEtBQUssTUFBTSxPQUFPO0FBQ3pCLFNBQVNDLDRCQUE0QixRQUFRLGtEQUFrRDtBQUMvRixTQUFTQyxNQUFNLFFBQVEsY0FBYztBQUNyQyxTQUFTQyxlQUFlLFFBQVEsOENBQThDO0FBQzlFLFNBQ0UsS0FBS0MsMERBQTBELEVBQy9EQyxRQUFRLFFBQ0gsbUNBQW1DO0FBQzFDLFNBQ0VDLG9CQUFvQixFQUNwQkMsaUNBQWlDLEVBQ2pDQyxrQkFBa0IsRUFDbEJDLGdCQUFnQixFQUNoQkMsbUJBQW1CLFFBQ2QsNEJBQTRCO0FBQ25DLFNBQ0VDLGVBQWUsRUFDZkMsK0JBQStCLFFBQzFCLDhCQUE4QjtBQUNyQyxTQUNFQyxZQUFZLEVBQ1pDLGdCQUFnQixFQUNoQkMsa0JBQWtCLEVBQ2xCQyxvQkFBb0IsRUFDcEJDLGVBQWUsUUFDViw4QkFBOEI7QUFDckMsY0FDRUMsV0FBVyxFQUNYQyxxQkFBcUIsUUFDaEIsNkJBQTZCO0FBQ3BDLFNBQ0VDLHlCQUF5QixFQUN6QkMsaUJBQWlCLEVBQ2pCQyxhQUFhLFFBQ1IsNkJBQTZCO0FBQ3BDLFNBQVNDLGdCQUFnQixRQUFRLHlCQUF5QjtBQUMxRCxTQUNFQyx1QkFBdUIsRUFDdkJDLGVBQWUsRUFDZkMsd0JBQXdCLFFBQ25CLHVCQUF1QjtBQUM5QixTQUFTQyxnQkFBZ0IsUUFBUSx1QkFBdUI7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 diff --git a/src/commands/mcp/doctorCommand.test.ts b/src/commands/mcp/doctorCommand.test.ts new file mode 100644 index 00000000..8e4754c4 --- /dev/null +++ b/src/commands/mcp/doctorCommand.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { Command } from '@commander-js/extra-typings' + +import { registerMcpDoctorCommand } from './doctorCommand.js' + +test('registerMcpDoctorCommand adds the doctor subcommand with expected options', () => { + const mcp = new Command('mcp') + + registerMcpDoctorCommand(mcp) + + const doctor = mcp.commands.find(command => command.name() === 'doctor') + assert.ok(doctor) + assert.equal(doctor?.usage(), '[options] [name]') + + const optionFlags = doctor?.options.map(option => option.long) + assert.deepEqual(optionFlags, ['--scope', '--config-only', '--json']) +}) diff --git a/src/commands/mcp/doctorCommand.ts b/src/commands/mcp/doctorCommand.ts new file mode 100644 index 00000000..75ed6a10 --- /dev/null +++ b/src/commands/mcp/doctorCommand.ts @@ -0,0 +1,25 @@ +/** + * MCP doctor CLI subcommand. + */ +import { type Command } from '@commander-js/extra-typings' + +export function registerMcpDoctorCommand(mcp: Command): void { + mcp + .command('doctor [name]') + .description( + 'Diagnose MCP configuration, precedence, disabled/pending state, and connection health. ' + + 'Note: unless --config-only is used, stdio servers may be spawned and remote servers may be contacted. ' + + 'Only use this command in directories you trust.', + ) + .option('-s, --scope ', 'Restrict config analysis to a specific scope (local, project, user, or enterprise)') + .option('--config-only', 'Skip live connection checks and only analyze configuration state') + .option('--json', 'Output the diagnostics report as JSON') + .action(async (name: string | undefined, options: { + scope?: string + configOnly?: boolean + json?: boolean + }) => { + const { mcpDoctorHandler } = await import('../../cli/handlers/mcp.js') + await mcpDoctorHandler(name, options) + }) +} diff --git a/src/main.tsx b/src/main.tsx index 07a3a3d2..9988dea7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -139,6 +139,7 @@ import { validateUuid } from './utils/uuid.js'; // Plugin startup checks are now handled non-blockingly in REPL.tsx import { registerMcpAddCommand } from 'src/commands/mcp/addCommand.js'; +import { registerMcpDoctorCommand } from 'src/commands/mcp/doctorCommand.js'; import { registerMcpXaaIdpCommand } from 'src/commands/mcp/xaaIdpCommand.js'; import { logPermissionContextForAnts } from 'src/services/internalLogging.js'; import { fetchClaudeAIMcpConfigsIfEligible } from 'src/services/mcp/claudeai.js'; @@ -3887,6 +3888,7 @@ async function run(): Promise { // Register the mcp add subcommand (extracted for testability) registerMcpAddCommand(mcp); + registerMcpDoctorCommand(mcp); if (isXaaEnabled()) { registerMcpXaaIdpCommand(mcp); } From fb27164ddfb9d4260bda7e1bd45f7c5361927073 Mon Sep 17 00:00:00 2001 From: gnanam1990 Date: Thu, 2 Apr 2026 14:55:05 +0530 Subject: [PATCH 32/35] fix(mcp): await failed transport cleanup on Windows Wait for failed MCP transport cleanup before command exit so targeted live checks do not crash on Windows. Co-Authored-By: Claude --- src/services/mcp/client.test.ts | 48 +++++++++++++++++++++++++++++++++ src/services/mcp/client.ts | 25 +++++++++++++---- 2 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 src/services/mcp/client.test.ts diff --git a/src/services/mcp/client.test.ts b/src/services/mcp/client.test.ts new file mode 100644 index 00000000..6f69ee7b --- /dev/null +++ b/src/services/mcp/client.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { cleanupFailedConnection } from './client.js' + +test('cleanupFailedConnection awaits transport close before resolving', async () => { + let closed = false + let resolveClose: (() => void) | undefined + + const transport = { + close: async () => + await new Promise(resolve => { + resolveClose = () => { + closed = true + resolve() + } + }), + } + + const cleanupPromise = cleanupFailedConnection(transport) + + assert.equal(closed, false) + resolveClose?.() + await cleanupPromise + assert.equal(closed, true) +}) + +test('cleanupFailedConnection closes in-process server and transport', async () => { + let inProcessClosed = false + let transportClosed = false + + const inProcessServer = { + close: async () => { + inProcessClosed = true + }, + } + + const transport = { + close: async () => { + transportClosed = true + }, + } + + await cleanupFailedConnection(transport, inProcessServer) + + assert.equal(inProcessClosed, true) + assert.equal(transportClosed, true) +}) diff --git a/src/services/mcp/client.ts b/src/services/mcp/client.ts index b053dbb6..8857b56c 100644 --- a/src/services/mcp/client.ts +++ b/src/services/mcp/client.ts @@ -560,6 +560,22 @@ function getRemoteMcpServerConnectionBatchSize(): number { ) } +type InProcessMcpServer = { + connect(t: Transport): Promise + close(): Promise +} + +export async function cleanupFailedConnection( + transport: Pick, + inProcessServer?: Pick, +): Promise { + if (inProcessServer) { + await inProcessServer.close().catch(() => {}) + } + + await transport.close().catch(() => {}) +} + function isLocalMcpServer(config: ScopedMcpServerConfig): boolean { return !config.type || config.type === 'stdio' || config.type === 'sdk' } @@ -606,9 +622,7 @@ export const connectToServer = memoize( }, ): Promise => { const connectStartTime = Date.now() - let inProcessServer: - | { connect(t: Transport): Promise; close(): Promise } - | undefined + let inProcessServer: InProcessMcpServer | undefined try { let transport @@ -1145,9 +1159,10 @@ export const connectToServer = memoize( }) } if (inProcessServer) { - inProcessServer.close().catch(() => { }) + await cleanupFailedConnection(transport, inProcessServer) + } else { + await cleanupFailedConnection(transport) } - transport.close().catch(() => { }) if (stderrOutput) { logMCPError(name, `Server stderr: ${stderrOutput}`) } From 64ba7fdb9af883d714e963df4459962be297bb3d Mon Sep 17 00:00:00 2001 From: Misha Skvortsov Date: Thu, 2 Apr 2026 12:27:12 +0300 Subject: [PATCH 33/35] refactor: enhance Atomic Chat API URL handling - Updated the `getAtomicChatApiBaseUrl` function to parse the base URL correctly and ensure the pathname is formatted without trailing version segments. - Cleared search and hash components from the URL to standardize the output. This change improves the robustness of the URL handling for the Atomic Chat provider. --- scripts/provider-discovery.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/provider-discovery.ts b/scripts/provider-discovery.ts index 9c463f2f..e65d2e8f 100644 --- a/scripts/provider-discovery.ts +++ b/scripts/provider-discovery.ts @@ -97,8 +97,16 @@ export async function listOllamaModels( // ── Atomic Chat discovery (Apple Silicon local LLMs at 127.0.0.1:1337) ────── export function getAtomicChatApiBaseUrl(baseUrl?: string): string { - const raw = baseUrl || process.env.ATOMIC_CHAT_BASE_URL || DEFAULT_ATOMIC_CHAT_BASE_URL - return trimTrailingSlash(raw) + const parsed = new URL( + baseUrl || process.env.ATOMIC_CHAT_BASE_URL || DEFAULT_ATOMIC_CHAT_BASE_URL, + ) + const pathname = trimTrailingSlash(parsed.pathname) + parsed.pathname = pathname.endsWith('/v1') + ? pathname.slice(0, -3) || '/' + : pathname || '/' + parsed.search = '' + parsed.hash = '' + return trimTrailingSlash(parsed.toString()) } export function getAtomicChatChatBaseUrl(baseUrl?: string): string { From 0a428394757cd918efbff6ff8cc78054c76d85d2 Mon Sep 17 00:00:00 2001 From: Rithul Kamesh Date: Thu, 2 Apr 2026 15:38:54 +0530 Subject: [PATCH 34/35] fix(github): address PR feedback for onboarding flow - Set competing provider flags to undefined in updateSettingsForSource to ensure clean GitHub boot - Fix resolveProviderRequest to default to github:copilot when OPENAI_MODEL is unset - Hydrate secure tokens and managed settings in system-check.ts to prevent false negatives - Add models:read scope to GitHub device flow --- scripts/system-check.ts | 7 +++++++ src/commands/onboard-github/onboard-github.tsx | 15 ++++++++++++--- src/services/api/providerConfig.ts | 3 ++- src/services/github/deviceFlow.ts | 2 +- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/scripts/system-check.ts b/scripts/system-check.ts index 6626149a..34990b04 100644 --- a/scripts/system-check.ts +++ b/scripts/system-check.ts @@ -447,6 +447,13 @@ async function main(): Promise { const options = parseOptions(process.argv.slice(2)) const results: CheckResult[] = [] + const { enableConfigs } = await import('../src/utils/config.js') + enableConfigs() + const { applySafeConfigEnvironmentVariables } = await import('../src/utils/managedEnv.js') + applySafeConfigEnvironmentVariables() + const { hydrateGithubModelsTokenFromSecureStorage } = await import('../src/utils/githubModelsCredentials.js') + hydrateGithubModelsTokenFromSecureStorage() + results.push(checkNodeVersion()) results.push(checkBunRuntime()) results.push(checkBuildArtifacts()) diff --git a/src/commands/onboard-github/onboard-github.tsx b/src/commands/onboard-github/onboard-github.tsx index 26088392..66326957 100644 --- a/src/commands/onboard-github/onboard-github.tsx +++ b/src/commands/onboard-github/onboard-github.tsx @@ -29,6 +29,11 @@ function mergeUserSettingsEnv(model: string): { ok: boolean; detail?: string } { env: { CLAUDE_CODE_USE_GITHUB: '1', OPENAI_MODEL: model, + CLAUDE_CODE_USE_OPENAI: undefined as any, + CLAUDE_CODE_USE_GEMINI: undefined as any, + CLAUDE_CODE_USE_BEDROCK: undefined as any, + CLAUDE_CODE_USE_VERTEX: undefined as any, + CLAUDE_CODE_USE_FOUNDRY: undefined as any, }, }) if (error) { @@ -49,6 +54,7 @@ function OnboardGithub(props: { verification_uri: string } | null>(null) const [patDraft, setPatDraft] = useState('') + const [cursorOffset, setCursorOffset] = useState(0) const finalize = useCallback( async (token: string, model: string = DEFAULT_MODEL) => { @@ -117,7 +123,7 @@ function OnboardGithub(props: { {errorMsg} { + onChange={(v: string) => { if (v === 'cancel') { onDone('GitHub onboard cancelled', { display: 'system' }) return diff --git a/src/services/api/providerConfig.ts b/src/services/api/providerConfig.ts index bbbc2cb9..90643aa1 100644 --- a/src/services/api/providerConfig.ts +++ b/src/services/api/providerConfig.ts @@ -194,11 +194,12 @@ export function resolveProviderRequest(options?: { baseUrl?: string fallbackModel?: string }): ResolvedProviderRequest { + const isGithubMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) const requestedModel = options?.model?.trim() || process.env.OPENAI_MODEL?.trim() || options?.fallbackModel?.trim() || - 'gpt-4o' + (isGithubMode ? 'github:copilot' : 'gpt-4o') const descriptor = parseModelDescriptor(requestedModel) const rawBaseUrl = options?.baseUrl ?? diff --git a/src/services/github/deviceFlow.ts b/src/services/github/deviceFlow.ts index 0e207b7f..379d757e 100644 --- a/src/services/github/deviceFlow.ts +++ b/src/services/github/deviceFlow.ts @@ -11,7 +11,7 @@ export const GITHUB_DEVICE_ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token' /** Match runtime devsper github_oauth DEFAULT_SCOPE */ -export const DEFAULT_GITHUB_DEVICE_SCOPE = 'read:user' +export const DEFAULT_GITHUB_DEVICE_SCOPE = 'read:user,models:read' export class GitHubDeviceFlowError extends Error { constructor(message: string) { From 7c09b1f01cd062db42d6462e429623335c781f7b Mon Sep 17 00:00:00 2001 From: Misha Skvortsov Date: Thu, 2 Apr 2026 13:58:50 +0300 Subject: [PATCH 35/35] docs: add Atomic Chat to README provider examples and launch profiles Made-with: Cursor --- README.md | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8a0690f9..17c0e2f3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Use Claude Code with **any LLM** — not just Claude. -OpenClaude is a fork of the [Claude Code source leak](https://gitlawb.com/node/repos/z6MkgKkb/instructkr-claude-code) (exposed via npm source maps on March 31, 2026). We added an OpenAI-compatible provider shim so you can plug in GPT-4o, DeepSeek, Gemini, Llama, Mistral, or any model that speaks the OpenAI chat completions API. It now also supports the ChatGPT Codex backend for `codexplan` and `codexspark`. +OpenClaude is a fork of the [Claude Code source leak](https://gitlawb.com/node/repos/z6MkgKkb/instructkr-claude-code) (exposed via npm source maps on March 31, 2026). We added an OpenAI-compatible provider shim so you can plug in GPT-4o, DeepSeek, Gemini, Llama, Mistral, or any model that speaks the OpenAI chat completions API. It now also supports the ChatGPT Codex backend for `codexplan` and `codexspark`, and local inference via [Atomic Chat](https://atomic.chat/) on Apple Silicon. All of Claude Code's tools work — bash, file read/write/edit, grep, glob, agents, tasks, MCP — just powered by whatever model you choose. @@ -130,6 +130,23 @@ export OPENAI_MODEL=llama3.3:70b # no API key needed for local models ``` +### Atomic Chat (local, Apple Silicon) + +```bash +export CLAUDE_CODE_USE_OPENAI=1 +export OPENAI_BASE_URL=http://127.0.0.1:1337/v1 +export OPENAI_MODEL=your-model-name +# no API key needed for local models +``` + +Or use the profile launcher: + +```bash +bun run dev:atomic-chat +``` + +Download Atomic Chat from [atomic.chat](https://atomic.chat/). The app must be running with a model loaded before launching. + ### LM Studio (local) ```bash @@ -181,7 +198,7 @@ export OPENAI_MODEL=gpt-4o | Variable | Required | Description | |----------|----------|-------------| | `CLAUDE_CODE_USE_OPENAI` | Yes | Set to `1` to enable the OpenAI provider | -| `OPENAI_API_KEY` | Yes* | Your API key (*not needed for local models like Ollama) | +| `OPENAI_API_KEY` | Yes* | Your API key (*not needed for local models like Ollama/Atomic Chat) | | `OPENAI_MODEL` | Yes | Model name (e.g. `gpt-4o`, `deepseek-chat`, `llama3.3:70b`) | | `OPENAI_BASE_URL` | No | API endpoint (defaults to `https://api.openai.com/v1`) | | `CODEX_API_KEY` | Codex only | Codex/ChatGPT access token override | @@ -241,6 +258,9 @@ bun run profile:codex # openai bootstrap with explicit key bun run profile:init -- --provider openai --api-key sk-... +# atomic-chat bootstrap (auto-detects running model) +bun run profile:init -- --provider atomic-chat + # ollama bootstrap with custom model bun run profile:init -- --provider ollama --model llama3.1:8b @@ -261,6 +281,9 @@ bun run dev:openai # Ollama profile (defaults: localhost:11434, llama3.1:8b) bun run dev:ollama + +# Atomic Chat profile (Apple Silicon local LLMs at 127.0.0.1:1337) +bun run dev:atomic-chat ``` `profile:recommend` ranks installed Ollama models for `latency`, `balanced`, or `coding`, and `profile:auto` can persist the recommendation directly. @@ -271,8 +294,9 @@ Goal-based Ollama selection only recommends among models that are already instal Use `profile:codex` or `--provider codex` when you want the ChatGPT Codex backend. -`dev:openai`, `dev:ollama`, and `dev:codex` run `doctor:runtime` first and only launch the app if checks pass. +`dev:openai`, `dev:ollama`, `dev:atomic-chat`, and `dev:codex` run `doctor:runtime` first and only launch the app if checks pass. For `dev:ollama`, make sure Ollama is running locally before launch. +For `dev:atomic-chat`, make sure Atomic Chat is running with a model loaded. ---