Compare commits

..

3 Commits

Author SHA1 Message Date
gnanam1990
1e72fc94cb fix: align SkillTool schema error output 2026-04-07 21:31:23 +05:30
gnanam1990
be7ec1b5fa fix: preserve SkillTool schema contract 2026-04-07 21:12:01 +05:30
gnanam1990
cdfaea5ced fix: handle missing skill parameter in SkillTool 2026-04-07 20:23:36 +05:30
287 changed files with 2163 additions and 21284 deletions

View File

@@ -1,16 +0,0 @@
node_modules
dist
.git
.gitignore
.env
.env.*
!.env.example
coverage
reports
vscode-extension
python
docs
*.md
!README.md
.github
.tsbuildinfo

View File

@@ -248,93 +248,3 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
# Enable debug logging
# CLAUDE_DEBUG=1
# =============================================================================
# WEB SEARCH (OPTIONAL)
# =============================================================================
# OpenClaude includes a web search tool. By default it uses DuckDuckGo (free)
# or the provider's native search (Anthropic firstParty / vertex).
#
# Set one API key below to enable a provider. That's it.
# ── Provider API keys — set ONE of these ────────────────────────────
# Tavily (AI-optimized search, recommended)
# TAVILY_API_KEY=tvly-your-key-here
# Exa (neural/semantic search)
# EXA_API_KEY=your-exa-key-here
# You.com (RAG-ready snippets)
# YOU_API_KEY=your-you-key-here
# Jina (s.jina.ai endpoint)
# JINA_API_KEY=your-jina-key-here
# Bing Web Search
# BING_API_KEY=your-bing-key-here
# Mojeek (privacy-focused)
# MOJEEK_API_KEY=your-mojeek-key-here
# Linkup
# LINKUP_API_KEY=your-linkup-key-here
# Firecrawl (premium, uses @mendable/firecrawl-js)
# FIRECRAWL_API_KEY=fc-your-key-here
# ── Provider selection mode ─────────────────────────────────────────
#
# WEB_SEARCH_PROVIDER controls fallback behavior:
#
# "auto" (default) — try all configured providers, fall through on failure
# "custom" — custom API only, throw on failure (NOT in auto chain)
# "firecrawl" — firecrawl only
# "tavily" — tavily only
# "exa" — exa only
# "you" — you.com only
# "jina" — jina only
# "bing" — bing only
# "mojeek" — mojeek only
# "linkup" — linkup only
# "ddg" — duckduckgo only
# "native" — anthropic native / codex only
#
# Auto mode priority: firecrawl → tavily → exa → you → jina → bing → mojeek →
# linkup → ddg
# Note: "custom" is NOT in the auto chain. To use the custom API provider,
# you must explicitly set WEB_SEARCH_PROVIDER=custom.
#
# WEB_SEARCH_PROVIDER=auto
# ── Built-in custom API presets ─────────────────────────────────────
#
# Use with WEB_KEY for the API key:
# WEB_PROVIDER=searxng|google|brave|serpapi
# WEB_KEY=your-api-key-here
# ── Custom API endpoint (advanced) ──────────────────────────────────
#
# WEB_SEARCH_API — base URL of your search endpoint
# WEB_QUERY_PARAM — query parameter name (default: "q")
# WEB_METHOD — GET or POST (default: GET)
# WEB_PARAMS — extra static query params as JSON: {"lang":"en","count":"10"}
# WEB_URL_TEMPLATE — URL template with {query} for path embedding
# WEB_BODY_TEMPLATE — custom POST body with {query} placeholder
# WEB_AUTH_HEADER — header name for API key (default: "Authorization")
# WEB_AUTH_SCHEME — prefix before key (default: "Bearer")
# WEB_HEADERS — extra headers as "Name: value; Name2: value2"
# WEB_JSON_PATH — dot-path to results array in response
# ── Custom API security guardrails ──────────────────────────────────
#
# The custom provider enforces security guardrails by default.
# Override these only if you understand the risks.
#
# WEB_CUSTOM_TIMEOUT_SEC=15 — request timeout in seconds (default 15)
# WEB_CUSTOM_MAX_BODY_KB=300 — max POST body size in KB (default 300)
# WEB_CUSTOM_ALLOW_ARBITRARY_HEADERS=false — set "true" to use non-standard headers
# WEB_CUSTOM_ALLOW_HTTP=false — set "true" to allow http:// URLs
# WEB_CUSTOM_ALLOW_PRIVATE=false — set "true" to target localhost/private IPs
# (needed for self-hosted SearXNG)

View File

@@ -29,13 +29,6 @@ jobs:
with:
bun-version: 1.3.11
- name: Set up Python
uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: python/requirements.txt
- name: Install dependencies
run: bun install --frozen-lockfile
@@ -45,12 +38,6 @@ jobs:
- name: Full unit test suite
run: bun test --max-concurrency=1
- name: Install Python test dependencies
run: python -m pip install -r python/requirements.txt
- name: Python unit tests
run: python -m pytest -q python/tests
- name: Suspicious PR intent scan
run: bun run security:pr-scan -- --base ${{ github.event.pull_request.base.sha || 'origin/main' }}
- name: Provider tests

View File

@@ -1,143 +0,0 @@
name: Auto Release
on:
push:
branches:
- main
concurrency:
group: auto-release-${{ github.ref }}
cancel-in-progress: false
jobs:
release-please:
name: Release Please
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
steps:
- name: Run release-please
id: release
uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38
with:
token: ${{ secrets.GITHUB_TOKEN }}
release-type: node
publish-npm:
name: Publish to npm
needs: release-please
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
id-token: write
steps:
- name: Checkout release tag
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
ref: ${{ needs.release-please.outputs.tag_name }}
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 24
registry-url: https://registry.npmjs.org
- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: 1.3.11
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run unit tests
run: bun test --max-concurrency=1
- name: Smoke test
run: bun run smoke
- name: Build
run: bun run build
- name: Dry-run package
run: npm pack --dry-run
- name: Clear token auth for trusted publishing
run: |
unset NODE_AUTH_TOKEN
echo "NODE_AUTH_TOKEN=" >> "$GITHUB_ENV"
- name: Publish to npm
run: npm publish --access public --provenance
- name: Release summary
run: |
{
echo "## Released ${{ needs.release-please.outputs.tag_name }}"
echo
echo "- npm: https://www.npmjs.com/package/@gitlawb/openclaude"
echo "- GitHub: https://github.com/Gitlawb/openclaude/releases/tag/${{ needs.release-please.outputs.tag_name }}"
} >> "$GITHUB_STEP_SUMMARY"
docker:
name: Build & Push Docker Image
needs: release-please
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout release tag
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ needs.release-please.outputs.tag_name }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}},value=${{ needs.release-please.outputs.version }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.release-please.outputs.version }}
type=raw,value=latest
- name: Build and load locally
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with:
context: .
load: true
tags: openclaude:smoke
cache-from: type=gha
- name: Smoke test
run: docker run --rm openclaude:smoke --version
- name: Build and push
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,3 +0,0 @@
{
".": "0.3.0"
}

View File

@@ -1,94 +0,0 @@
# Changelog
## [0.3.0](https://github.com/Gitlawb/openclaude/compare/v0.2.3...v0.3.0) (2026-04-14)
### Features
* activate coordinator mode in open build ([#647](https://github.com/Gitlawb/openclaude/issues/647)) ([99a1714](https://github.com/Gitlawb/openclaude/commit/99a17144ee285b892a0801acb6abcc9af68879af))
* activate local-only team memory in open build ([#648](https://github.com/Gitlawb/openclaude/issues/648)) ([24d485f](https://github.com/Gitlawb/openclaude/commit/24d485f42f5b1405d2fab13f2f497d5edd3b5300))
* activate message actions in open build ([#632](https://github.com/Gitlawb/openclaude/issues/632)) ([252808b](https://github.com/Gitlawb/openclaude/commit/252808bbd0a12a6ccf97e2cb09752a0212ea3acd))
* add allowBypassPermissionsMode setting ([#658](https://github.com/Gitlawb/openclaude/issues/658)) ([31be66d](https://github.com/Gitlawb/openclaude/commit/31be66d7645ea3473334c9ce89ea1a5095b8df6e))
* add Docker image build and push to GHCR on release ([#656](https://github.com/Gitlawb/openclaude/issues/656)) ([658d076](https://github.com/Gitlawb/openclaude/commit/658d076909e14eb0459bcb98aee9aa0472118265))
* implement /loop command with fixed and dynamic scheduling ([#621](https://github.com/Gitlawb/openclaude/issues/621)) ([64298a6](https://github.com/Gitlawb/openclaude/commit/64298a663f1391b16aa1f5a49e8a877e1d3742f2))
* implement Monitor tool for streaming shell output ([#649](https://github.com/Gitlawb/openclaude/issues/649)) ([b818dd5](https://github.com/Gitlawb/openclaude/commit/b818dd5958f4e8428566ce25a1a6be5fd4fe66f8))
* local feature flag overrides via ~/.claude/feature-flags.json ([#639](https://github.com/Gitlawb/openclaude/issues/639)) ([0e48884](https://github.com/Gitlawb/openclaude/commit/0e48884f56c6c008f047a7926d3b2cb924170625))
* open useful USER_TYPE-gated features to all users ([#644](https://github.com/Gitlawb/openclaude/issues/644)) ([c1beea9](https://github.com/Gitlawb/openclaude/commit/c1beea98676a413c54152a45a6b9fbe7fb9ed028))
### Bug Fixes
* bump axios 1.14.0 → 1.15.0 (Dependabot [#4](https://github.com/Gitlawb/openclaude/issues/4), [#5](https://github.com/Gitlawb/openclaude/issues/5)) ([#670](https://github.com/Gitlawb/openclaude/issues/670)) ([a07e5ef](https://github.com/Gitlawb/openclaude/commit/a07e5ef990a5ed01a72e83fdbd1fcab36f515a08))
* extend provider guard to protect anthropic profiles from cross-terminal override ([#641](https://github.com/Gitlawb/openclaude/issues/641)) ([03e0b06](https://github.com/Gitlawb/openclaude/commit/03e0b06e0784e4ea46945b3950840b10b6e3ca49))
* improve fetch diagnostics for bootstrap and session requests ([#646](https://github.com/Gitlawb/openclaude/issues/646)) ([df2b9f2](https://github.com/Gitlawb/openclaude/commit/df2b9f2b7b4c661ee3d9ed5dc58b3064de0599d1))
* **openai-shim:** preserve tool result images and local token caps ([#659](https://github.com/Gitlawb/openclaude/issues/659)) ([30c866d](https://github.com/Gitlawb/openclaude/commit/30c866d31ad8538496460667d86ed5efbd4a8547))
* replace broken bun:bundle shim with source pre-processing ([#657](https://github.com/Gitlawb/openclaude/issues/657)) ([adbe391](https://github.com/Gitlawb/openclaude/commit/adbe391e63721918b5d147f4f845111c1a3143db))
* resolve 12 bugs across API, MCP, agent tools, web search, and context overflow ([#674](https://github.com/Gitlawb/openclaude/issues/674)) ([25ce2ca](https://github.com/Gitlawb/openclaude/commit/25ce2ca7bff8937b0b79ad7f85c6dc1c68432069))
* route OpenAI Codex shortcuts to correct endpoint ([#566](https://github.com/Gitlawb/openclaude/issues/566)) ([7c8bdcc](https://github.com/Gitlawb/openclaude/commit/7c8bdcc3e2ac1ecb98286c705c85671044be3d6b))
## [0.2.3](https://github.com/Gitlawb/openclaude/compare/v0.2.2...v0.2.3) (2026-04-12)
### Bug Fixes
* prevent infinite auto-compact loop for unknown 3P models ([#635](https://github.com/Gitlawb/openclaude/issues/635)) ([#636](https://github.com/Gitlawb/openclaude/issues/636)) ([aeaa658](https://github.com/Gitlawb/openclaude/commit/aeaa658f776fb8df95721e8b8962385f8b00f66a))
## [0.2.2](https://github.com/Gitlawb/openclaude/compare/v0.2.1...v0.2.2) (2026-04-12)
### Bug Fixes
* **read/edit:** make compact line prefix unambiguous for tab-indented files ([#613](https://github.com/Gitlawb/openclaude/issues/613)) ([08cc6f3](https://github.com/Gitlawb/openclaude/commit/08cc6f328711cd93ce9fa53351266c29a0b0a341))
## [0.2.1](https://github.com/Gitlawb/openclaude/compare/v0.2.0...v0.2.1) (2026-04-12)
### Bug Fixes
* **provider:** add recovery guidance for missing OpenAI API key ([#616](https://github.com/Gitlawb/openclaude/issues/616)) ([9419e8a](https://github.com/Gitlawb/openclaude/commit/9419e8a4a21b3771d9ddb10f7072e0a8c5b5b631))
## [0.2.0](https://github.com/Gitlawb/openclaude/compare/v0.1.8...v0.2.0) (2026-04-12)
### Features
* add /cache-probe diagnostic command ([#580](https://github.com/Gitlawb/openclaude/issues/580)) ([9ccaa7a](https://github.com/Gitlawb/openclaude/commit/9ccaa7a6759b6991f4a566b4118c06e68a2398fe)), closes [#515](https://github.com/Gitlawb/openclaude/issues/515)
* add auto-fix service — auto-lint and test after AI file edits ([#508](https://github.com/Gitlawb/openclaude/issues/508)) ([c385047](https://github.com/Gitlawb/openclaude/commit/c385047abba4366866f4c87bfb5e0b0bd4dcbb9d))
* Add Gemini support with thought_signature fix ([#404](https://github.com/Gitlawb/openclaude/issues/404)) ([5012c16](https://github.com/Gitlawb/openclaude/commit/5012c160c9a2dff9418e7ee19dc9a4d29ef2b024))
* add headless gRPC server for external agent integration ([#278](https://github.com/Gitlawb/openclaude/issues/278)) ([26eef92](https://github.com/Gitlawb/openclaude/commit/26eef92fe72e9c3958d61435b8d3571e12bf2b74))
* add wiki mvp commands ([#532](https://github.com/Gitlawb/openclaude/issues/532)) ([c328fdf](https://github.com/Gitlawb/openclaude/commit/c328fdf9e2fe59ad101b049301298ce9ff24caca))
* GitHub provider lifecycle and onboarding hardening ([#351](https://github.com/Gitlawb/openclaude/issues/351)) ([ff7d499](https://github.com/Gitlawb/openclaude/commit/ff7d49990de515825ddbe4099f3a39b944b61370))
### Bug Fixes
* add File polyfill for Node < 20 to prevent startup deadlock with proxy ([#442](https://github.com/Gitlawb/openclaude/issues/442)) ([85aa8b0](https://github.com/Gitlawb/openclaude/commit/85aa8b0985c8f3cb8801efa5141114a0ab0f6a83))
* add GitHub Copilot model context windows and output limits ([#576](https://github.com/Gitlawb/openclaude/issues/576)) ([a7f5982](https://github.com/Gitlawb/openclaude/commit/a7f5982f6438ab0ddc3f0daae31ea68ac7ac206c)), closes [#515](https://github.com/Gitlawb/openclaude/issues/515)
* add LiteLLM-style aliases for GitHub Copilot context windows ([#606](https://github.com/Gitlawb/openclaude/issues/606)) ([2e0e14d](https://github.com/Gitlawb/openclaude/commit/2e0e14d71313e0e501efaa9e55c6c56f2742fb10))
* add store:false to Chat Completions and /responses fallback ([#578](https://github.com/Gitlawb/openclaude/issues/578)) ([8aaa4f2](https://github.com/Gitlawb/openclaude/commit/8aaa4f22ac5b942d82aa9cad54af30d56034515a))
* address code scanning alerts ([#434](https://github.com/Gitlawb/openclaude/issues/434)) ([e365cb4](https://github.com/Gitlawb/openclaude/commit/e365cb4010becabacd7cbccb4c3e59ea23a41e90))
* avoid sync github credential reads in provider manager ([#428](https://github.com/Gitlawb/openclaude/issues/428)) ([aff2bd8](https://github.com/Gitlawb/openclaude/commit/aff2bd87e4f2821992f74fb95481c505d0ba5d5d))
* convert dragged file paths to [@mentions](https://github.com/mentions) for attachment ([#382](https://github.com/Gitlawb/openclaude/issues/382)) ([112df59](https://github.com/Gitlawb/openclaude/commit/112df5911791ea71ee9efbb98ea59c5ded1ea161))
* custom web search — WEB_URL_TEMPLATE not recognized, timeout too short, silent native fallback ([#537](https://github.com/Gitlawb/openclaude/issues/537)) ([32fbd0c](https://github.com/Gitlawb/openclaude/commit/32fbd0c7b4168b32dcb13a5b69342e2727269201))
* defer startup checks and suppress recommendation dialogs during startup window (issue [#363](https://github.com/Gitlawb/openclaude/issues/363)) ([#504](https://github.com/Gitlawb/openclaude/issues/504)) ([2caf2fd](https://github.com/Gitlawb/openclaude/commit/2caf2fd982af1ec845c50152ad9d28d1a597f82f))
* display selected model in startup screen instead of hardcoded sonnet 4.6 ([#587](https://github.com/Gitlawb/openclaude/issues/587)) ([b126e38](https://github.com/Gitlawb/openclaude/commit/b126e38b1affddd2de83fcc3ba26f2e44b42a509))
* handle missing skill parameter in SkillTool ([#485](https://github.com/Gitlawb/openclaude/issues/485)) ([f9ce81b](https://github.com/Gitlawb/openclaude/commit/f9ce81bfb384e909353813fb6f6760cadd508ae7))
* include MCP tool results in microcompact to reduce token waste ([#348](https://github.com/Gitlawb/openclaude/issues/348)) ([52d33a8](https://github.com/Gitlawb/openclaude/commit/52d33a87a047b943aedaaaf772cd48636c263509))
* **ink:** restore host prop updates in React 19 reconciler ([#589](https://github.com/Gitlawb/openclaude/issues/589)) ([6e94dd9](https://github.com/Gitlawb/openclaude/commit/6e94dd913688b2d6433a9abe62a245c5f031b776))
* let saved provider profiles win on restart ([#513](https://github.com/Gitlawb/openclaude/issues/513)) ([cb8f8b7](https://github.com/Gitlawb/openclaude/commit/cb8f8b7ac2e3e74516ee219a3a48156db7c6ed78))
* normalize malformed Bash tool arguments from OpenAI-compatible providers ([#385](https://github.com/Gitlawb/openclaude/issues/385)) ([b4bd95b](https://github.com/Gitlawb/openclaude/commit/b4bd95b47715c9896240d708c106777507fd26ec))
* preserve only originally-required properties in strict tool schemas ([#471](https://github.com/Gitlawb/openclaude/issues/471)) ([ccaa193](https://github.com/Gitlawb/openclaude/commit/ccaa193eec5761f0972ffb58eb3189a81a9244b0))
* preserve unicode in Windows clipboard fallback ([#388](https://github.com/Gitlawb/openclaude/issues/388)) ([c193497](https://github.com/Gitlawb/openclaude/commit/c1934974aaf64db460cc850a044bd13cc744cce7))
* rebrand prompt identity to openclaude ([#496](https://github.com/Gitlawb/openclaude/issues/496)) ([598651f](https://github.com/Gitlawb/openclaude/commit/598651f42389ce76311ec00e8a9c701c939ead27))
* replace isDeepStrictEqual with navigation-aware options comparison ([#507](https://github.com/Gitlawb/openclaude/issues/507)) ([537c469](https://github.com/Gitlawb/openclaude/commit/537c469c3a2f7cb0eed05fa2f54dca57b6bc273f)), closes [#472](https://github.com/Gitlawb/openclaude/issues/472)
* report cache reads in streaming and correct cost calculation ([#577](https://github.com/Gitlawb/openclaude/issues/577)) ([f4ac709](https://github.com/Gitlawb/openclaude/commit/f4ac709fa6eda732bf45204fcab625ba6c5674b9))
* restore default context window for unknown 3p models ([#494](https://github.com/Gitlawb/openclaude/issues/494)) ([69ea1f1](https://github.com/Gitlawb/openclaude/commit/69ea1f1e4a99e9436215d8cb391a116a64442b94))
* restore Grep and Glob reliability on OpenAI paths ([#461](https://github.com/Gitlawb/openclaude/issues/461)) ([600c01f](https://github.com/Gitlawb/openclaude/commit/600c01faf761a080a2c7dede872ddbe05a132f23))
* restore Ollama auto-detect in first-run setup ([#561](https://github.com/Gitlawb/openclaude/issues/561)) ([68c2968](https://github.com/Gitlawb/openclaude/commit/68c296833dcef54ce44cb18b24357230b5204dbc))
* scrub canonical Anthropic headers from 3P shim requests ([#499](https://github.com/Gitlawb/openclaude/issues/499)) ([07621a6](https://github.com/Gitlawb/openclaude/commit/07621a6f8d0918170281869a47b5dbff90e71594))
* strip Anthropic params from 3P resume paths ([#479](https://github.com/Gitlawb/openclaude/issues/479)) ([4975cfc](https://github.com/Gitlawb/openclaude/commit/4975cfc2e0ddbe34aa4e8e3f52ee5eba07fbe465))
* suppress startup dialogs when input is buffered ([#423](https://github.com/Gitlawb/openclaude/issues/423)) ([8ece290](https://github.com/Gitlawb/openclaude/commit/8ece2900872dadd157e798ef501ddf126dac66c4))
* **tui:** restore prompt rendering on startup ([#498](https://github.com/Gitlawb/openclaude/issues/498)) ([e30ad17](https://github.com/Gitlawb/openclaude/commit/e30ad17ae0056787273be2caafd6cf5340b6ab57))
* update theme preview on focus change ([#562](https://github.com/Gitlawb/openclaude/issues/562)) ([6924718](https://github.com/Gitlawb/openclaude/commit/692471850fc789ee0797190089272407f9a4d953))
* **web-search:** close SSRF bypasses in custom provider hostname guard ([#610](https://github.com/Gitlawb/openclaude/issues/610)) ([a02c441](https://github.com/Gitlawb/openclaude/commit/a02c44143b257fbee7f38f1b93873cc0ea68a1f9))
* WebSearch providers + MCPTool bugs ([#593](https://github.com/Gitlawb/openclaude/issues/593)) ([91e4cfb](https://github.com/Gitlawb/openclaude/commit/91e4cfb15b62c04615834fd3c417fe38b4feb914))

View File

@@ -1,49 +0,0 @@
# ---- build stage ----
FROM node:22-slim AS build
# Install Bun
RUN npm install -g bun@1.3.11
WORKDIR /app
# Copy dependency manifests first for better layer caching
COPY package.json bun.lock ./
# Install all dependencies (including devDependencies for build)
RUN bun install --frozen-lockfile
# Copy source code
COPY src/ src/
COPY scripts/ scripts/
COPY bin/ bin/
COPY tsconfig.json ./
# Build the CLI bundle
RUN bun run build
# Prune devDependencies
RUN rm -rf node_modules && bun install --frozen-lockfile --production
# ---- runtime stage ----
FROM node:22-slim
WORKDIR /app
# Copy only what's needed to run
COPY --from=build /app/dist/cli.mjs dist/cli.mjs
COPY --from=build /app/bin/ bin/
COPY --from=build /app/node_modules/ node_modules/
COPY --from=build /app/package.json package.json
COPY README.md ./
# Install git — many CLI tool operations depend on it
RUN apt-get update && apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/*
# Run as non-root user
RUN groupadd --gid 1000 appuser && useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser
USER appuser
WORKDIR /home/appuser
ENV HOME=/home/appuser
ENTRYPOINT ["node", "/app/dist/cli.mjs"]

View File

@@ -2,7 +2,7 @@
OpenClaude is an open-source coding-agent CLI for cloud and local model providers.
Use OpenAI-compatible APIs, Gemini, GitHub Models, Codex OAuth, Codex, Ollama, Atomic Chat, and other supported backends while keeping one terminal-first workflow: prompts, tools, agents, MCP, slash commands, and streaming output.
Use OpenAI-compatible APIs, Gemini, GitHub Models, Codex, Ollama, Atomic Chat, and other supported backends while keeping one terminal-first workflow: prompts, tools, agents, MCP, slash commands, and streaming output.
[![PR Checks](https://github.com/Gitlawb/openclaude/actions/workflows/pr-checks.yml/badge.svg?branch=main)](https://github.com/Gitlawb/openclaude/actions/workflows/pr-checks.yml)
[![Release](https://img.shields.io/github/v/tag/Gitlawb/openclaude?label=release&color=0ea5e9)](https://github.com/Gitlawb/openclaude/tags)
@@ -10,16 +10,13 @@ Use OpenAI-compatible APIs, Gemini, GitHub Models, Codex OAuth, Codex, Ollama, A
[![Security Policy](https://img.shields.io/badge/security-policy-0f766e)](SECURITY.md)
[![License](https://img.shields.io/badge/license-MIT-2563eb)](LICENSE)
OpenClaude is also mirrored to GitLawb:
[gitlawb.com/node/repos/z6MkqDnb/openclaude](https://gitlawb.com/node/repos/z6MkqDnb/openclaude)
[Quick Start](#quick-start) | [Setup Guides](#setup-guides) | [Providers](#supported-providers) | [Source Build](#source-build-and-local-development) | [VS Code Extension](#vs-code-extension) | [Community](#community)
## Why OpenClaude
- Use one CLI across cloud APIs and local model backends
- Save provider profiles inside the app with `/provider`
- Run with OpenAI-compatible services, Gemini, GitHub Models, Codex OAuth, Codex, Ollama, Atomic Chat, and other supported providers
- Run with OpenAI-compatible services, Gemini, GitHub Models, Codex, Ollama, Atomic Chat, and other supported providers
- Keep coding-agent workflows in one place: bash, file tools, grep, glob, agents, tasks, MCP, and web tools
- Use the bundled VS Code extension for launch integration and theme support
@@ -108,8 +105,7 @@ Advanced and source-build guides:
| OpenAI-compatible | `/provider` or env vars | Works with OpenAI, OpenRouter, DeepSeek, Groq, Mistral, LM Studio, and other compatible `/v1` servers |
| Gemini | `/provider` or env vars | Supports API key, access token, or local ADC workflow on current `main` |
| GitHub Models | `/onboard-github` | Interactive onboarding with saved credentials |
| Codex OAuth | `/provider` | Opens ChatGPT sign-in in your browser and stores Codex credentials securely |
| Codex | `/provider` | Uses existing Codex CLI auth, OpenClaude secure storage, or env credentials |
| Codex | `/provider` | Uses existing Codex credentials when available |
| Ollama | `/provider` or env vars | Local inference with no API key |
| Atomic Chat | advanced setup | Local Apple Silicon backend |
| Bedrock / Vertex / Foundry | env vars | Additional provider integrations for supported environments |

View File

@@ -30,7 +30,7 @@
"@opentelemetry/semantic-conventions": "1.40.0",
"ajv": "8.18.0",
"auto-bind": "5.0.1",
"axios": "1.15.0",
"axios": "1.14.0",
"bidi-js": "1.0.3",
"chalk": "5.6.2",
"chokidar": "4.0.3",
@@ -479,7 +479,7 @@
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
"axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="],
"axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
@@ -1151,8 +1151,6 @@
"@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@mendable/firecrawl-js/axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="],
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@1.30.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ=="],
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.57.2", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/otlp-transformer": "0.57.2" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-XdxEzL23Urhidyebg5E6jZoaiW5ygP/mRjxLHixogbqwDy2Faduzb5N0o/Oi+XTIJu+iyxXdVORjXax+Qgfxag=="],
@@ -1379,8 +1377,6 @@
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"firecrawl/axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="],
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"gaxios/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],

View File

@@ -48,8 +48,6 @@ export OPENAI_MODEL=gpt-4o
`codexplan` maps to GPT-5.4 on the Codex backend with high reasoning.
`codexspark` maps to GPT-5.3 Codex Spark for faster loops.
If you use the in-app provider wizard, choose `Codex OAuth` to open ChatGPT sign-in in your browser and let OpenClaude store Codex credentials securely.
If you already use the Codex CLI, OpenClaude reads `~/.codex/auth.json` automatically. You can also point it elsewhere with `CODEX_AUTH_JSON_PATH` or override the token directly with `CODEX_API_KEY`.
```bash
@@ -139,9 +137,10 @@ export OPENAI_MODEL=llama-3.3-70b-versatile
### Mistral
```bash
export CLAUDE_CODE_USE_MISTRAL=1
export MISTRAL_API_KEY=...
export MISTRAL_MODEL=mistral-large-latest
export CLAUDE_CODE_USE_OPENAI=1
export OPENAI_API_KEY=...
export OPENAI_BASE_URL=https://api.mistral.ai/v1
export OPENAI_MODEL=mistral-large-latest
```
### Azure OpenAI

View File

@@ -1,67 +0,0 @@
# Codebase Intelligence — Repo Map
The repo map feature gives the AI model structural awareness of your codebase at the start of each session. Instead of the model needing to explore the repository with `Grep`, `Glob`, and `Read` calls, it starts with a ranked summary of the most important files and their key signatures.
## How it works
1. **File enumeration** — Lists all tracked files via `git ls-files` (falls back to a manual directory walk when not in a git repo)
2. **Symbol extraction** — Parses each supported source file with tree-sitter to extract function, class, type, and interface definitions, plus cross-file references
3. **Reference graph** — Builds a directed graph where an edge from file A to file B means A references a symbol defined in B. Edges are weighted by reference count multiplied by the IDF (inverse document frequency) of the symbol name — common names like `get`, `set`, `value` contribute less
4. **PageRank** — Ranks files by structural importance using PageRank. Files imported by many others rank highest
5. **Rendering** — Walks ranked files top-down, emitting file paths and definition signatures, stopping when the token budget is reached
Results are cached to disk (`~/.openclaude/repomap-cache/`) keyed by file path, mtime, and size. Only changed files are re-parsed on subsequent runs.
## Supported languages
- TypeScript (`.ts`, `.tsx`)
- JavaScript (`.js`, `.jsx`, `.mjs`, `.cjs`)
- Python (`.py`)
Additional language grammars will be added in future releases.
## Enabling auto-injection
The repo map is gated behind the `REPO_MAP` feature flag, **off by default**. To enable auto-injection into the session context:
Set the environment variable before launching:
```bash
REPO_MAP=1 openclaude
```
Or add it to your shell profile for persistent use.
When enabled, the map is built once per session and prepended to the system context alongside git status and CLAUDE.md content. The default budget is 1024 tokens.
Auto-injection is skipped in:
- Bare mode (`--bare`)
- Remote sessions (`CLAUDE_CODE_REMOTE`)
## The /repomap slash command
The `/repomap` command is always available regardless of the feature flag. It lets you inspect and tune the map interactively.
```
/repomap # Show the map with default settings (1024 tokens)
/repomap --tokens 4096 # Increase the token budget for a larger map
/repomap --focus src/tools/ # Boost specific paths in the ranking
/repomap --focus src/context.ts # Can use multiple --focus flags
/repomap --stats # Show cache statistics
/repomap --invalidate # Clear cache and rebuild from scratch
```
## The RepoMap tool
The model can also call the `RepoMap` tool on demand during a session. This is useful when:
- The model needs structural context mid-conversation
- The user asks about specific areas (the model can pass `focus_files` or `focus_symbols`)
- A larger token budget is needed than the auto-injected default
## Known limitations
- **Signatures only** — The map shows function/class/type declarations, not implementations. The model still needs `Read` to see function bodies.
- **Cold build time** — First build on large repos (2000+ files) can take 20-30 seconds due to WASM-based parsing. Subsequent builds use the disk cache and complete in under 100ms.
- **Language coverage** — Only TypeScript, JavaScript, and Python are supported. Files in other languages are skipped.
- **TypeScript references** — The TypeScript tree-sitter query captures type annotations and `new` expressions as references, but not plain function calls. This means the ranking slightly favors type-heavy hub files.
- **Git dependency** — File enumeration uses `git ls-files` by default. Non-git repos fall back to a directory walk with hardcoded exclusions.

View File

@@ -1,6 +1,6 @@
{
"name": "@gitlawb/openclaude",
"version": "0.3.0",
"version": "0.1.8",
"description": "Claude Code opened to any LLM — OpenAI, Gemini, DeepSeek, Ollama, and 200+ models",
"type": "module",
"bin": {
@@ -76,7 +76,7 @@
"@opentelemetry/semantic-conventions": "1.40.0",
"ajv": "8.18.0",
"auto-bind": "5.0.1",
"axios": "1.15.0",
"axios": "1.14.0",
"bidi-js": "1.0.3",
"chalk": "5.6.2",
"chokidar": "4.0.3",
@@ -95,12 +95,8 @@
"fuse.js": "7.1.0",
"get-east-asian-width": "1.5.0",
"google-auth-library": "9.15.1",
"graphology": "^0.26.0",
"graphology-operators": "^1.6.0",
"graphology-pagerank": "^1.1.0",
"https-proxy-agent": "7.0.6",
"ignore": "7.0.5",
"js-tiktoken": "^1.0.16",
"indent-string": "5.0.0",
"jsonc-parser": "3.3.1",
"lodash-es": "4.18.1",
@@ -121,13 +117,11 @@
"strip-ansi": "7.2.0",
"supports-hyperlinks": "3.2.0",
"tree-kill": "1.2.2",
"tree-sitter-wasms": "^0.1.12",
"turndown": "7.2.2",
"type-fest": "4.41.0",
"undici": "7.24.6",
"usehooks-ts": "3.1.1",
"vscode-languageserver-protocol": "3.17.5",
"web-tree-sitter": "^0.25.0",
"wrap-ansi": "9.0.2",
"ws": "8.20.0",
"xss": "1.0.15",
@@ -146,7 +140,7 @@
},
"repository": {
"type": "git",
"url": "https://github.com/Gitlawb/openclaude.git"
"url": "https://gitlawb.com/z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr/openclaude"
},
"keywords": [
"claude-code",

View File

@@ -1,3 +0,0 @@
pytest==7.4.4
pytest-asyncio==0.23.3
httpx==0.25.2

View File

@@ -112,14 +112,6 @@ def build_default_providers() -> list[Provider]:
big_model=big if "gemini" in big else "gemini-2.5-pro",
small_model=small if "gemini" in small else "gemini-2.0-flash",
),
Provider(
name="mistral",
ping_url="",
api_key_env="MISTRAL_API_KEY",
cost_per_1k_tokens=0.0001,
big_model=big if "mistral" in big else "devstral-latest",
small_model=small if "small" in small else "ministral-3b-latest",
),
Provider(
name="ollama",
ping_url=f"{ollama_url}/api/tags",

View File

@@ -1,11 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"packages": {
".": {
"release-type": "node",
"package-name": "@gitlawb/openclaude",
"bump-minor-pre-major": true,
"include-v-in-tag": true
}
}
}

View File

@@ -8,8 +8,7 @@
* - src/ path aliases
*/
import { readFileSync, readdirSync, writeFileSync } from 'fs'
import { join } from 'path'
import { readFileSync } from 'fs'
import { noTelemetryPlugin } from './no-telemetry-plugin'
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
@@ -25,84 +24,25 @@ const featureFlags: Record<string, boolean> = {
BRIDGE_MODE: false,
DAEMON: false,
AGENT_TRIGGERS: false,
MONITOR_TOOL: true,
MONITOR_TOOL: false,
ABLATION_BASELINE: false,
DUMP_SYSTEM_PROMPT: false,
CACHED_MICROCOMPACT: false,
COORDINATOR_MODE: true,
BUILTIN_EXPLORE_PLAN_AGENTS: true,
COORDINATOR_MODE: false,
CONTEXT_COLLAPSE: false,
COMMIT_ATTRIBUTION: false,
TEAMMEM: true,
TEAMMEM: false,
UDS_INBOX: false,
BG_SESSIONS: false,
AWAY_SUMMARY: false,
TRANSCRIPT_CLASSIFIER: false,
WEB_BROWSER_TOOL: false,
MESSAGE_ACTIONS: true,
MESSAGE_ACTIONS: false,
BUDDY: true,
CHICAGO_MCP: false,
COWORKER_TYPE_TELEMETRY: false,
}
// ── Pre-process: replace feature() calls with boolean literals ──────
// Bun v1.3.9+ resolves `import { feature } from 'bun:bundle'` natively
// before plugins can intercept it via onResolve. The bun: namespace is
// handled by Bun's C++ resolver which runs before the JS plugin phase,
// so the previous onResolve/onLoad shim was silently ineffective — ALL
// feature() calls evaluated to false regardless of the featureFlags map.
//
// Fix: pre-process source files to strip the bun:bundle import and
// replace feature('FLAG') calls with their boolean literal. Files are
// modified in-place before Bun.build() and restored in a finally block.
// Match feature('FLAG') calls, including multi-line: feature(\n 'FLAG',\n)
const featureCallRe = /\bfeature\(\s*['"](\w+)['"][,\s]*\)/gs
const featureImportRe = /import\s*\{[^}]*\bfeature\b[^}]*\}\s*from\s*['"]bun:bundle['"];?\s*\n?/g
const modifiedFiles = new Map<string, string>() // path → original content
function preProcessFeatureFlags(dir: string) {
for (const ent of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, ent.name)
if (ent.isDirectory()) { preProcessFeatureFlags(full); continue }
if (!/\.(ts|tsx)$/.test(ent.name)) continue
const raw = readFileSync(full, 'utf-8')
if (!raw.includes('feature(')) continue
let contents = raw
contents = contents.replace(featureImportRe, '')
contents = contents.replace(featureCallRe, (_match, name) =>
String((featureFlags as Record<string, boolean>)[name] ?? false),
)
if (contents !== raw) {
modifiedFiles.set(full, raw)
writeFileSync(full, contents)
}
}
}
function restoreModifiedFiles() {
for (const [path, original] of modifiedFiles) {
writeFileSync(path, original)
}
modifiedFiles.clear()
}
preProcessFeatureFlags(join(import.meta.dir, '..', 'src'))
const numModified = modifiedFiles.size
// Restore source files on abrupt termination (Ctrl+C, kill, etc.)
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
restoreModifiedFiles()
process.exit(signal === 'SIGINT' ? 130 : 143)
})
}
try {
const result = await Bun.build({
entrypoints: ['./src/entrypoints/cli.tsx'],
outdir: './dist',
@@ -163,11 +103,18 @@ export async function handleBgFlag() { throw new Error("Background sessions are
],
] as const)
// bun:bundle feature() replacement is handled by the source
// pre-processing step above (see preProcessFeatureFlags).
// The previous onResolve/onLoad shim was ineffective in Bun
// v1.3.9+ because the bun: namespace is resolved natively
// before the JS plugin phase runs.
// Resolve `import { feature } from 'bun:bundle'` to a shim
build.onResolve({ filter: /^bun:bundle$/ }, () => ({
path: 'bun:bundle',
namespace: 'bun-bundle-shim',
}))
build.onLoad(
{ filter: /.*/, namespace: 'bun-bundle-shim' },
() => ({
contents: `const featureFlags = ${JSON.stringify(featureFlags)};\nexport function feature(name) { return featureFlags[name] ?? false; }`,
loader: 'js',
}),
)
build.onResolve(
{ filter: /^\.\.\/(daemon\/workerRegistry|daemon\/main|cli\/bg|cli\/handlers\/templateJobs|environment-runner\/main|self-hosted-runner\/main)\.js$/ },
@@ -327,7 +274,16 @@ export const SeverityNumber = {};
// Scan source to find imports that can't resolve
function scanForMissingImports() {
function checkAndRegister(specifier: string, fileDir: string, namedPart: string) {
function walk(dir: string) {
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const full = pathMod.join(dir, ent.name)
if (ent.isDirectory()) { walk(full); continue }
if (!/\.(ts|tsx)$/.test(ent.name)) continue
const code: string = fs.readFileSync(full, 'utf-8')
// Collect all imports
for (const m of code.matchAll(/import\s+(?:\{([^}]*)\}|(\w+))?\s*(?:,\s*\{([^}]*)\})?\s*from\s+['"](.*?)['"]/g)) {
const specifier = m[4]
const namedPart = m[1] || m[3] || ''
const names = namedPart.split(',')
.map((s: string) => s.trim().replace(/^type\s+/, ''))
.filter((s: string) => s && !s.startsWith('type '))
@@ -347,7 +303,8 @@ export const SeverityNumber = {};
}
// Check relative .js imports
else if (specifier.endsWith('.js') && (specifier.startsWith('./') || specifier.startsWith('../'))) {
const resolved = pathMod.resolve(fileDir, specifier)
const dir2 = pathMod.dirname(full)
const resolved = pathMod.resolve(dir2, specifier)
const tsVariant = resolved.replace(/\.js$/, '.ts')
const tsxVariant = resolved.replace(/\.js$/, '.tsx')
if (!fs.existsSync(resolved) && !fs.existsSync(tsVariant) && !fs.existsSync(tsxVariant)) {
@@ -360,30 +317,6 @@ export const SeverityNumber = {};
if (!missingModuleExports.has(specifier)) missingModuleExports.set(specifier, new Set())
for (const n of names) missingModuleExports.get(specifier)!.add(n)
}
}
function walk(dir: string) {
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const full = pathMod.join(dir, ent.name)
if (ent.isDirectory()) { walk(full); continue }
if (!/\.(ts|tsx)$/.test(ent.name)) continue
const code: string = fs.readFileSync(full, 'utf-8')
const fileDir = pathMod.dirname(full)
// Collect static imports: import { X } from '...'
for (const m of code.matchAll(/import\s+(?:\{([^}]*)\}|(\w+))?\s*(?:,\s*\{([^}]*)\})?\s*from\s+['"](.*?)['"]/g)) {
checkAndRegister(m[4], fileDir, m[1] || m[3] || '')
}
// Collect dynamic requires: require('...') — these are used
// behind feature() gates and become live when flags are enabled.
for (const m of code.matchAll(/require\(\s*['"](\.\.?\/[^'"]+)['"]\s*\)/g)) {
checkAndRegister(m[1], fileDir, '')
}
// Collect dynamic imports: import('...')
for (const m of code.matchAll(/import\(\s*['"](\.\.?\/[^'"]+)['"]\s*\)/g)) {
checkAndRegister(m[1], fileDir, '')
}
}
}
@@ -456,13 +389,7 @@ if (!result.success) {
for (const log of result.logs) {
console.error(log)
}
process.exitCode = 1
} else {
console.log(`✓ Built openclaude v${version} → dist/cli.mjs`)
process.exit(1)
}
} finally {
// Always restore source files, even if Bun.build() throws
restoreModifiedFiles()
console.log(` 🔄 feature-flags: pre-processed ${numModified} files (restored)`)
}
console.log(`✓ Built openclaude v${version} → dist/cli.mjs`)

View File

@@ -1,146 +0,0 @@
import { afterAll, beforeEach, describe, expect, test } from 'bun:test'
import { mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
// ---------------------------------------------------------------------------
// Setup: extract the growthbook stub from no-telemetry-plugin.ts, write it to
// a temp .mjs file, and dynamically import it so we can test the real code
// that gets bundled.
// ---------------------------------------------------------------------------
const pluginSource = readFileSync(join(__dirname, 'no-telemetry-plugin.ts'), 'utf-8')
const stubMatch = pluginSource.match(/'services\/analytics\/growthbook': `([\s\S]*?)`/)
if (!stubMatch) throw new Error('Could not extract growthbook stub from no-telemetry-plugin.ts')
const testDir = join(tmpdir(), `growthbook-stub-test-${process.pid}`)
const stubFile = join(testDir, 'growthbook-stub.mjs')
const flagsFile = join(testDir, 'test-flags.json')
mkdirSync(testDir, { recursive: true })
writeFileSync(stubFile, stubMatch[1])
// Point the stub at our test flags file (checked by _loadFlags on first access)
process.env.CLAUDE_FEATURE_FLAGS_FILE = flagsFile
const stub = await import(stubFile)
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('growthbook stub — local feature flag overrides', () => {
beforeEach(() => {
stub.resetGrowthBook()
try { unlinkSync(flagsFile) } catch { /* may not exist */ }
})
afterAll(() => {
rmSync(testDir, { recursive: true, force: true })
delete process.env.CLAUDE_FEATURE_FLAGS_FILE
})
// ── File absent ──────────────────────────────────────────────────
test('returns defaultValue when flags file is absent', () => {
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 42)).toBe(42)
})
test('getAllGrowthBookFeatures returns {} when file is absent', () => {
expect(stub.getAllGrowthBookFeatures()).toEqual({})
})
// ── Valid JSON object ────────────────────────────────────────────
test('loads and returns values from a valid JSON file', () => {
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: true, tengu_bar: 'hello' }))
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', false)).toBe(true)
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_bar', 'default')).toBe('hello')
})
test('returns defaultValue for keys not present in the file', () => {
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: true }))
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_missing', 99)).toBe(99)
})
test('getAllGrowthBookFeatures returns the full flags object', () => {
const flags = { tengu_a: true, tengu_b: false, tengu_c: 42 }
writeFileSync(flagsFile, JSON.stringify(flags))
expect(stub.getAllGrowthBookFeatures()).toEqual(flags)
})
// ── Malformed / non-object JSON ──────────────────────────────────
test('falls back to defaults on malformed JSON', () => {
writeFileSync(flagsFile, '{not valid json!!!')
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'fallback')).toBe('fallback')
})
test('falls back to defaults when JSON is a primitive (true)', () => {
writeFileSync(flagsFile, 'true')
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'fallback')).toBe('fallback')
})
test('falls back to defaults when JSON is an array', () => {
writeFileSync(flagsFile, '["a", "b"]')
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'fallback')).toBe('fallback')
})
// ── Cache invalidation ───────────────────────────────────────────
test('resetGrowthBook clears cache so the file is re-read', () => {
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'first' }))
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('first')
// Update the file — cached value is still 'first'
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'second' }))
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('first')
// After reset, the new value is picked up
stub.resetGrowthBook()
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('second')
})
test('refreshGrowthBookFeatures clears cache', async () => {
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'v1' }))
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('v1')
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'v2' }))
await stub.refreshGrowthBookFeatures()
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('v2')
})
// ── Multiple getter variants ─────────────────────────────────────
test('all getter functions read from local flags', async () => {
writeFileSync(flagsFile, JSON.stringify({ tengu_gate: true, tengu_config: { a: 1 } }))
expect(await stub.getFeatureValue_DEPRECATED('tengu_gate', false)).toBe(true)
stub.resetGrowthBook()
expect(stub.getFeatureValue_CACHED_WITH_REFRESH('tengu_gate', false)).toBe(true)
stub.resetGrowthBook()
expect(stub.checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_gate')).toBe(true)
stub.resetGrowthBook()
expect(await stub.checkGate_CACHED_OR_BLOCKING('tengu_gate')).toBe(true)
stub.resetGrowthBook()
expect(await stub.getDynamicConfig_BLOCKS_ON_INIT('tengu_config', {})).toEqual({ a: 1 })
stub.resetGrowthBook()
expect(stub.getDynamicConfig_CACHED_MAY_BE_STALE('tengu_config', {})).toEqual({ a: 1 })
})
// ── Security gate ────────────────────────────────────────────────
test('checkSecurityRestrictionGate always returns false regardless of flags', async () => {
writeFileSync(flagsFile, JSON.stringify({
tengu_disable_bypass_permissions_mode: true,
}))
expect(await stub.checkSecurityRestrictionGate()).toBe(false)
})
})

View File

@@ -34,55 +34,28 @@ export function _resetForTesting() {}
`,
'services/analytics/growthbook': `
import _fs from 'node:fs';
import _path from 'node:path';
import _os from 'node:os';
let _flags = undefined;
function _loadFlags() {
if (_flags !== undefined) return;
try {
const flagsPath = process.env.CLAUDE_FEATURE_FLAGS_FILE
|| _path.join(_os.homedir(), '.claude', 'feature-flags.json');
const parsed = JSON.parse(_fs.readFileSync(flagsPath, 'utf-8'));
_flags = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : null;
} catch {
_flags = null;
}
}
function _getFlagValue(key, defaultValue) {
_loadFlags();
if (_flags != null && Object.hasOwn(_flags, key)) return _flags[key];
return defaultValue;
}
const noop = () => {};
export function onGrowthBookRefresh() { return noop; }
export function hasGrowthBookEnvOverride() { return false; }
export function getAllGrowthBookFeatures() { _loadFlags(); return _flags || {}; }
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 _getFlagValue(feature, defaultValue); }
export function getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue) { return _getFlagValue(feature, defaultValue); }
export function getFeatureValue_CACHED_WITH_REFRESH(feature, defaultValue) { return _getFlagValue(feature, defaultValue); }
export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE(gate) { return Boolean(_getFlagValue(gate, false)); }
// Security killswitch — always false in the open build. Anthropic uses this
// gate to remotely disable bypassPermissions mode; exposing it via local flags
// would let users accidentally lock themselves out of --dangerously-skip-permissions.
export async function checkSecurityRestrictionGate(gate) { return false; }
export async function checkGate_CACHED_OR_BLOCKING(gate) { return Boolean(_getFlagValue(gate, false)); }
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() { _flags = undefined; }
export async function refreshGrowthBookFeatures() { _flags = undefined; }
export function resetGrowthBook() {}
export async function refreshGrowthBookFeatures() {}
export function setupPeriodicGrowthBookRefresh() {}
export function stopPeriodicGrowthBookRefresh() {}
export async function getDynamicConfig_BLOCKS_ON_INIT(configName, defaultValue) { return _getFlagValue(configName, defaultValue); }
export function getDynamicConfig_CACHED_MAY_BE_STALE(configName, defaultValue) { return _getFlagValue(configName, defaultValue); }
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': `

View File

@@ -11,7 +11,6 @@ import {
buildAtomicChatProfileEnv,
buildCodexProfileEnv,
buildGeminiProfileEnv,
buildMistralProfileEnv,
buildOllamaProfileEnv,
buildOpenAIProfileEnv,
createProfileFile,
@@ -38,7 +37,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' || p === 'mistral' || p === 'atomic-chat') return p
if (p === 'openai' || p === 'ollama' || p === 'codex' || p === 'gemini' || p === 'atomic-chat') return p
return 'auto'
}
@@ -91,21 +90,6 @@ async function main(): Promise<void> {
process.exit(1)
}
env = builtEnv
} else if (selected === 'mistral') {
const builtEnv = buildMistralProfileEnv({
model: argModel || null,
baseUrl: argBaseUrl || null,
apiKey: argApiKey || null,
processEnv: process.env,
})
if (!builtEnv) {
console.error('Mistral profile requires an API key. Use --api-key or set MISTRAL_API_KEY.')
console.error('Get a free key at: https://admin.mistral.ai/organization/api-keys')
process.exit(1)
}
env = builtEnv
} else if (selected === 'ollama') {
resolvedOllamaModel ??= await resolveOllamaModel(argModel, argBaseUrl, goal)
@@ -185,7 +169,7 @@ async function main(): Promise<void> {
console.log(`Saved profile: ${selected}`)
console.log(`Goal: ${goal}`)
console.log(`Model: ${profile.env.GEMINI_MODEL || profile.env.MISTRAL_MODEL || profile.env.OPENAI_MODEL || getGoalDefaultOpenAIModel(goal)}`)
console.log(`Model: ${profile.env.GEMINI_MODEL || profile.env.OPENAI_MODEL || getGoalDefaultOpenAIModel(goal)}`)
console.log(`Path: ${outputPath}`)
console.log('Next: bun run dev:profile')
}

View File

@@ -50,7 +50,7 @@ function parseLaunchOptions(argv: string[]): LaunchOptions {
continue
}
if ((lower === 'auto' || lower === 'openai' || lower === 'ollama' || lower === 'codex' || lower === 'gemini' || lower ==='mistral' || lower === 'atomic-chat') && requestedProfile === 'auto') {
if ((lower === 'auto' || lower === 'openai' || lower === 'ollama' || lower === 'codex' || lower === 'gemini' || lower === 'atomic-chat') && requestedProfile === 'auto') {
requestedProfile = lower as ProviderProfile | 'auto'
continue
}
@@ -124,8 +124,6 @@ function printSummary(profile: ProviderProfile): void {
console.log(`Launching profile: ${profile}`)
if (profile === 'gemini') {
console.log('Using configured Gemini provider settings.')
} else if (profile === 'mistral') {
console.log('Using configured Mistral provider settings.')
} else if (profile === 'codex') {
console.log('Using configured Codex/OpenAI-compatible provider settings.')
} else if (profile === 'atomic-chat') {
@@ -141,7 +139,7 @@ async function main(): Promise<void> {
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|mistral|atomic-chat|mistral|auto] [--fast] [--goal <latency|balanced|coding>] [-- <cli args>]')
console.error('Usage: bun run scripts/provider-launch.ts [openai|ollama|codex|gemini|atomic-chat|auto] [--fast] [--goal <latency|balanced|coding>] [-- <cli args>]')
process.exit(1)
}
@@ -207,11 +205,6 @@ async function main(): Promise<void> {
process.exit(1)
}
if (profile === 'mistral' && !env.MISTRAL_API_KEY) {
console.error('MISTRAL_API_KEY is required for mistral profile. Run: bun run profile:init -- --provider mistral --api-key <key>')
process.exit(1)
}
if (profile === 'openai' && (!env.OPENAI_API_KEY || env.OPENAI_API_KEY === 'SUA_CHAVE')) {
console.error('OPENAI_API_KEY is required for openai profile and cannot be SUA_CHAVE. Run: bun run profile:init -- --provider openai --api-key <key>')
process.exit(1)

View File

@@ -118,18 +118,14 @@ function isLocalBaseUrl(baseUrl: string): boolean {
}
const GEMINI_DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/openai'
const MISTRAL_DEFAULT_BASE_URL = 'https://api.mistral.ai/v1'
const GITHUB_COPILOT_BASE = 'https://api.githubcopilot.com'
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_MISTRAL)) {
return process.env.MISTRAL_BASE_URL ?? MISTRAL_DEFAULT_BASE_URL
}
if (isTruthy(process.env.CLAUDE_CODE_USE_GITHUB)) {
return process.env.OPENAI_BASE_URL ?? GITHUB_COPILOT_BASE
return process.env.OPENAI_BASE_URL ?? GITHUB_MODELS_DEFAULT_BASE
}
return process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1'
}
@@ -159,34 +155,9 @@ function checkGeminiEnv(): CheckResult[] {
return results
}
function checkMistralEnv(): CheckResult[] {
const results: CheckResult[] = []
const model = process.env.MISTRAL_MODEL
const key = process.env.MISTRAL_API_KEY
const baseUrl = process.env.MISTRAL_BASE_URL ?? MISTRAL_DEFAULT_BASE_URL
results.push(pass('Provider mode', 'Mistral provider enabled.'))
if (!model) {
results.push(pass('MISTRAL_MODEL', 'Not set. Default will be used at runtime.'))
} else {
results.push(pass('MISTRAL_MODEL', model))
}
results.push(pass('MISTRAL_BASE_URL', baseUrl))
if (!key) {
results.push(fail('MISTRAL_API_KEY', 'Missing. Set MISTRAL_API_KEY.'))
} else {
results.push(pass('MISTRAL_API_KEY', 'Configured.'))
}
return results
}
function checkGithubEnv(): CheckResult[] {
const results: CheckResult[] = []
const baseUrl = process.env.OPENAI_BASE_URL ?? GITHUB_COPILOT_BASE
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
@@ -215,17 +186,12 @@ 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 useMistral = isTruthy(process.env.CLAUDE_CODE_USE_MISTRAL)
const useOpenAI = isTruthy(process.env.CLAUDE_CODE_USE_OPENAI)
if (useGemini) {
return checkGeminiEnv()
}
if (useMistral) {
return checkMistralEnv()
}
if (useGithub && !useOpenAI) {
return checkGithubEnv()
}
@@ -302,9 +268,8 @@ async function checkBaseUrlReachability(): Promise<CheckResult> {
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)
const useMistral = isTruthy(process.env.CLAUDE_CODE_USE_MISTRAL)
if (!useGemini && !useOpenAI && !useGithub && !useMistral) {
if (!useGemini && !useOpenAI && !useGithub) {
return pass('Provider reachability', 'Skipped (OpenAI-compatible mode disabled).')
}
@@ -361,8 +326,6 @@ async function checkBaseUrlReachability(): Promise<CheckResult> {
})
} else if (useGemini && (process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY)) {
headers.Authorization = `Bearer ${process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY}`
} else if (useMistral && process.env.MISTRAL_API_KEY) {
headers.Authorization = `Bearer ${process.env.MISTRAL_API_KEY}`
} else if (process.env.OPENAI_API_KEY) {
headers.Authorization = `Bearer ${process.env.OPENAI_API_KEY}`
}
@@ -410,8 +373,7 @@ function checkOllamaProcessorMode(): CheckResult {
if (
!isTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
isTruthy(process.env.CLAUDE_CODE_USE_GEMINI) ||
isTruthy(process.env.CLAUDE_CODE_USE_GITHUB) ||
isTruthy(process.env.CLAUDE_CODE_USE_MISTRAL)
isTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
) {
return pass('Ollama processor mode', 'Skipped (OpenAI-compatible mode disabled).')
}
@@ -463,14 +425,6 @@ function serializeSafeEnvSummary(): Record<string, string | boolean> {
GEMINI_API_KEY_SET: Boolean(process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY),
}
}
if (isTruthy(process.env.CLAUDE_CODE_USE_MISTRAL)) {
return {
CLAUDE_CODE_USE_MISTRAL: true,
MISTRAL_MODEL: process.env.MISTRAL_MODEL ?? '(unset, default: devstral-latest)',
MISTRAL_BASE_URL: process.env.MISTRAL_BASE_URL ?? 'https://api.mistral.ai/v1',
MISTRAL_API_KEY_SET: Boolean(process.env.MISTRAL_API_KEY),
}
}
if (
isTruthy(process.env.CLAUDE_CODE_USE_GITHUB) &&
!isTruthy(process.env.CLAUDE_CODE_USE_OPENAI)
@@ -481,7 +435,7 @@ function serializeSafeEnvSummary(): Record<string, string | boolean> {
process.env.OPENAI_MODEL ??
'(unset, default: github:copilot → openai/gpt-4.1)',
OPENAI_BASE_URL:
process.env.OPENAI_BASE_URL ?? GITHUB_COPILOT_BASE,
process.env.OPENAI_BASE_URL ?? GITHUB_MODELS_DEFAULT_BASE,
GITHUB_TOKEN_SET: Boolean(
process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN,
),

View File

@@ -1,282 +0,0 @@
/**
* Tests for Bug Fixes applied to openclaude.
*
* Covers:
* 1. Gemini `store: false` rejection fix
* 2. Session timeout / 500 error fix (stream idle timeout)
* 3. Agent loop continuation nudge
* 4. Web search result count improvements
*/
import { describe, test, expect } from 'bun:test'
import { resolve } from 'path'
const SRC = resolve(import.meta.dir, '..')
const file = (relative: string) => Bun.file(resolve(SRC, relative))
// ---------------------------------------------------------------------------
// Fix 1: Gemini `store: false` rejection
// ---------------------------------------------------------------------------
describe('Gemini store field fix', () => {
test('isGeminiMode is imported and used in openaiShim', async () => {
const content = await file('services/api/openaiShim.ts').text()
// Verify the fix: store deletion should check for Gemini mode
expect(content).toContain('isGeminiMode()')
expect(content).toContain("mistral and gemini don't recognize body.store")
// Ensure the delete body.store is guarded for both Mistral and Gemini
expect(content).toMatch(/isMistral\s*\|\|\s*isGeminiMode\(\)/)
})
test('store: false is still set by default (OpenAI needs it)', async () => {
const content = await file('services/api/openaiShim.ts').text()
// The body should still have store: false by default
expect(content).toMatch(/store:\s*false/)
// But it should be deleted for non-OpenAI providers
expect(content).toMatch(/delete body\.store/)
})
})
// ---------------------------------------------------------------------------
// Fix 2: Session timeout — stream idle timeout
// ---------------------------------------------------------------------------
describe('Session timeout fix', () => {
test('openaiShim has idle timeout for SSE streams', async () => {
const content = await file('services/api/openaiShim.ts').text()
expect(content).toContain('STREAM_IDLE_TIMEOUT_MS')
expect(content).toContain('readWithTimeout')
expect(content).toMatch(/readWithTimeout\(\)/)
})
test('codexShim has idle timeout for SSE streams', async () => {
const content = await file('services/api/codexShim.ts').text()
expect(content).toContain('STREAM_IDLE_TIMEOUT_MS')
expect(content).toContain('readWithTimeout')
expect(content).toMatch(/readWithTimeout\(\)/)
})
test('idle timeout is set to a reasonable value (>= 60s)', async () => {
const content = await file('services/api/openaiShim.ts').text()
// Extract the timeout value (supports numeric separators like 120_000)
const match = content.match(/STREAM_IDLE_TIMEOUT_MS\s*=\s*([\d_]+)/)
expect(match).not.toBeNull()
const timeoutMs = parseInt(match![1].replace(/_/g, ''), 10)
expect(timeoutMs).toBeGreaterThanOrEqual(60_000)
})
})
// ---------------------------------------------------------------------------
// Fix 3: Agent loop continuation nudge
// ---------------------------------------------------------------------------
describe('Agent loop continuation nudge', () => {
test('query.ts has continuation signal detection', async () => {
const content = await file('query.ts').text()
expect(content).toContain('continuationSignals')
expect(content).toContain('Continuation nudge triggered')
expect(content).toContain('continuation_nudge')
})
test('continuation signals include tightened patterns', async () => {
const content = await file('query.ts').text()
// Should detect tightened patterns requiring explicit action verbs
expect(content).toMatch(/so now \(i\|let me\|we\)/)
expect(content).toContain('completionMarkers')
expect(content).toContain('MAX_CONTINUATION_NUDGES')
// Verify the nudge counter guard exists
expect(content).toMatch(/continuationNudgeCount\s*<\s*MAX_CONTINUATION_NUDGES/)
})
test('nudge creates a meta user message to continue', async () => {
const content = await file('query.ts').text()
expect(content).toContain(
'Continue with the task. Use the appropriate tools to proceed.',
)
})
})
// ---------------------------------------------------------------------------
// Fix 4: Web search result count improvements
// ---------------------------------------------------------------------------
describe('Web search result count improvements', () => {
test('Bing provider requests at least 15 results', async () => {
const content = await file(
'tools/WebSearchTool/providers/bing.ts',
).text()
expect(content).toMatch(/count.*['"]15['"]/)
})
test('Tavily provider requests at least 15 results', async () => {
const content = await file(
'tools/WebSearchTool/providers/tavily.ts',
).text()
expect(content).toMatch(/max_results:\s*15/)
})
test('Exa provider requests at least 15 results', async () => {
const content = await file(
'tools/WebSearchTool/providers/exa.ts',
).text()
expect(content).toMatch(/numResults:\s*15/)
})
test('Firecrawl provider requests at least 15 results', async () => {
const content = await file(
'tools/WebSearchTool/providers/firecrawl.ts',
).text()
expect(content).toMatch(/limit:\s*15/)
})
test('Mojeek provider requests at least 10 results', async () => {
const content = await file(
'tools/WebSearchTool/providers/mojeek.ts',
).text()
// Mojeek uses 't' param for result count — verify it's set to 10
expect(content).toMatch(/searchParams\.set\('t',\s*'10'\)/)
})
test('You.com provider requests at least 10 results', async () => {
const content = await file(
'tools/WebSearchTool/providers/you.ts',
).text()
expect(content).toMatch(/num_web_results.*['"]10['"]/)
})
test('Jina provider requests at least 10 results', async () => {
const content = await file(
'tools/WebSearchTool/providers/jina.ts',
).text()
expect(content).toMatch(/count.*['"]10['"]/)
})
test('Native Anthropic web search max_uses increased to 15', async () => {
const content = await file(
'tools/WebSearchTool/WebSearchTool.ts',
).text()
expect(content).toMatch(/max_uses:\s*15/)
})
})
// ---------------------------------------------------------------------------
// Fix 5: MCP tool timeout fix
// ---------------------------------------------------------------------------
describe('MCP tool timeout fix', () => {
test('default MCP tool timeout is reasonable (not 27 hours)', async () => {
const content = await file('services/mcp/client.ts').text()
// Should NOT have the old ~27.8 hour default
expect(content).not.toContain('100_000_000')
// Should have a reasonable timeout (5 minutes = 300_000ms)
expect(content).toMatch(/DEFAULT_MCP_TOOL_TIMEOUT_MS\s*=\s*300_000/)
})
test('MCP tools/list has retry logic', async () => {
const content = await file('services/mcp/client.ts').text()
expect(content).toContain('tools/list failed (attempt')
expect(content).toContain('Retrying...')
})
test('MCP URL elicitation checks abort signal', async () => {
const content = await file('services/mcp/client.ts').text()
expect(content).toContain('signal.aborted')
expect(content).toContain('Tool call aborted during URL elicitation')
})
test('MCP tool error messages include server and tool name in telemetry', async () => {
const content = await file('services/mcp/client.ts').text()
// Telemetry message should include context like "MCP tool [serverName] toolName: error"
// The human-readable message stays unchanged to avoid breaking error consumers
expect(content).toContain('MCP tool [${name}] ${tool}:')
})
})
// ---------------------------------------------------------------------------
// Cross-cutting: verify no regressions
// ---------------------------------------------------------------------------
describe('Regression checks', () => {
test('store field is still set for OpenAI (not deleted unconditionally)', async () => {
const content = await file('services/api/openaiShim.ts').text()
// store: false should exist in body construction
expect(content).toMatch(/store:\s*false/)
// But delete body.store should be conditional (guarded by if)
const deleteLines = content.split('\n').filter(l => l.includes('delete body.store'))
expect(deleteLines.length).toBeGreaterThan(0)
// Verify the delete is inside a conditional block by checking surrounding context
for (const line of deleteLines) {
const trimmed = line.trim()
// Should be either inside an if block (indented delete) or a comment
expect(
trimmed.startsWith('delete') && !trimmed.includes('// unconditional'),
).toBe(true)
}
})
})
// ---------------------------------------------------------------------------
// Fix 6: SendMessageTool race condition guard
// ---------------------------------------------------------------------------
describe('SendMessageTool race condition fix', () => {
test('SendMessageTool has double-check for concurrent resume', async () => {
const content = await file('tools/SendMessageTool/SendMessageTool.ts').text()
// Should have a second status check before resuming to prevent race
expect(content).toContain('was concurrently resumed')
// The freshTask check should re-read from getAppState
expect(content).toMatch(/const freshTask = context\.getAppState\(\)\.tasks\[agentId\]/)
})
})
// ---------------------------------------------------------------------------
// Fix 7: AgentTool dump state cleanup
// ---------------------------------------------------------------------------
describe('AgentTool cleanup fix', () => {
test('backgrounded agent always cleans up dump state', async () => {
const content = await file('tools/AgentTool/AgentTool.tsx').text()
// The backgrounded agent's finally block should clean up regardless
// of whether the agent crashed or completed normally
expect(content).toContain('Defensive cleanup: wrap each call so one failure')
// Verify cleanup is wrapped in try/catch for defensive execution
expect(content).toMatch(/try\s*\{\s*clearInvokedSkillsForAgent/)
expect(content).toMatch(/try\s*\{\s*clearDumpState/)
})
})
// ---------------------------------------------------------------------------
// Fix 8: Context overflow 500 error handling
// ---------------------------------------------------------------------------
describe('Context overflow 500 fix', () => {
test('errors.ts has handler for context overflow 500 errors', async () => {
const content = await file('services/api/errors.ts').text()
expect(content).toContain('500 errors caused by context overflow')
expect(content).toContain('too many tokens')
expect(content).toContain('The conversation has grown too large')
})
test('query.ts has circuit breaker safety net for oversized context', async () => {
const content = await file('query.ts').text()
expect(content).toContain('Safety net: when auto-compact')
expect(content).toContain('circuit breaker has tripped')
expect(content).toContain('automatic compaction has failed')
})
})

View File

@@ -1,55 +0,0 @@
/**
* Tests for Web Search Provider result count configurations.
*/
import { describe, test, expect } from 'bun:test'
import { resolve } from 'path'
const SRC = resolve(import.meta.dir, '..', 'tools', 'WebSearchTool', 'providers')
const file = (name: string) => Bun.file(resolve(SRC, name))
describe('Provider result counts', () => {
const providers = [
'bing.ts',
'tavily.ts',
'exa.ts',
'firecrawl.ts',
'mojeek.ts',
'you.ts',
'jina.ts',
'duckduckgo.ts',
// linkup.ts excluded — uses depth param, not a result count field
]
for (const name of providers) {
test(`${name} exists and is readable`, async () => {
const f = file(name)
expect(await f.exists()).toBe(true)
const content = await f.text()
expect(content.length).toBeGreaterThan(100)
})
}
test('No provider hardcodes a limit below 10', async () => {
const suspiciousPatterns = [
/count['":\s]*['"]([1-9])['"]/i,
/limit['":\s]*([1-9])\b/,
/max_results['":\s]*([1-9])\b/,
/numResults['":\s]*([1-9])\b/,
]
for (const name of providers) {
const content = await file(name).text()
for (const pattern of suspiciousPatterns) {
const match = content.match(pattern)
if (match) {
const num = parseInt(match[1], 10)
expect(num).toBeGreaterThanOrEqual(
10,
`${name} has suspiciously low result count: ${match[0]}`,
)
}
}
}
})
})

View File

@@ -1562,8 +1562,29 @@ export function clearInvokedSkillsForAgent(agentId: string): void {
}
}
// Slow operations tracking removed (was internal-only).
// Functions kept as no-ops to avoid breaking callers.
// Slow operations tracking for dev bar
const MAX_SLOW_OPERATIONS = 10
const SLOW_OPERATION_TTL_MS = 10000
export function addSlowOperation(operation: string, durationMs: number): void {
if (process.env.USER_TYPE !== 'ant') return
// Skip tracking for editor sessions (user editing a prompt file in $EDITOR)
// These are intentionally slow since the user is drafting text
if (operation.includes('exec') && operation.includes('claude-prompt-')) {
return
}
const now = Date.now()
// Remove stale operations
STATE.slowOperations = STATE.slowOperations.filter(
op => now - op.timestamp < SLOW_OPERATION_TTL_MS,
)
// Add new operation
STATE.slowOperations.push({ operation, durationMs, timestamp: now })
// Keep only the most recent operations
if (STATE.slowOperations.length > MAX_SLOW_OPERATIONS) {
STATE.slowOperations = STATE.slowOperations.slice(-MAX_SLOW_OPERATIONS)
}
}
const EMPTY_SLOW_OPERATIONS: ReadonlyArray<{
operation: string
@@ -1571,17 +1592,32 @@ const EMPTY_SLOW_OPERATIONS: ReadonlyArray<{
timestamp: number
}> = []
export function addSlowOperation(
_operation: string,
_durationMs: number,
): void {}
export function getSlowOperations(): ReadonlyArray<{
operation: string
durationMs: number
timestamp: number
}> {
return EMPTY_SLOW_OPERATIONS
// Most common case: nothing tracked. Return a stable reference so the
// caller's setState() can bail via Object.is instead of re-rendering at 2fps.
if (STATE.slowOperations.length === 0) {
return EMPTY_SLOW_OPERATIONS
}
const now = Date.now()
// Only allocate a new array when something actually expired; otherwise keep
// the reference stable across polls while ops are still fresh.
if (
STATE.slowOperations.some(op => now - op.timestamp >= SLOW_OPERATION_TTL_MS)
) {
STATE.slowOperations = STATE.slowOperations.filter(
op => now - op.timestamp < SLOW_OPERATION_TTL_MS,
)
if (STATE.slowOperations.length === 0) {
return EMPTY_SLOW_OPERATIONS
}
}
// Safe to return directly: addSlowOperation() reassigns STATE.slowOperations
// before pushing, so the array held in React state is never mutated.
return STATE.slowOperations
}
export function getMainThreadAgentType(): string | undefined {

View File

@@ -14,14 +14,21 @@
import { getOauthConfig } from '../constants/oauth.js'
import { getClaudeAIOAuthTokens } from '../utils/auth.js'
/** Dev override: CLAUDE_BRIDGE_OAUTH_TOKEN, else undefined. */
/** Ant-only dev override: CLAUDE_BRIDGE_OAUTH_TOKEN, else undefined. */
export function getBridgeTokenOverride(): string | undefined {
return process.env.CLAUDE_BRIDGE_OAUTH_TOKEN || undefined
return (
(process.env.USER_TYPE === 'ant' &&
process.env.CLAUDE_BRIDGE_OAUTH_TOKEN) ||
undefined
)
}
/** Dev override: CLAUDE_BRIDGE_BASE_URL, else undefined. */
/** Ant-only dev override: CLAUDE_BRIDGE_BASE_URL, else undefined. */
export function getBridgeBaseUrlOverride(): string | undefined {
return process.env.CLAUDE_BRIDGE_BASE_URL || undefined
return (
(process.env.USER_TYPE === 'ant' && process.env.CLAUDE_BRIDGE_BASE_URL) ||
undefined
)
}
/**

View File

@@ -2194,10 +2194,14 @@ export async function bridgeMain(args: string[]): Promise<void> {
// Session ingress URL for WebSocket connections. In production this is the
// same as baseUrl (Envoy routes /v1/session_ingress/* to session-ingress).
// Locally, session-ingress may run on a different port, so
// CLAUDE_BRIDGE_SESSION_INGRESS_URL can override the default.
// Locally, session-ingress runs on a different port (9413) than the
// contain-provide-api (8211), so CLAUDE_BRIDGE_SESSION_INGRESS_URL must be
// set explicitly. Ant-only, matching CLAUDE_BRIDGE_BASE_URL.
const sessionIngressUrl =
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
process.env.USER_TYPE === 'ant' &&
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
: baseUrl
const { getBranch, getRemoteUrl, findGitRoot } = await import(
'../utils/git.js'
@@ -2847,7 +2851,10 @@ export async function runBridgeHeadless(
)
}
const sessionIngressUrl =
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
process.env.USER_TYPE === 'ant' &&
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
: baseUrl
const { getBranch, getRemoteUrl, findGitRoot } = await import(
'../utils/git.js'

View File

@@ -217,39 +217,25 @@ export async function getBridgeSession(
}
const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}`
const timeoutMs = 10_000
logForDebugging(`[bridge] Fetching session ${sessionId}`)
let response
try {
response = await axios.get<{ environment_id?: string; title?: string }>(
url,
{ headers, timeout: timeoutMs, validateStatus: s => s < 500 },
{ headers, timeout: 10_000, validateStatus: s => s < 500 },
)
} catch (err: unknown) {
if (axios.isAxiosError(err)) {
const status = err.response?.status ?? 'no-response'
const code = err.code ?? 'unknown-code'
const requestUrl = err.config?.url ?? url
const method = err.config?.method?.toUpperCase() ?? 'GET'
const message = err.message ?? errorMessage(err)
const timeout = err.config?.timeout ?? timeoutMs
logForDebugging(
`[bridge] Session fetch request failed: status=${status} code=${code} method=${method} url=${requestUrl} timeout=${timeout} message=${message}`,
)
} else {
logForDebugging(
`[bridge] Session fetch request failed: url=${url} timeout=${timeoutMs} message=${errorMessage(err)}`,
)
}
logForDebugging(
`[bridge] Session fetch request failed: ${errorMessage(err)}`,
)
return null
}
if (response.status !== 200) {
const detail = extractErrorDetail(response.data)
logForDebugging(
`[bridge] Session fetch failed with status ${response.status} url=${url}${detail ? `: ${detail}` : ''}`,
`[bridge] Session fetch failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
)
return null
}

View File

@@ -465,7 +465,10 @@ export async function initReplBridge(
const branch = await getBranch()
const gitRepoUrl = await getRemoteUrl()
const sessionIngressUrl =
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
process.env.USER_TYPE === 'ant' &&
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
: baseUrl
// Assistant-mode sessions advertise a distinct worker_type so the web UI
// can filter them into a dedicated picker. KAIROS guard keeps the

View File

@@ -362,9 +362,15 @@ const proactiveModule =
feature('PROACTIVE') || feature('KAIROS')
? (require('../proactive/index.js') as typeof import('../proactive/index.js'))
: null
const cronSchedulerModule = require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js')
const cronJitterConfigModule = require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js')
const cronGate = require('../tools/ScheduleCronTool/prompt.js') as typeof import('../tools/ScheduleCronTool/prompt.js')
const cronSchedulerModule = feature('AGENT_TRIGGERS')
? (require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js'))
: null
const cronJitterConfigModule = feature('AGENT_TRIGGERS')
? (require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js'))
: null
const cronGate = feature('AGENT_TRIGGERS')
? (require('../tools/ScheduleCronTool/prompt.js') as typeof import('../tools/ScheduleCronTool/prompt.js'))
: null
const extractMemoriesModule = feature('EXTRACT_MEMORIES')
? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js'))
: null
@@ -2695,7 +2701,11 @@ function runHeadlessStreaming(
// the end of run() picks up the queued command.
let cronScheduler: import('../utils/cronScheduler.js').CronScheduler | null =
null
if (cronGate.isKairosCronEnabled()) {
if (
feature('AGENT_TRIGGERS') &&
cronSchedulerModule &&
cronGate?.isKairosCronEnabled()
) {
cronScheduler = cronSchedulerModule.createCronScheduler({
onFire: prompt => {
if (inputClosed) return
@@ -2717,8 +2727,8 @@ function runHeadlessStreaming(
void run()
},
isLoading: () => running || inputClosed,
getJitterConfig: cronJitterConfigModule.getCronJitterConfig,
isKilled: () => !cronGate.isKairosCronEnabled(),
getJitterConfig: cronJitterConfigModule?.getCronJitterConfig,
isKilled: () => !cronGate?.isKairosCronEnabled(),
})
cronScheduler.start()
}
@@ -4582,7 +4592,7 @@ function handleSetPermissionMode(
subtype: 'error',
request_id: requestId,
error:
'Cannot set permission mode to bypassPermissions. Enable it with --allow-dangerously-skip-permissions or set permissions.allowBypassPermissionsMode in settings.json',
'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions',
},
})
return toolPermissionContext

View File

@@ -400,12 +400,12 @@ export async function update() {
if (useLocalUpdate) {
process.stderr.write('Try manually updating with:\n')
process.stderr.write(
` cd ~/.openclaude/local && npm update ${MACRO.PACKAGE_URL}\n`,
` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`,
)
} else {
process.stderr.write('Try running with sudo or fix npm permissions\n')
process.stderr.write(
'Or consider using native installation with: openclaude install\n',
'Or consider using native installation with: claude install\n',
)
}
await gracefulShutdown(1)
@@ -415,11 +415,11 @@ export async function update() {
if (useLocalUpdate) {
process.stderr.write('Try manually updating with:\n')
process.stderr.write(
` cd ~/.openclaude/local && npm update ${MACRO.PACKAGE_URL}\n`,
` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`,
)
} else {
process.stderr.write(
'Or consider using native installation with: openclaude install\n',
'Or consider using native installation with: claude install\n',
)
}
await gracefulShutdown(1)

View File

@@ -22,7 +22,6 @@ 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 repomap from './commands/repomap/index.js'
import help from './commands/help/index.js'
import ide from './commands/ide/index.js'
import init from './commands/init.js'
@@ -33,7 +32,6 @@ import logout from './commands/logout/index.js'
import installGitHubApp from './commands/install-github-app/index.js'
import installSlackApp from './commands/install-slack-app/index.js'
import breakCache from './commands/break-cache/index.js'
import cacheProbe from './commands/cache-probe/index.js'
import mcp from './commands/mcp/index.js'
import mobile from './commands/mobile/index.js'
import onboarding from './commands/onboarding/index.js'
@@ -138,7 +136,6 @@ import hooks from './commands/hooks/index.js'
import files from './commands/files/index.js'
import branch from './commands/branch/index.js'
import agents from './commands/agents/index.js'
import autoFix from './commands/auto-fix.js'
import plugin from './commands/plugin/index.js'
import reloadPlugins from './commands/reload-plugins/index.js'
import rewind from './commands/rewind/index.js'
@@ -146,7 +143,6 @@ import heapDump from './commands/heapdump/index.js'
import mockLimits from './commands/mock-limits/index.js'
import bridgeKick from './commands/bridge-kick.js'
import version from './commands/version.js'
import wiki from './commands/wiki/index.js'
import summary from './commands/summary/index.js'
import {
resetLimits,
@@ -267,10 +263,8 @@ const COMMANDS = memoize((): Command[] => [
addDir,
advisor,
agents,
autoFix,
branch,
btw,
cacheProbe,
chrome,
clear,
color,
@@ -308,7 +302,6 @@ const COMMANDS = memoize((): Command[] => [
releaseNotes,
reloadPlugins,
rename,
repomap,
resume,
session,
skills,
@@ -331,7 +324,6 @@ const COMMANDS = memoize((): Command[] => [
usage,
usageReport,
vim,
wiki,
...(webCmd ? [webCmd] : []),
...(forkCmd ? [forkCmd] : []),
...(buddy ? [buddy] : []),

View File

@@ -1,25 +0,0 @@
import type { Command } from '../types/command.js'
const command: Command = {
name: 'auto-fix',
description: 'Configure auto-fix: run lint/test after AI edits',
isEnabled: () => true,
type: 'prompt',
progressMessage: 'Configuring auto-fix...',
contentLength: 0,
source: 'builtin',
async getPromptForCommand() {
return [
{
type: 'text',
text:
'The user wants to configure auto-fix settings. Auto-fix automatically runs lint and test commands after AI file edits, feeding errors back for self-repair.\n\n' +
'Current settings location: `.claude/settings.json` or `.claude/settings.local.json`\n\n' +
'Example configuration:\n```json\n{\n "autoFix": {\n "enabled": true,\n "lint": "eslint . --fix",\n "test": "bun test",\n "maxRetries": 3,\n "timeout": 30000\n }\n}\n```\n\n' +
'Ask the user what lint and test commands they use, then help them set up the configuration.',
},
]
},
}
export default command

View File

@@ -1,413 +0,0 @@
import { getSessionId } from '../../bootstrap/state.js'
import { resolveProviderRequest } from '../../services/api/providerConfig.js'
import type { LocalCommandCall } from '../../types/command.js'
import { logForDebugging } from '../../utils/debug.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js'
import { getMainLoopModel } from '../../utils/model/model.js'
const COPILOT_HEADERS: Record<string, string> = {
'User-Agent': 'GitHubCopilotChat/0.26.7',
'Editor-Version': 'vscode/1.99.3',
'Editor-Plugin-Version': 'copilot-chat/0.26.7',
'Copilot-Integration-Id': 'vscode-chat',
}
// Large system prompt (~6000 chars, ~1500 tokens) to cross the 1024-token cache threshold
const SYSTEM_PROMPT = [
'You are a coding assistant. Answer concisely.',
'CONTEXT: User is working on a TypeScript project with Bun runtime.',
...Array.from(
{ length: 80 },
(_, i) =>
`Rule ${i + 1}: Follow best practices for TypeScript including strict typing, error handling, testing, and clean code. Prefer explicit types over any. Use const assertions. Await all async operations.`,
),
].join('\n\n')
const USER_MESSAGE = 'Say "hello" and nothing else.'
const DELAY_MS = 3000
/**
* Extract model family from a versioned model string.
* e.g. "gpt-5.4-0626" → "gpt-5.4", "codex-mini-latest" → "codex-mini"
*/
function getModelFamily(model: string | undefined): string {
if (!model) return 'unknown'
return model
.replace(/-\d{4,}$/, '')
.replace(/-latest$/, '')
.replace(/-preview$/, '')
}
function getField(obj: unknown, path: string): unknown {
return path
.split('.')
.reduce((o: any, k: string) => (o != null ? o[k] : undefined), obj)
}
interface ProbeResult {
label: string
status: number
elapsed: number
headers: Record<string, string>
usage: Record<string, unknown> | null
responseId: string | null
error: string | null
}
async function sendProbe(
url: string,
headers: Record<string, string>,
body: Record<string, unknown>,
label: string,
): Promise<ProbeResult> {
const start = Date.now()
let response: Response
try {
response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
})
} catch (err: any) {
return {
label,
status: 0,
elapsed: Date.now() - start,
headers: {},
usage: null,
responseId: null,
error: err.message,
}
}
const elapsed = Date.now() - start
const respHeaders: Record<string, string> = {}
response.headers.forEach((value, key) => {
respHeaders[key] = value
})
if (!response.ok) {
const errorBody = await response.text().catch(() => '')
return {
label,
status: response.status,
elapsed,
headers: respHeaders,
usage: null,
responseId: null,
error: errorBody,
}
}
// Parse SSE stream for usage data
const text = await response.text()
let usage: Record<string, unknown> | null = null
let responseId: string | null = null
const isResponses = url.endsWith('/responses')
for (const chunk of text.split('\n\n')) {
const lines = chunk
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
if (isResponses) {
const eventLine = lines.find((l) => l.startsWith('event: '))
const dataLines = lines.filter((l) => l.startsWith('data: '))
if (!eventLine || !dataLines.length) continue
const event = eventLine.slice(7).trim()
if (
event === 'response.completed' ||
event === 'response.incomplete'
) {
try {
const data = JSON.parse(
dataLines.map((l) => l.slice(6)).join('\n'),
)
usage = (data?.response?.usage as Record<string, unknown>) ?? null
responseId = (data?.response?.id as string) ?? null
} catch {}
}
} else {
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const raw = line.slice(6).trim()
if (raw === '[DONE]') continue
try {
const data = JSON.parse(raw) as Record<string, unknown>
if (data.usage) {
usage = data.usage as Record<string, unknown>
responseId = (data.id as string) ?? null
}
} catch {}
}
}
}
return { label, status: response.status, elapsed, headers: respHeaders, usage, responseId, error: null }
}
function formatResult(r: ProbeResult): string {
const lines: string[] = [`--- ${r.label} ---`]
if (r.error) {
lines.push(` ERROR (HTTP ${r.status}): ${r.error.slice(0, 200)}`)
return lines.join('\n')
}
lines.push(` HTTP ${r.status}${r.elapsed}ms`)
if (r.responseId) lines.push(` response.id: ${r.responseId}`)
if (r.usage) {
lines.push(' Usage:')
lines.push(` ${JSON.stringify(r.usage, null, 2).replace(/\n/g, '\n ')}`)
} else {
lines.push(' Usage: null')
}
// Interesting headers
for (const h of [
'openai-processing-ms',
'x-ratelimit-remaining',
'x-ratelimit-limit',
'x-ms-region',
'x-github-request-id',
'x-request-id',
]) {
if (r.headers[h]) lines.push(` ${h}: ${r.headers[h]}`)
}
return lines.join('\n')
}
export const call: LocalCommandCall = async (args) => {
const parts = (args ?? '').trim().split(/\s+/).filter(Boolean)
const noKey = parts.includes('--no-key')
const modelOverride = parts.find((p) => !p.startsWith('--')) || undefined
const modelStr = modelOverride ?? getMainLoopModel()
const request = resolveProviderRequest({ model: modelStr })
const isGithub = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
// Resolve API key the same way the OpenAI shim does
let apiKey = process.env.OPENAI_API_KEY ?? ''
if (!apiKey && isGithub) {
hydrateGithubModelsTokenFromSecureStorage()
apiKey =
process.env.OPENAI_API_KEY ??
process.env.GITHUB_TOKEN ??
process.env.GH_TOKEN ??
''
}
if (!apiKey) {
return {
type: 'text',
value:
'No API key found. Make sure you are in an active OpenAI-compatible or GitHub Copilot session.\n' +
'For GitHub Copilot: run /onboard-github first.\n' +
'For OpenAI-compatible: set OPENAI_API_KEY.',
}
}
const useResponses = request.transport === 'codex_responses'
const endpoint = useResponses ? '/responses' : '/chat/completions'
const url = `${request.baseUrl}${endpoint}`
const family = getModelFamily(request.resolvedModel)
const cacheKey = `${getSessionId()}:${family}`
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
originator: 'openclaude',
}
if (isGithub) {
Object.assign(headers, COPILOT_HEADERS)
}
let body: Record<string, unknown>
if (useResponses) {
body = {
model: request.resolvedModel,
instructions: SYSTEM_PROMPT,
input: [
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: USER_MESSAGE }],
},
],
stream: true,
...(noKey ? {} : {
store: false,
prompt_cache_key: cacheKey,
prompt_cache_retention: '24h',
}),
}
} else {
body = {
model: request.resolvedModel,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: USER_MESSAGE },
],
stream: true,
stream_options: { include_usage: true },
max_tokens: 20,
...(noKey ? {} : {
store: false,
prompt_cache_key: cacheKey,
}),
}
}
// Log configuration
const config = [
`[cache-probe] Starting cache probe${noKey ? ' (--no-key: cache params OMITTED)' : ''}`,
` model: ${request.resolvedModel} (family: ${family})`,
` transport: ${request.transport}`,
` endpoint: ${url}`,
` prompt_cache_key: ${noKey ? 'NOT SENT' : cacheKey}`,
` store: ${noKey ? 'NOT SENT' : 'false'}`,
` system prompt: ~${Math.round(SYSTEM_PROMPT.length / 4)} tokens`,
` delay between calls: ${DELAY_MS}ms`,
].join('\n')
logForDebugging(config)
// Call 1 — Cold
const r1 = await sendProbe(url, headers, body, 'CALL 1 — Cold (no cache)')
logForDebugging(`[cache-probe]\n${formatResult(r1)}`)
if (r1.error) {
return {
type: 'text',
value: `Cache probe failed on first call: HTTP ${r1.status}\n${r1.error.slice(0, 300)}\n\nFull details in debug log.`,
}
}
// Wait
await new Promise((r) => setTimeout(r, DELAY_MS))
// Call 2 — Warm
const r2 = await sendProbe(url, headers, body, 'CALL 2 — Warm (cache expected)')
logForDebugging(`[cache-probe]\n${formatResult(r2)}`)
// --- Comparison ---
const fields = [
'input_tokens',
'output_tokens',
'total_tokens',
'prompt_tokens',
'completion_tokens',
'input_tokens_details.cached_tokens',
'prompt_tokens_details.cached_tokens',
'output_tokens_details.reasoning_tokens',
]
const comparison: string[] = ['[cache-probe] COMPARISON']
comparison.push(
` ${'Field'.padEnd(42)} ${'Call 1'.padStart(8)} ${'Call 2'.padStart(8)} ${'Delta'.padStart(8)}`,
)
comparison.push(` ${'-'.repeat(72)}`)
for (const f of fields) {
const v1 = getField(r1.usage, f)
const v2 = getField(r2.usage, f)
if (v1 === undefined && v2 === undefined) continue
const d =
typeof v1 === 'number' && typeof v2 === 'number' ? v2 - v1 : ''
comparison.push(
` ${f.padEnd(42)} ${String(v1 ?? '-').padStart(8)} ${String(v2 ?? '-').padStart(8)} ${String(d).padStart(8)}`,
)
}
comparison.push('')
comparison.push(
` Latency: ${r1.elapsed}ms → ${r2.elapsed}ms (${r2.elapsed - r1.elapsed > 0 ? '+' : ''}${r2.elapsed - r1.elapsed}ms)`,
)
// Header comparison
for (const h of ['openai-processing-ms', 'x-ms-region', 'x-ratelimit-remaining']) {
const v1 = r1.headers[h]
const v2 = r2.headers[h]
if (v1 || v2) {
comparison.push(` ${h}: ${v1 ?? '-'}${v2 ?? '-'}`)
}
}
// Verdict
const cached2 =
(getField(r2.usage, 'input_tokens_details.cached_tokens') as number) ??
(getField(r2.usage, 'prompt_tokens_details.cached_tokens') as number) ??
0
const input1 =
((r1.usage?.input_tokens ?? r1.usage?.prompt_tokens) as number) ?? 0
const input2 =
((r2.usage?.input_tokens ?? r2.usage?.prompt_tokens) as number) ?? 0
let verdict: string
if (cached2 > 0) {
const rate = input2 > 0 ? Math.round((cached2 / input2) * 100) : '?'
verdict = `CACHE HIT: ${cached2} cached tokens (${rate}% of input)`
} else if (input1 === 0 && input2 === 0) {
verdict = 'INCONCLUSIVE: Server returns 0 input_tokens — cannot measure'
} else if (r2.elapsed < r1.elapsed * 0.6 && input1 > 100) {
verdict = `POSSIBLE SILENT CACHING: Call 2 was ${Math.round((1 - r2.elapsed / r1.elapsed) * 100)}% faster but no cached_tokens reported`
} else {
verdict = 'NO CACHE DETECTED'
}
comparison.push(`\n Verdict: ${verdict}`)
// --- Simulate what main's shim code does with this usage ---
// codexShim.ts makeUsage() — used for Responses API (GPT-5+/Codex)
function mainMakeUsage(u: any) {
return {
input_tokens: u?.input_tokens ?? 0,
output_tokens: u?.output_tokens ?? 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0, // ← main hardcodes this to 0
}
}
// openaiShim.ts convertChunkUsage() — used for Chat Completions
function mainConvertChunkUsage(u: any) {
return {
input_tokens: u?.prompt_tokens ?? 0,
output_tokens: u?.completion_tokens ?? 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: u?.prompt_tokens_details?.cached_tokens ?? 0,
}
}
const shimFn = useResponses ? mainMakeUsage : mainConvertChunkUsage
const shim1 = shimFn(r1.usage)
const shim2 = shimFn(r2.usage)
comparison.push('')
comparison.push(` --- What main's shim reports (${useResponses ? 'codexShim.makeUsage' : 'openaiShim.convertChunkUsage'}) ---`)
comparison.push(` Call 1: cache_read_input_tokens=${shim1.cache_read_input_tokens}`)
comparison.push(` Call 2: cache_read_input_tokens=${shim2.cache_read_input_tokens}`)
if (useResponses && cached2 > 0) {
comparison.push(` BUG: Server returned ${cached2} cached tokens but main's makeUsage() drops it → reports 0`)
} else if (!useResponses && shim2.cache_read_input_tokens > 0) {
comparison.push(` OK: Chat Completions path on main correctly reads cached_tokens`)
}
logForDebugging(comparison.join('\n'))
// User-facing summary
const mode = noKey ? ' (NO cache key sent)' : ''
const shimLabel = useResponses ? 'codexShim.makeUsage()' : 'openaiShim.convertChunkUsage()'
const summary = [
`Cache Probe — ${request.resolvedModel} via ${useResponses ? 'Responses API' : 'Chat Completions'}${mode}`,
'',
`Call 1: ${r1.elapsed}ms, input=${input1}, cached=${(getField(r1.usage, 'input_tokens_details.cached_tokens') as number) ?? (getField(r1.usage, 'prompt_tokens_details.cached_tokens') as number) ?? 0}`,
`Call 2: ${r2.elapsed}ms, input=${input2}, cached=${cached2}`,
'',
verdict,
'',
`What main's ${shimLabel} reports:`,
` Call 2 cache_read_input_tokens = ${shim2.cache_read_input_tokens}${useResponses && cached2 > 0 ? ' ← BUG: server sent ' + cached2 + ' but main drops it' : ''}`,
'',
'Full details written to debug log.',
].join('\n')
return { type: 'text', value: summary }
}

View File

@@ -1,17 +0,0 @@
import type { Command } from '../../commands.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
const cacheProbe: Command = {
type: 'local',
name: 'cache-probe',
description:
'Send identical requests to test prompt caching (results in debug log)',
argumentHint: '[model] [--no-key]',
isEnabled: () =>
isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB),
supportsNonInteractive: false,
load: () => import('./cache-probe.js'),
}
export default cacheProbe

View File

@@ -45,7 +45,7 @@ function getPromptContent(
<!-- CHANGELOG:END -->`
let slackStep = `
5. After creating/updating the PR, check if the user's AGENTS.md or CLAUDE.md mentions posting to Slack channels. If it does, use ToolSearch to search for "slack send message" tools. If ToolSearch finds a Slack tool, ask the user if they'd like you to post the PR URL to the relevant Slack channel. Only post if the user confirms. If ToolSearch returns no results or errors, skip this step silently—do not mention the failure, do not attempt workarounds, and do not try alternative approaches.`
5. After creating/updating the PR, check if the user's CLAUDE.md mentions posting to Slack channels. If it does, use ToolSearch to search for "slack send message" tools. If ToolSearch finds a Slack tool, ask the user if they'd like you to post the PR URL to the relevant Slack channel. Only post if the user confirms. If ToolSearch returns no results or errors, skip this step silently—do not mention the failure, do not attempt workarounds, and do not try alternative approaches.`
if (process.env.USER_TYPE === 'ant' && isUndercover()) {
prefix = getUndercoverInstructions() + '\n'
reviewerArg = ''

View File

@@ -1,43 +0,0 @@
import { afterEach, expect, mock, test } from 'bun:test'
const originalClaudeCodeNewInit = process.env.CLAUDE_CODE_NEW_INIT
async function importInitCommand() {
return (await import(`./init.ts?ts=${Date.now()}-${Math.random()}`)).default
}
afterEach(() => {
mock.restore()
if (originalClaudeCodeNewInit === undefined) {
delete process.env.CLAUDE_CODE_NEW_INIT
} else {
process.env.CLAUDE_CODE_NEW_INIT = originalClaudeCodeNewInit
}
})
test('NEW_INIT prompt preserves existing root CLAUDE.md by default', async () => {
process.env.CLAUDE_CODE_NEW_INIT = '1'
mock.module('../projectOnboardingState.js', () => ({
maybeMarkProjectOnboardingComplete: () => {},
}))
mock.module('./initMode.js', () => ({
isNewInitEnabled: () => true,
}))
const command = await importInitCommand()
const blocks = await command.getPromptForCommand()
expect(blocks).toHaveLength(1)
expect(blocks[0]?.type).toBe('text')
expect(String(blocks[0]?.text)).toContain(
'checked-in root `CLAUDE.md` and does NOT already have a root `AGENTS.md`',
)
expect(String(blocks[0]?.text)).toContain(
'do NOT silently create a second root instruction file',
)
expect(String(blocks[0]?.text)).toContain(
'update the existing root `CLAUDE.md` in place by default',
)
})

View File

@@ -1,6 +1,7 @@
import { feature } from 'bun:bundle'
import type { Command } from '../commands.js'
import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js'
import { isNewInitEnabled } from './initMode.js'
import { isEnvTruthy } from '../utils/envUtils.js'
const OLD_INIT_PROMPT = `Please analyze this codebase and create a CLAUDE.md file, which will be given to future instances of Claude Code to operate in this repository.
@@ -24,19 +25,19 @@ Usage notes:
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
\`\`\``
const NEW_INIT_PROMPT = `Set up a minimal AGENTS.md (and optionally CLAUDE.local.md, skills, and hooks) for this repo. The root project instruction file is loaded into every Claude Code session, so it must be concise — only include what Claude would get wrong without it.
const NEW_INIT_PROMPT = `Set up a minimal CLAUDE.md (and optionally skills and hooks) for this repo. CLAUDE.md is loaded into every Claude Code session, so it must be concise — only include what Claude would get wrong without it.
## Phase 1: Ask what to set up
Use AskUserQuestion to find out what the user wants:
- "Which instruction files should /init set up?"
Options: "Project AGENTS.md" | "Personal CLAUDE.local.md" | "Both project + personal"
- "Which CLAUDE.md files should /init set up?"
Options: "Project CLAUDE.md" | "Personal CLAUDE.local.md" | "Both project + personal"
Description for project: "Team-shared instructions checked into source control — architecture, coding standards, common workflows."
Description for personal: "Your private preferences for this project (gitignored, not shared) — your role, sandbox URLs, preferred test data, workflow quirks."
- "Also set up skills and hooks?"
Options: "Skills + hooks" | "Skills only" | "Hooks only" | "Neither, just the instruction file(s)"
Options: "Skills + hooks" | "Skills only" | "Hooks only" | "Neither, just CLAUDE.md"
Description for skills: "On-demand capabilities you or Claude invoke with \`/skill-name\` — good for repeatable workflows and reference knowledge."
Description for hooks: "Deterministic shell commands that run on tool events (e.g., format after every edit). Claude can't skip them."
@@ -58,24 +59,24 @@ Note what you could NOT figure out from code alone — these become interview qu
## Phase 3: Fill in the gaps
Use AskUserQuestion to gather what you still need to write good instruction files and skills. Ask only things the code can't answer.
Use AskUserQuestion to gather what you still need to write good CLAUDE.md files and skills. Ask only things the code can't answer.
If the user chose project AGENTS.md or both: ask about codebase practices — non-obvious commands, gotchas, branch/PR conventions, required env setup, testing quirks. Skip things already in README or obvious from manifest files. Do not mark any options as "recommended" — this is about how their team works, not best practices.
If the user chose project CLAUDE.md or both: ask about codebase practices — non-obvious commands, gotchas, branch/PR conventions, required env setup, testing quirks. Skip things already in README or obvious from manifest files. Do not mark any options as "recommended" — this is about how their team works, not best practices.
If the user chose personal CLAUDE.local.md or both: ask about them, not the codebase. Do not mark any options as "recommended" — this is about their personal preferences, not best practices. Examples of questions:
- What's their role on the team? (e.g., "backend engineer", "data scientist", "new hire onboarding")
- How familiar are they with this codebase and its languages/frameworks? (so Claude can calibrate explanation depth)
- Do they have personal sandbox URLs, test accounts, API key paths, or local setup details Claude should know?
- Only if Phase 2 found multiple git worktrees: ask whether their worktrees are nested inside the main repo (e.g., \`.claude/worktrees/<name>/\`) or siblings/external (e.g., \`../myrepo-feature/\`). If nested, the upward file walk finds the main repo's CLAUDE.local.md automatically — no special handling needed. If sibling/external, the personal content should live in a home-directory file (e.g., \`~/.claude/<project-name>-instructions.md\`) and each worktree gets a one-line CLAUDE.local.md stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. Never put this import in the project AGENTS.md — that would check a personal reference into the team-shared file.
- Only if Phase 2 found multiple git worktrees: ask whether their worktrees are nested inside the main repo (e.g., \`.claude/worktrees/<name>/\`) or siblings/external (e.g., \`../myrepo-feature/\`). If nested, the upward file walk finds the main repo's CLAUDE.local.md automatically — no special handling needed. If sibling/external, the personal content should live in a home-directory file (e.g., \`~/.claude/<project-name>-instructions.md\`) and each worktree gets a one-line CLAUDE.local.md stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. Never put this import in the project CLAUDE.md — that would check a personal reference into the team-shared file.
- Any communication preferences? (e.g., "be terse", "always explain tradeoffs", "don't summarize at the end")
**Synthesize a proposal from Phase 2 findings** — e.g., format-on-edit if a formatter exists, a project verification workflow if tests exist, an AGENTS.md note for anything from the gap-fill answers that's a guideline rather than a workflow. For each, pick the artifact type that fits, **constrained by the Phase 1 skills+hooks choice**:
**Synthesize a proposal from Phase 2 findings** — e.g., format-on-edit if a formatter exists, a project verification workflow if tests exist, a CLAUDE.md note for anything from the gap-fill answers that's a guideline rather than a workflow. For each, pick the artifact type that fits, **constrained by the Phase 1 skills+hooks choice**:
- **Hook** (stricter) — deterministic shell command on a tool event; Claude can't skip it. Fits mechanical, fast, per-edit steps: formatting, linting, running a quick test on the changed file.
- **Skill** (on-demand) — you or Claude invoke \`/skill-name\` when you want it. Fits workflows that don't belong on every edit: deep verification, session reports, deploys.
- **AGENTS.md note** (looser) — influences Claude's behavior but not enforced. Fits communication/thinking preferences: "plan before coding", "be terse", "explain tradeoffs".
- **CLAUDE.md note** (looser) — influences Claude's behavior but not enforced. Fits communication/thinking preferences: "plan before coding", "be terse", "explain tradeoffs".
**Respect Phase 1's skills+hooks choice as a hard filter**: if the user picked "Skills only", downgrade any hook you'd suggest to a skill or an AGENTS.md note. If "Hooks only", downgrade skills to hooks (where mechanically possible) or notes. If "Neither", everything becomes an AGENTS.md note. Never propose an artifact type the user didn't opt into.
**Respect Phase 1's skills+hooks choice as a hard filter**: if the user picked "Skills only", downgrade any hook you'd suggest to a skill or a CLAUDE.md note. If "Hooks only", downgrade skills to hooks (where mechanically possible) or notes. If "Neither", everything becomes a CLAUDE.md note. Never propose an artifact type the user didn't opt into.
**Show the proposal via AskUserQuestion's \`preview\` field, not as a separate text message** — the dialog overlays your output, so preceding text is hidden. The \`preview\` field renders markdown in a side-panel (like plan mode); the \`question\` field is plain-text-only. Structure it as:
@@ -85,19 +86,17 @@ If the user chose personal CLAUDE.local.md or both: ask about them, not the code
• **Format-on-edit hook** (automatic) — \`ruff format <file>\` via PostToolUse
• **Verification workflow** (on-demand) — \`make lint && make typecheck && make test\`
• **AGENTS.md note** (guideline) — "run lint/typecheck/test before marking done"
• **CLAUDE.md note** (guideline) — "run lint/typecheck/test before marking done"
- Option labels stay short ("Looks good", "Drop the hook", "Drop the skill") — the tool auto-adds an "Other" free-text option, so don't add your own catch-all.
**Build the preference queue** from the accepted proposal. Each entry: {type: hook|skill|note, description, target file, any Phase-2-sourced details like the actual test/format command}. Phases 4-7 consume this queue.
## Phase 4: Write AGENTS.md (if user chose project or both)
## Phase 4: Write CLAUDE.md (if user chose project or both)
Write a minimal AGENTS.md at the project root. Every line must pass this test: "Would removing this cause Claude to make mistakes?" If no, cut it.
Write a minimal CLAUDE.md at the project root. Every line must pass this test: "Would removing this cause Claude to make mistakes?" If no, cut it.
If the repo already has a checked-in root \`CLAUDE.md\` and does NOT already have a root \`AGENTS.md\`, do NOT silently create a second root instruction file. In that case, update the existing root \`CLAUDE.md\` in place by default. Only create or migrate to root \`AGENTS.md\` if the user explicitly asks to migrate.
**Consume \`note\` entries from the Phase 3 preference queue whose target is AGENTS.md** (team-level notes) — add each as a concise line in the most relevant section. These are the behaviors the user wants Claude to follow but didn't need guaranteed (e.g., "propose a plan before implementing", "explain the tradeoffs when refactoring"). Leave personal-targeted notes for Phase 5.
**Consume \`note\` entries from the Phase 3 preference queue whose target is CLAUDE.md** (team-level notes) — add each as a concise line in the most relevant section. These are the behaviors the user wants Claude to follow but didn't need guaranteed (e.g., "propose a plan before implementing", "explain the tradeoffs when refactoring"). Leave personal-targeted notes for Phase 5.
Include:
- Build/test/lint commands Claude can't guess (non-standard scripts, flags, or sequences)
@@ -112,7 +111,7 @@ Exclude:
- File-by-file structure or component lists (Claude can discover these by reading the codebase)
- Standard language conventions Claude already knows
- Generic advice ("write clean code", "handle errors")
- Detailed API docs or long references — use \`@path/to/import\` syntax instead (e.g., \`@docs/api-reference.md\`) to inline content on demand without bloating AGENTS.md
- Detailed API docs or long references — use \`@path/to/import\` syntax instead (e.g., \`@docs/api-reference.md\`) to inline content on demand without bloating CLAUDE.md
- Information that changes frequently — reference the source with \`@path/to/import\` so Claude always reads the current version
- Long tutorials or walkthroughs (move to a separate file and reference with \`@path/to/import\`, or put in a skill)
- Commands obvious from manifest files (e.g., standard "npm test", "cargo test", "pytest")
@@ -124,20 +123,20 @@ Do not repeat yourself and do not make up sections like "Common Development Task
Prefix the file with:
\`\`\`
# AGENTS.md
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
\`\`\`
If AGENTS.md already exists: read it, propose specific changes as diffs, and explain why each change improves it. Do not silently overwrite.
If CLAUDE.md already exists: read it, propose specific changes as diffs, and explain why each change improves it. Do not silently overwrite.
For projects with multiple concerns, suggest organizing instructions into \`.claude/rules/\` as separate focused files (e.g., \`code-style.md\`, \`testing.md\`, \`security.md\`). These are loaded automatically alongside AGENTS.md and can be scoped to specific file paths using \`paths\` frontmatter.
For projects with multiple concerns, suggest organizing instructions into \`.claude/rules/\` as separate focused files (e.g., \`code-style.md\`, \`testing.md\`, \`security.md\`). These are loaded automatically alongside CLAUDE.md and can be scoped to specific file paths using \`paths\` frontmatter.
For projects with distinct subdirectories (monorepos, multi-module projects, etc.): mention that subdirectory AGENTS.md files can be added for module-specific instructions (they're loaded automatically when Claude works in those directories). Offer to create them if the user wants.
For projects with distinct subdirectories (monorepos, multi-module projects, etc.): mention that subdirectory CLAUDE.md files can be added for module-specific instructions (they're loaded automatically when Claude works in those directories). Offer to create them if the user wants.
## Phase 5: Write CLAUDE.local.md (if user chose personal or both)
Write a minimal CLAUDE.local.md at the project root. This file is automatically loaded alongside AGENTS.md. After creating it, add \`CLAUDE.local.md\` to the project's .gitignore so it stays private.
Write a minimal CLAUDE.local.md at the project root. This file is automatically loaded alongside CLAUDE.md. After creating it, add \`CLAUDE.local.md\` to the project's .gitignore so it stays private.
**Consume \`note\` entries from the Phase 3 preference queue whose target is CLAUDE.local.md** (personal-level notes) — add each as a concise line. If the user chose personal-only in Phase 1, this is the sole consumer of note entries.
@@ -148,7 +147,7 @@ Include:
Keep it short — only include what would make Claude's responses noticeably better for this user.
If Phase 2 found multiple git worktrees and the user confirmed they use sibling/external worktrees (not nested inside the main repo): the upward file walk won't find a single CLAUDE.local.md from all worktrees. Write the actual personal content to \`~/.claude/<project-name>-instructions.md\` and make CLAUDE.local.md a one-line stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. The user can copy this one-line stub to each sibling worktree. Never put this import in the project AGENTS.md. If worktrees are nested inside the main repo (e.g., \`.claude/worktrees/\`), no special handling is needed — the main repo's CLAUDE.local.md is found automatically.
If Phase 2 found multiple git worktrees and the user confirmed they use sibling/external worktrees (not nested inside the main repo): the upward file walk won't find a single CLAUDE.local.md from all worktrees. Write the actual personal content to \`~/.claude/<project-name>-instructions.md\` and make CLAUDE.local.md a one-line stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. The user can copy this one-line stub to each sibling worktree. Never put this import in the project CLAUDE.md. If worktrees are nested inside the main repo (e.g., \`.claude/worktrees/\`), no special handling is needed — the main repo's CLAUDE.local.md is found automatically.
If CLAUDE.local.md already exists: read it, propose specific additions, and do not silently overwrite.
@@ -184,7 +183,7 @@ Both the user (\`/<skill-name>\`) and Claude can invoke skills by default. For w
## Phase 7: Suggest additional optimizations
Tell the user you're going to suggest a few additional optimizations now that AGENTS.md and skills (if chosen) are in place.
Tell the user you're going to suggest a few additional optimizations now that CLAUDE.md and skills (if chosen) are in place.
Check the environment and ask about each gap you find (use AskUserQuestion):
@@ -196,7 +195,7 @@ Check the environment and ask about each gap you find (use AskUserQuestion):
For each hook preference (from the queue or the formatter fallback):
1. Target file: default based on the Phase 1 instruction-file choice — project → \`.claude/settings.json\` (team-shared, committed); personal → \`.claude/settings.local.json\`. Only ask if the user chose "both" in Phase 1 or the preference is ambiguous. Ask once for all hooks, not per-hook.
1. Target file: default based on the Phase 1 CLAUDE.md choice — project → \`.claude/settings.json\` (team-shared, committed); personal → \`.claude/settings.local.json\`. Only ask if the user chose "both" in Phase 1 or the preference is ambiguous. Ask once for all hooks, not per-hook.
2. Pick the event and matcher from the preference:
- "after every edit" → \`PostToolUse\` with matcher \`Write|Edit\`
@@ -228,9 +227,11 @@ const command = {
type: 'prompt',
name: 'init',
get description() {
return isNewInitEnabled()
? 'Initialize new project instruction file(s) and optional skills/hooks with codebase documentation'
: 'Initialize a new project instruction file with codebase documentation'
return feature('NEW_INIT') &&
(process.env.USER_TYPE === 'ant' ||
isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT))
? 'Initialize new CLAUDE.md file(s) and optional skills/hooks with codebase documentation'
: 'Initialize a new CLAUDE.md file with codebase documentation'
},
contentLength: 0, // Dynamic content
progressMessage: 'analyzing your codebase',
@@ -241,7 +242,12 @@ const command = {
return [
{
type: 'text',
text: isNewInitEnabled() ? NEW_INIT_PROMPT : OLD_INIT_PROMPT,
text:
feature('NEW_INIT') &&
(process.env.USER_TYPE === 'ant' ||
isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT))
? NEW_INIT_PROMPT
: OLD_INIT_PROMPT,
},
]
},

View File

@@ -1,13 +0,0 @@
import { feature } from 'bun:bundle'
import { isEnvTruthy } from '../utils/envUtils.js'
export function isNewInitEnabled(): boolean {
if (feature('NEW_INIT')) {
return (
process.env.USER_TYPE === 'ant' ||
isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT)
)
}
return false
}

View File

@@ -1,12 +1,17 @@
import { execFileSync } from 'child_process'
import { diffLines } from 'diff'
import { constants as fsConstants } from 'fs'
import {
copyFile,
mkdir,
mkdtemp,
readdir,
readFile,
rm,
unlink,
writeFile,
} from 'fs/promises'
import { tmpdir } from 'os'
import { extname, join } from 'path'
import type { Command } from '../commands.js'
import { queryWithModel } from '../services/api/claude.js'
@@ -17,6 +22,7 @@ import {
import type { LogOption } from '../types/logs.js'
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
import { toError } from '../utils/errors.js'
import { execFileNoThrow } from '../utils/execFileNoThrow.js'
import { logError } from '../utils/log.js'
import { extractTextContent } from '../utils/messages.js'
import { getDefaultOpusModel } from '../utils/model/model.js'
@@ -41,6 +47,180 @@ function getInsightsModel(): string {
return getDefaultOpusModel()
}
// ============================================================================
// Homespace Data Collection
// ============================================================================
type RemoteHostInfo = {
name: string
sessionCount: number
}
/* eslint-disable custom-rules/no-process-env-top-level */
const getRunningRemoteHosts: () => Promise<string[]> =
process.env.USER_TYPE === 'ant'
? async () => {
const { stdout, code } = await execFileNoThrow(
'coder',
['list', '-o', 'json'],
{ timeout: 30000 },
)
if (code !== 0) return []
try {
const workspaces = jsonParse(stdout) as Array<{
name: string
latest_build?: { status?: string }
}>
return workspaces
.filter(w => w.latest_build?.status === 'running')
.map(w => w.name)
} catch {
return []
}
}
: async () => []
const getRemoteHostSessionCount: (hs: string) => Promise<number> =
process.env.USER_TYPE === 'ant'
? async (homespace: string) => {
const { stdout, code } = await execFileNoThrow(
'ssh',
[
`${homespace}.coder`,
'find /root/.claude/projects -name "*.jsonl" 2>/dev/null | wc -l',
],
{ timeout: 30000 },
)
if (code !== 0) return 0
return parseInt(stdout.trim(), 10) || 0
}
: async () => 0
const collectFromRemoteHost: (
hs: string,
destDir: string,
) => Promise<{ copied: number; skipped: number }> =
process.env.USER_TYPE === 'ant'
? async (homespace: string, destDir: string) => {
const result = { copied: 0, skipped: 0 }
// Create temp directory
const tempDir = await mkdtemp(join(tmpdir(), 'claude-hs-'))
try {
// SCP the projects folder
const scpResult = await execFileNoThrow(
'scp',
['-rq', `${homespace}.coder:/root/.claude/projects/`, tempDir],
{ timeout: 300000 },
)
if (scpResult.code !== 0) {
// SCP failed
return result
}
const projectsDir = join(tempDir, 'projects')
let projectDirents: Awaited<ReturnType<typeof readdir>>
try {
projectDirents = await readdir(projectsDir, { withFileTypes: true })
} catch {
return result
}
// Merge into destination (parallel per project directory)
await Promise.all(
projectDirents.map(async dirent => {
const projectName = dirent.name
const projectPath = join(projectsDir, projectName)
// Skip if not a directory
if (!dirent.isDirectory()) return
const destProjectName = `${projectName}__${homespace}`
const destProjectPath = join(destDir, destProjectName)
try {
await mkdir(destProjectPath, { recursive: true })
} catch {
// Directory may already exist
}
// Copy session files (skip existing)
let files: Awaited<ReturnType<typeof readdir>>
try {
files = await readdir(projectPath, { withFileTypes: true })
} catch {
return
}
await Promise.all(
files.map(async fileDirent => {
const fileName = fileDirent.name
if (!fileName.endsWith('.jsonl')) return
const srcFile = join(projectPath, fileName)
const destFile = join(destProjectPath, fileName)
try {
await copyFile(srcFile, destFile, fsConstants.COPYFILE_EXCL)
result.copied++
} catch {
// EEXIST from COPYFILE_EXCL means dest already exists
result.skipped++
}
}),
)
}),
)
} finally {
try {
await rm(tempDir, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
}
return result
}
: async () => ({ copied: 0, skipped: 0 })
const collectAllRemoteHostData: (destDir: string) => Promise<{
hosts: RemoteHostInfo[]
totalCopied: number
totalSkipped: number
}> =
process.env.USER_TYPE === 'ant'
? async (destDir: string) => {
const rHosts = await getRunningRemoteHosts()
const result: RemoteHostInfo[] = []
let totalCopied = 0
let totalSkipped = 0
// Collect from all hosts in parallel (SCP per host can take seconds)
const hostResults = await Promise.all(
rHosts.map(async hs => {
const sessionCount = await getRemoteHostSessionCount(hs)
if (sessionCount > 0) {
const { copied, skipped } = await collectFromRemoteHost(
hs,
destDir,
)
return { name: hs, sessionCount, copied, skipped }
}
return { name: hs, sessionCount, copied: 0, skipped: 0 }
}),
)
for (const hr of hostResults) {
result.push({ name: hr.name, sessionCount: hr.sessionCount })
totalCopied += hr.copied
totalSkipped += hr.skipped
}
return { hosts: result, totalCopied, totalSkipped }
}
: async () => ({ hosts: [], totalCopied: 0, totalSkipped: 0 })
/* eslint-enable custom-rules/no-process-env-top-level */
// ============================================================================
// Types
// ============================================================================
@@ -2479,6 +2659,7 @@ export type InsightsExport = {
claude_code_version: string
date_range: { start: string; end: string }
session_count: number
remote_hosts_collected?: string[]
}
aggregated_data: AggregatedData
insights: InsightResults
@@ -2499,9 +2680,14 @@ export function buildExportData(
data: AggregatedData,
insights: InsightResults,
facets: Map<string, SessionFacets>,
remoteStats?: { hosts: RemoteHostInfo[]; totalCopied: number },
): InsightsExport {
const version = typeof MACRO !== 'undefined' ? MACRO.VERSION : 'unknown'
const remote_hosts_collected = remoteStats?.hosts
.filter(h => h.sessionCount > 0)
.map(h => h.name)
const facets_summary = {
total: facets.size,
goal_categories: {} as Record<string, number>,
@@ -2539,6 +2725,10 @@ export function buildExportData(
claude_code_version: version,
date_range: data.date_range,
session_count: data.total_sessions,
...(remote_hosts_collected &&
remote_hosts_collected.length > 0 && {
remote_hosts_collected,
}),
},
aggregated_data: data,
insights,
@@ -2603,12 +2793,24 @@ async function scanAllSessions(): Promise<LiteSessionInfo[]> {
// Main Function
// ============================================================================
export async function generateUsageReport(): Promise<{
export async function generateUsageReport(options?: {
collectRemote?: boolean
}): Promise<{
insights: InsightResults
htmlPath: string
data: AggregatedData
remoteStats?: { hosts: RemoteHostInfo[]; totalCopied: number }
facets: Map<string, SessionFacets>
}> {
let remoteStats: { hosts: RemoteHostInfo[]; totalCopied: number } | undefined
// Optionally collect data from remote hosts first (internal-only)
if (process.env.USER_TYPE === 'ant' && options?.collectRemote) {
const destDir = join(getClaudeConfigHomeDir(), 'projects')
const { hosts, totalCopied } = await collectAllRemoteHostData(destDir)
remoteStats = { hosts, totalCopied }
}
// Phase 1: Lite scan — filesystem metadata only (no JSONL parsing)
const allScannedSessions = await scanAllSessions()
const totalSessionsScanned = allScannedSessions.length
@@ -2815,6 +3017,7 @@ export async function generateUsageReport(): Promise<{
insights,
htmlPath,
data: aggregated,
remoteStats,
facets: substantiveFacets,
}
}
@@ -2840,8 +3043,31 @@ const usageReport: Command = {
contentLength: 0, // Dynamic content
progressMessage: 'analyzing your sessions',
source: 'builtin',
async getPromptForCommand(_args) {
const { insights, htmlPath, data } = await generateUsageReport()
async getPromptForCommand(args) {
let collectRemote = false
let remoteHosts: string[] = []
let hasRemoteHosts = false
if (process.env.USER_TYPE === 'ant') {
// Parse --homespaces flag
collectRemote = args?.includes('--homespaces') ?? false
// Check for available remote hosts
remoteHosts = await getRunningRemoteHosts()
hasRemoteHosts = remoteHosts.length > 0
// Show collection message if collecting
if (collectRemote && hasRemoteHosts) {
// biome-ignore lint/suspicious/noConsole: intentional
console.error(
`Collecting sessions from ${remoteHosts.length} homespace(s): ${remoteHosts.join(', ')}...`,
)
}
}
const { insights, htmlPath, data, remoteStats } = await generateUsageReport(
{ collectRemote },
)
let reportUrl = `file://${htmlPath}`
let uploadHint = ''
@@ -2859,6 +3085,20 @@ const usageReport: Command = {
`${data.git_commits} commits`,
].join(' · ')
// Build remote host info (internal-only)
let remoteInfo = ''
if (process.env.USER_TYPE === 'ant') {
if (remoteStats && remoteStats.totalCopied > 0) {
const hsNames = remoteStats.hosts
.filter(h => h.sessionCount > 0)
.map(h => h.name)
.join(', ')
remoteInfo = `\n_Collected ${remoteStats.totalCopied} new sessions from: ${hsNames}_\n`
} else if (!collectRemote && hasRemoteHosts) {
// Suggest using --homespaces if they have remote hosts but didn't use the flag
remoteInfo = `\n_Tip: Run \`/insights --homespaces\` to include sessions from your ${remoteHosts.length} running homespace(s)_\n`
}
}
// Build markdown summary from insights
const atAGlance = insights.at_a_glance
@@ -2878,6 +3118,7 @@ ${atAGlance.ambitious_workflows ? `**Ambitious workflows:** ${atAGlance.ambitiou
${stats}
${data.date_range.start} to ${data.date_range.end}
${remoteInfo}
`
const userSummary = `${header}${summaryText}

View File

@@ -39,16 +39,16 @@ type InstallState = {
message: string;
warnings?: string[];
};
export function getInstallationPath(): string {
function getInstallationPath(): string {
const isWindows = env.platform === 'win32';
const homeDir = homedir();
if (isWindows) {
// Convert to Windows-style path
const windowsPath = join(homeDir, '.local', 'bin', 'openclaude.exe');
const windowsPath = join(homeDir, '.local', 'bin', 'claude.exe');
// Replace forward slashes with backslashes for Windows display
return windowsPath.replace(/\//g, '\\');
}
return '~/.local/bin/openclaude';
return '~/.local/bin/claude';
}
function SetupNotes(t0) {
const $ = _c(5);

View File

@@ -1,44 +1,20 @@
import { afterEach, expect, mock, test } from 'bun:test'
import { getAdditionalModelOptionsCacheScope } from '../../services/api/providerConfig.js'
import { getAPIProvider } from '../../utils/model/providers.js'
const originalEnv = {
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
CLAUDE_CODE_USE_MISTRAL: process.env.CLAUDE_CODE_USE_MISTRAL,
CLAUDE_CODE_USE_BEDROCK: process.env.CLAUDE_CODE_USE_BEDROCK,
CLAUDE_CODE_USE_VERTEX: process.env.CLAUDE_CODE_USE_VERTEX,
CLAUDE_CODE_USE_FOUNDRY: process.env.CLAUDE_CODE_USE_FOUNDRY,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_BASE: process.env.OPENAI_API_BASE,
OPENAI_MODEL: process.env.OPENAI_MODEL,
}
afterEach(() => {
mock.restore()
process.env.CLAUDE_CODE_USE_OPENAI = originalEnv.CLAUDE_CODE_USE_OPENAI
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_MISTRAL = originalEnv.CLAUDE_CODE_USE_MISTRAL
process.env.CLAUDE_CODE_USE_BEDROCK = originalEnv.CLAUDE_CODE_USE_BEDROCK
process.env.CLAUDE_CODE_USE_VERTEX = originalEnv.CLAUDE_CODE_USE_VERTEX
process.env.CLAUDE_CODE_USE_FOUNDRY = originalEnv.CLAUDE_CODE_USE_FOUNDRY
process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL
process.env.OPENAI_API_BASE = originalEnv.OPENAI_API_BASE
process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL
})
test('opens the model picker without awaiting local model discovery refresh', async () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.CLAUDE_CODE_USE_MISTRAL
delete process.env.CLAUDE_CODE_USE_BEDROCK
delete process.env.CLAUDE_CODE_USE_VERTEX
delete process.env.CLAUDE_CODE_USE_FOUNDRY
delete process.env.OPENAI_API_BASE
process.env.OPENAI_BASE_URL = 'http://127.0.0.1:8080/v1'
process.env.OPENAI_MODEL = 'qwen2.5-coder-7b-instruct'
@@ -54,9 +30,7 @@ test('opens the model picker without awaiting local model discovery refresh', as
discoverOpenAICompatibleModelOptions,
}))
expect(getAdditionalModelOptionsCacheScope()).toBe('openai:http://127.0.0.1:8080/v1')
const { call } = await import('./model.js')
const { call } = await import(`./model.js?ts=${Date.now()}-${Math.random()}`)
const result = await Promise.race([
call(() => {}, {} as never, ''),
new Promise(resolve => setTimeout(() => resolve('timeout'), 50)),

View File

@@ -284,7 +284,7 @@ function haveSameModelOptions(left: ModelOption[], right: ModelOption[]): boolea
});
}
async function refreshOpenAIModelOptionsCache(): Promise<void> {
if (!getAdditionalModelOptionsCacheScope()?.startsWith('openai:')) {
if (getAPIProvider() !== 'openai') {
return;
}
try {

View File

@@ -4,7 +4,7 @@ const onboardGithub: Command = {
name: 'onboard-github',
aliases: ['onboarding-github', 'onboardgithub', 'onboardinggithub'],
description:
'Interactive setup for GitHub Copilot: OAuth device login stored in secure storage',
'Interactive setup for GitHub Models: device login or PAT, saved to secure storage',
type: 'local-jsx',
load: () => import('./onboard-github.js'),
}

View File

@@ -2,9 +2,9 @@ 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 {
exchangeForCopilotToken,
openVerificationUri,
pollAccessToken,
requestDeviceCode,
@@ -15,7 +15,7 @@ import {
readGithubModelsToken,
saveGithubModelsToken,
} from '../../utils/githubModelsCredentials.js'
import { getSettingsForSource, updateSettingsForSource } from '../../utils/settings/settings.js'
import { updateSettingsForSource } from '../../utils/settings/settings.js'
const DEFAULT_MODEL = 'github:copilot'
const FORCE_RELOGIN_ARGS = new Set([
@@ -27,25 +27,11 @@ const FORCE_RELOGIN_ARGS = new Set([
'--reauth',
])
type Step = 'menu' | 'device-busy' | 'error'
const PROVIDER_SPECIFIC_KEYS = new Set([
'CLAUDE_CODE_USE_OPENAI',
'CLAUDE_CODE_USE_GEMINI',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_USE_FOUNDRY',
'OPENAI_BASE_URL',
'OPENAI_API_BASE',
'OPENAI_API_KEY',
'OPENAI_MODEL',
'GEMINI_API_KEY',
'GOOGLE_API_KEY',
'GEMINI_BASE_URL',
'GEMINI_MODEL',
'GEMINI_ACCESS_TOKEN',
'GEMINI_AUTH_MODE',
])
type Step =
| 'menu'
| 'device-busy'
| 'pat'
| 'error'
export function shouldForceGithubRelogin(args?: string): boolean {
const normalized = (args ?? '').trim().toLowerCase()
@@ -55,29 +41,15 @@ export function shouldForceGithubRelogin(args?: string): boolean {
return normalized.split(/\s+/).some(arg => FORCE_RELOGIN_ARGS.has(arg))
}
const GITHUB_PAT_PREFIXES = ['ghp_', 'gho_','ghs_', 'ghr_', 'github_pat_']
function isGithubPat(token: string): boolean {
return GITHUB_PAT_PREFIXES.some(prefix => token.startsWith(prefix))
}
export function hasExistingGithubModelsLoginToken(
env: NodeJS.ProcessEnv = process.env,
storedToken?: string,
): boolean {
const envToken = env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim()
if (envToken) {
// PATs are no longer supported - require OAuth re-auth
if (isGithubPat(envToken)) {
return false
}
return true
}
const persisted = (storedToken ?? readGithubModelsToken())?.trim()
// PATs are no longer supported - require OAuth re-auth
if (persisted && isGithubPat(persisted)) {
return false
}
return Boolean(persisted)
}
@@ -125,21 +97,8 @@ export function applyGithubOnboardingProcessEnv(
}
function mergeUserSettingsEnv(model: string): { ok: boolean; detail?: string } {
const currentSettings = getSettingsForSource('userSettings')
const currentEnv = currentSettings?.env ?? {}
const newEnv: Record<string, string> = {}
for (const [key, value] of Object.entries(currentEnv)) {
if (!PROVIDER_SPECIFIC_KEYS.has(key)) {
newEnv[key] = value
}
}
newEnv.CLAUDE_CODE_USE_GITHUB = '1'
newEnv.OPENAI_MODEL = model
const { error } = updateSettingsForSource('userSettings', {
env: newEnv,
env: buildGithubOnboardingSettingsEnv(model) as any,
})
if (error) {
return { ok: false, detail: error.message }
@@ -184,14 +143,12 @@ function OnboardGithub(props: {
user_code: string
verification_uri: string
} | null>(null)
const [patDraft, setPatDraft] = useState('')
const [cursorOffset, setCursorOffset] = useState(0)
const finalize = useCallback(
async (
token: string,
model: string = DEFAULT_MODEL,
oauthToken?: string,
) => {
const saved = saveGithubModelsToken(token, oauthToken)
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')
@@ -208,18 +165,8 @@ function OnboardGithub(props: {
setStep('error')
return
}
// Clear stale provider-specific env vars from the current session
// so resolveProviderRequest() doesn't pick up a previous provider's
// base URL or key after onboarding completes.
for (const key of PROVIDER_SPECIFIC_KEYS) {
delete process.env[key]
}
process.env.CLAUDE_CODE_USE_GITHUB = '1'
process.env.OPENAI_MODEL = model.trim() || DEFAULT_MODEL
hydrateGithubModelsTokenFromSecureStorage()
onChangeAPIKey()
onDone(
'GitHub Copilot onboard complete. Copilot token and OAuth token stored in secure storage (Windows/Linux: ~/.claude/.credentials.json, macOS: Keychain fallback to ~/.claude/.credentials.json); user settings updated. Restart if the model does not switch.',
'GitHub Models onboard complete. Token stored in secure storage; user settings updated. Restart if the model does not switch.',
{ display: 'user' },
)
},
@@ -237,12 +184,11 @@ function OnboardGithub(props: {
verification_uri: device.verification_uri,
})
await openVerificationUri(device.verification_uri)
const oauthToken = await pollAccessToken(device.device_code, {
const token = await pollAccessToken(device.device_code, {
initialInterval: device.interval,
timeoutSeconds: device.expires_in,
})
const copilotToken = await exchangeForCopilotToken(oauthToken)
await finalize(copilotToken.token, DEFAULT_MODEL, oauthToken)
await finalize(token, DEFAULT_MODEL)
} catch (e) {
setErrorMsg(e instanceof Error ? e.message : String(e))
setStep('error')
@@ -281,7 +227,7 @@ function OnboardGithub(props: {
if (step === 'device-busy') {
return (
<Box flexDirection="column" gap={1}>
<Text>GitHub Copilot sign-in</Text>
<Text>GitHub device login</Text>
{deviceHint ? (
<>
<Text>
@@ -300,11 +246,43 @@ function OnboardGithub(props: {
)
}
if (step === 'pat') {
return (
<Box flexDirection="column" gap={1}>
<Text>Paste a GitHub personal access token with access to GitHub Models.</Text>
<Text dimColor>Input is masked. Enter to submit; Esc to go back.</Text>
<TextInput
value={patDraft}
mask="*"
onChange={setPatDraft}
onSubmit={async (value: string) => {
const t = value.trim()
if (!t) {
return
}
await finalize(t, DEFAULT_MODEL)
}}
onExit={() => {
setStep('menu')
setPatDraft('')
}}
columns={80}
cursorOffset={cursorOffset}
onChangeCursorOffset={setCursorOffset}
/>
</Box>
)
}
const menuOptions = [
{
label: 'Sign in with browser',
label: 'Sign in with browser (device code)',
value: 'device' as const,
},
{
label: 'Paste personal access token',
value: 'pat' as const,
},
{
label: 'Cancel',
value: 'cancel' as const,
@@ -313,7 +291,7 @@ function OnboardGithub(props: {
return (
<Box flexDirection="column" gap={1}>
<Text bold>GitHub Copilot setup</Text>
<Text bold>GitHub Models setup</Text>
<Text dimColor>
Stores your token in the OS credential store (macOS Keychain when available)
and enables CLAUDE_CODE_USE_GITHUB in your user settings - no export
@@ -326,6 +304,10 @@ function OnboardGithub(props: {
onDone('GitHub onboard cancelled', { display: 'system' })
return
}
if (v === 'pat') {
setStep('pat')
return
}
void runDeviceFlow()
}}
/>

View File

@@ -1,28 +1,20 @@
import { PassThrough } from 'node:stream'
import { afterEach, expect, mock, test } from 'bun:test'
import { expect, test } from 'bun:test'
import React from 'react'
import stripAnsi from 'strip-ansi'
import { createRoot, render, useApp } from '../../ink.js'
import { AppStateProvider } from '../../state/AppState.js'
import {
applySavedProfileToCurrentSession,
buildCodexOAuthProfileEnv,
buildCurrentProviderSummary,
buildProfileSaveMessage,
getProviderWizardDefaults,
ProviderWizard,
TextEntryDialog,
} from './provider.js'
import { createProfileFile } from '../../utils/providerProfile.js'
const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l'
const ORIGINAL_SIMPLE_ENV = process.env.CLAUDE_CODE_SIMPLE
const ORIGINAL_CODEX_API_KEY = process.env.CODEX_API_KEY
const ORIGINAL_CHATGPT_ACCOUNT_ID = process.env.CHATGPT_ACCOUNT_ID
const ORIGINAL_CODEX_ACCOUNT_ID = process.env.CODEX_ACCOUNT_ID
function extractLastFrame(output: string): string {
let lastFrame: string | null = null
@@ -68,51 +60,6 @@ async function renderFinalFrame(node: React.ReactNode): Promise<string> {
return stripAnsi(extractLastFrame(getOutput()))
}
async function waitForOutput(
getOutput: () => string,
predicate: (output: string) => boolean,
timeoutMs = 2500,
): Promise<string> {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
const output = stripAnsi(extractLastFrame(getOutput()))
if (predicate(output)) {
return output
}
await Bun.sleep(10)
}
throw new Error('Timed out waiting for ProviderWizard test output')
}
async function renderProviderWizardFrame(): Promise<string> {
const { stdout, stdin, getOutput } = createTestStreams()
const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(
<AppStateProvider>
<ProviderWizard onDone={() => {}} />
</AppStateProvider>,
)
try {
return await waitForOutput(
getOutput,
output => output.includes('Set up a provider profile'),
)
} finally {
root.unmount()
stdin.end()
stdout.end()
await Bun.sleep(0)
}
}
function createTestStreams(): {
stdout: PassThrough
stdin: PassThrough & {
@@ -147,34 +94,6 @@ function createTestStreams(): {
}
}
afterEach(() => {
mock.restore()
if (ORIGINAL_SIMPLE_ENV === undefined) {
delete process.env.CLAUDE_CODE_SIMPLE
} else {
process.env.CLAUDE_CODE_SIMPLE = ORIGINAL_SIMPLE_ENV
}
if (ORIGINAL_CODEX_API_KEY === undefined) {
delete process.env.CODEX_API_KEY
} else {
process.env.CODEX_API_KEY = ORIGINAL_CODEX_API_KEY
}
if (ORIGINAL_CHATGPT_ACCOUNT_ID === undefined) {
delete process.env.CHATGPT_ACCOUNT_ID
} else {
process.env.CHATGPT_ACCOUNT_ID = ORIGINAL_CHATGPT_ACCOUNT_ID
}
if (ORIGINAL_CODEX_ACCOUNT_ID === undefined) {
delete process.env.CODEX_ACCOUNT_ID
} else {
process.env.CODEX_ACCOUNT_ID = ORIGINAL_CODEX_ACCOUNT_ID
}
})
function StepChangeHarness(): React.ReactNode {
const { exit } = useApp()
const [step, setStep] = React.useState<'api' | 'model'>('api')
@@ -314,167 +233,6 @@ test('buildProfileSaveMessage describes Gemini access token / ADC mode clearly',
expect(message).not.toContain('AIza')
})
test('buildProfileSaveMessage reflects immediate Codex activation for existing credentials', () => {
const message = buildProfileSaveMessage(
'codex',
{
OPENAI_MODEL: 'codexplan',
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
CHATGPT_ACCOUNT_ID: 'acct_codex',
},
'D:/codings/Opensource/openclaude/.openclaude-profile.json',
{
activatedInSession: true,
},
)
expect(message).toContain('Saved Codex profile.')
expect(message).toContain('OpenClaude switched to it for this session.')
expect(message).not.toContain('Restart OpenClaude to use it.')
})
test('buildProfileSaveMessage reflects immediate Codex OAuth activation when the session switched successfully', () => {
const message = buildProfileSaveMessage(
'codex',
{
OPENAI_MODEL: 'codexplan',
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
CHATGPT_ACCOUNT_ID: 'acct_codex',
CODEX_CREDENTIAL_SOURCE: 'oauth',
},
'D:/codings/Opensource/openclaude/.openclaude-profile.json',
{
activatedInSession: true,
},
)
expect(message).toContain('Saved Codex profile.')
expect(message).toContain('OpenClaude switched to it for this session.')
expect(message).not.toContain('Restart OpenClaude to use it.')
})
test('buildCodexOAuthProfileEnv uses the fresh OAuth account id without persisting an API key', () => {
process.env.CODEX_API_KEY = 'stale-codex-key'
process.env.CHATGPT_ACCOUNT_ID = 'acct_stale'
const env = buildCodexOAuthProfileEnv({
accessToken: 'oauth-access-token',
accountId: 'acct_oauth',
})
expect(env).toEqual({
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
OPENAI_MODEL: 'codexplan',
CHATGPT_ACCOUNT_ID: 'acct_oauth',
CODEX_CREDENTIAL_SOURCE: 'oauth',
})
expect(env).not.toHaveProperty('CODEX_API_KEY')
})
test('buildCodexProfileEnv derives oauth source from secure storage when no explicit source is provided', async () => {
const actualProviderConfig = await import('../../services/api/providerConfig.js')
mock.module('../../services/api/providerConfig.js', () => ({
...actualProviderConfig,
resolveCodexApiCredentials: () => ({
apiKey: 'stored-access-token',
accountId: 'acct_secure_storage',
source: 'secure-storage' as const,
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { buildCodexProfileEnv } = await import(
'../../utils/providerProfile.js?secure-storage-codex-source'
)
const env = buildCodexProfileEnv({
model: 'codexplan',
processEnv: {},
})
expect(env).toEqual({
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
OPENAI_MODEL: 'codexplan',
CHATGPT_ACCOUNT_ID: 'acct_secure_storage',
CODEX_CREDENTIAL_SOURCE: 'oauth',
})
})
test('applySavedProfileToCurrentSession switches the current env to the saved Codex profile', async () => {
// @ts-expect-error cache-busting query string for Bun module mocks
const { applySavedProfileToCurrentSession } = await import(
'../../utils/providerProfile.js?apply-saved-profile-codex'
)
const processEnv: NodeJS.ProcessEnv = {
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_MODEL: 'gpt-4o',
OPENAI_BASE_URL: 'https://api.openai.com/v1',
OPENAI_API_KEY: 'sk-openai',
CODEX_API_KEY: 'codex-live',
CHATGPT_ACCOUNT_ID: 'acct_codex',
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED: '1',
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID: 'provider_old',
}
const profileFile = createProfileFile('codex', {
OPENAI_MODEL: 'codexplan',
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
CODEX_API_KEY: 'codex-live',
CHATGPT_ACCOUNT_ID: 'acct_codex',
})
const warning = await applySavedProfileToCurrentSession({
profileFile,
processEnv,
})
expect(warning).toBeNull()
expect(processEnv.CLAUDE_CODE_USE_OPENAI).toBe('1')
expect(processEnv.OPENAI_MODEL).toBe('codexplan')
expect(processEnv.OPENAI_BASE_URL).toBe(
'https://chatgpt.com/backend-api/codex',
)
expect(processEnv.CODEX_API_KEY).toBe('codex-live')
expect(processEnv.CHATGPT_ACCOUNT_ID).toBe('acct_codex')
expect(processEnv.OPENAI_API_KEY).toBeUndefined()
expect(processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED).toBeUndefined()
expect(processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBeUndefined()
})
test('applySavedProfileToCurrentSession ignores stale Codex env overrides for OAuth-backed profiles', async () => {
// @ts-expect-error cache-busting query string for Bun module mocks
const { applySavedProfileToCurrentSession } = await import(
'../../utils/providerProfile.js?apply-saved-profile-codex-oauth'
)
const processEnv: NodeJS.ProcessEnv = {
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_MODEL: 'gpt-4o',
OPENAI_BASE_URL: 'https://api.openai.com/v1',
CODEX_API_KEY: 'stale-codex-key',
CHATGPT_ACCOUNT_ID: 'acct_stale',
}
const profileFile = createProfileFile('codex', {
OPENAI_MODEL: 'codexplan',
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
CHATGPT_ACCOUNT_ID: 'acct_oauth',
CODEX_CREDENTIAL_SOURCE: 'oauth',
})
const warning = await applySavedProfileToCurrentSession({
profileFile,
processEnv,
})
expect(warning).toBeNull()
expect(processEnv.OPENAI_MODEL).toBe('codexplan')
expect(processEnv.OPENAI_BASE_URL).toBe(
'https://chatgpt.com/backend-api/codex',
)
expect(processEnv.CODEX_API_KEY).toBeUndefined()
expect(processEnv.CHATGPT_ACCOUNT_ID).not.toBe('acct_stale')
expect(processEnv.CHATGPT_ACCOUNT_ID).toBeTruthy()
})
test('buildCurrentProviderSummary redacts poisoned model and endpoint values', () => {
const summary = buildCurrentProviderSummary({
processEnv: {
@@ -506,7 +264,7 @@ test('buildCurrentProviderSummary labels generic local openai-compatible provide
expect(summary.endpointLabel).toBe('http://127.0.0.1:8080/v1')
})
test('buildCurrentProviderSummary does not relabel local gpt-5.4 providers as Codex when custom base URL is set', () => {
test('buildCurrentProviderSummary does not relabel local gpt-5.4 providers as Codex', () => {
const summary = buildCurrentProviderSummary({
processEnv: {
CLAUDE_CODE_USE_OPENAI: '1',
@@ -549,12 +307,3 @@ test('getProviderWizardDefaults ignores poisoned current provider values', () =>
expect(defaults.openAIBaseUrl).toBe('https://api.openai.com/v1')
expect(defaults.geminiModel).toBe('gemini-2.0-flash')
})
test('ProviderWizard hides Codex OAuth while running in bare mode', async () => {
process.env.CLAUDE_CODE_SIMPLE = '1'
const output = await renderProviderWizardFrame()
expect(output).toContain('Set up a provider profile')
expect(output).not.toContain('Codex OAuth')
})

View File

@@ -10,12 +10,8 @@ import {
} from '../../components/CustomSelect/index.js'
import { Dialog } from '../../components/design-system/Dialog.js'
import { LoadingState } from '../../components/design-system/LoadingState.js'
import { useCodexOAuthFlow } from '../../components/useCodexOAuthFlow.js'
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { Box, Text } from '../../ink.js'
import {
type CodexOAuthTokens,
} from '../../services/api/codexOAuth.js'
import {
DEFAULT_CODEX_BASE_URL,
DEFAULT_OPENAI_BASE_URL,
@@ -24,18 +20,13 @@ import {
resolveProviderRequest,
} from '../../services/api/providerConfig.js'
import {
applySavedProfileToCurrentSession as applySharedProfileToCurrentSession,
buildCodexOAuthProfileEnv as buildSharedCodexOAuthProfileEnv,
buildCodexProfileEnv,
buildGeminiProfileEnv,
buildMistralProfileEnv,
buildOllamaProfileEnv,
buildOpenAIProfileEnv,
createProfileFile,
DEFAULT_GEMINI_BASE_URL,
DEFAULT_GEMINI_MODEL,
DEFAULT_MISTRAL_BASE_URL,
DEFAULT_MISTRAL_MODEL,
deleteProfileFile,
loadProfileFile,
maskSecretForDisplay,
@@ -55,7 +46,6 @@ import {
readGeminiAccessToken,
saveGeminiAccessToken,
} from '../../utils/geminiCredentials.js'
import { isBareMode } from '../../utils/envUtils.js'
import {
getGoalDefaultOpenAIModel,
normalizeRecommendationGoal,
@@ -64,13 +54,12 @@ import {
type RecommendationGoal,
} from '../../utils/providerRecommendation.js'
import {
getOllamaChatBaseUrl,
getLocalOpenAICompatibleProviderLabel,
hasLocalOllama,
listOllamaModels,
} from '../../utils/providerDiscovery.js'
type ProviderChoice = 'auto' | ProviderProfile | 'codex-oauth' | 'clear'
type ProviderChoice = 'auto' | ProviderProfile | 'clear'
type Step =
| { name: 'choose' }
@@ -85,14 +74,6 @@ type Step =
baseUrl: string | null
defaultModel: string
}
| { name: 'mistral-key'; defaultModel: string }
| { name: 'mistral-base'; apiKey: string; defaultModel: string }
| {
name: 'mistral-model'
apiKey: string
baseUrl: string | null
defaultModel: string
}
| { name: 'gemini-auth-method' }
| { name: 'gemini-key' }
| { name: 'gemini-access-token' }
@@ -101,7 +82,6 @@ type Step =
apiKey?: string
authMode: 'api-key' | 'access-token' | 'adc'
}
| { name: 'codex-oauth' }
| { name: 'codex-check' }
type CurrentProviderSummary = {
@@ -136,12 +116,8 @@ type ProviderWizardDefaults = {
openAIModel: string
openAIBaseUrl: string
geminiModel: string
mistralModel: string
mistralBaseUrl: string
}
type SecretSourceEnv = NodeJS.ProcessEnv & Partial<ProfileEnv>
function isEnvTruthy(value: string | undefined): boolean {
if (!value) return false
const normalized = value.trim().toLowerCase()
@@ -150,7 +126,7 @@ function isEnvTruthy(value: string | undefined): boolean {
function getSafeDisplayValue(
value: string | undefined,
processEnv: SecretSourceEnv,
processEnv: NodeJS.ProcessEnv,
profileEnv?: ProfileEnv,
fallback = '(not set)',
): string {
@@ -162,29 +138,20 @@ function getSafeDisplayValue(
export function getProviderWizardDefaults(
processEnv: NodeJS.ProcessEnv = process.env,
): ProviderWizardDefaults {
const secretSource = processEnv as SecretSourceEnv
const safeOpenAIModel =
sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, secretSource) ||
sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, processEnv) ||
'gpt-4o'
const safeOpenAIBaseUrl =
sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, secretSource) ||
sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, processEnv) ||
DEFAULT_OPENAI_BASE_URL
const safeGeminiModel =
sanitizeProviderConfigValue(processEnv.GEMINI_MODEL, secretSource) ||
sanitizeProviderConfigValue(processEnv.GEMINI_MODEL, processEnv) ||
DEFAULT_GEMINI_MODEL
const safeMistralModel =
sanitizeProviderConfigValue(processEnv.MISTRAL_MODEL, processEnv) ||
DEFAULT_MISTRAL_MODEL
const safeMistralBaseUrl =
sanitizeProviderConfigValue(processEnv.MISTRAL_BASE_URL, processEnv) ||
DEFAULT_MISTRAL_BASE_URL
return {
openAIModel: safeOpenAIModel,
openAIBaseUrl: safeOpenAIBaseUrl,
geminiModel: safeGeminiModel,
mistralModel: safeMistralModel,
mistralBaseUrl: safeMistralBaseUrl,
}
}
@@ -193,7 +160,6 @@ export function buildCurrentProviderSummary(options?: {
persisted?: ProfileFile | null
}): CurrentProviderSummary {
const processEnv = options?.processEnv ?? process.env
const secretSource = processEnv as SecretSourceEnv
const persisted = options?.persisted ?? loadProfileFile()
const savedProfileLabel = persisted?.profile ?? 'none'
@@ -202,26 +168,11 @@ export function buildCurrentProviderSummary(options?: {
providerLabel: 'Google Gemini',
modelLabel: getSafeDisplayValue(
processEnv.GEMINI_MODEL ?? DEFAULT_GEMINI_MODEL,
secretSource,
processEnv,
),
endpointLabel: getSafeDisplayValue(
processEnv.GEMINI_BASE_URL ?? DEFAULT_GEMINI_BASE_URL,
secretSource,
),
savedProfileLabel,
}
}
if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_MISTRAL)) {
return {
providerLabel: 'Mistral',
modelLabel: getSafeDisplayValue(
processEnv.MISTRAL_MODEL ?? DEFAULT_MISTRAL_MODEL,
processEnv
),
endpointLabel: getSafeDisplayValue(
processEnv.MISTRAL_BASE_URL ?? DEFAULT_MISTRAL_BASE_URL,
processEnv
processEnv,
),
savedProfileLabel,
}
@@ -232,13 +183,13 @@ export function buildCurrentProviderSummary(options?: {
providerLabel: 'GitHub Models',
modelLabel: getSafeDisplayValue(
processEnv.OPENAI_MODEL ?? 'github:copilot',
secretSource,
processEnv,
),
endpointLabel: getSafeDisplayValue(
processEnv.OPENAI_BASE_URL ??
processEnv.OPENAI_API_BASE ??
'https://models.github.ai/inference',
secretSource,
processEnv,
),
savedProfileLabel,
}
@@ -259,8 +210,8 @@ export function buildCurrentProviderSummary(options?: {
return {
providerLabel,
modelLabel: getSafeDisplayValue(request.requestedModel, secretSource),
endpointLabel: getSafeDisplayValue(request.baseUrl, secretSource),
modelLabel: getSafeDisplayValue(request.requestedModel, processEnv),
endpointLabel: getSafeDisplayValue(request.baseUrl, processEnv),
savedProfileLabel,
}
}
@@ -271,11 +222,11 @@ export function buildCurrentProviderSummary(options?: {
processEnv.ANTHROPIC_MODEL ??
processEnv.CLAUDE_MODEL ??
'claude-sonnet-4-6',
secretSource,
processEnv,
),
endpointLabel: getSafeDisplayValue(
processEnv.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com',
secretSource,
processEnv,
),
savedProfileLabel,
}
@@ -308,24 +259,6 @@ function buildSavedProfileSummary(
? 'configured'
: undefined,
}
case 'mistral':
return {
providerLabel: 'Mistral',
modelLabel: getSafeDisplayValue(
env.MISTRAL_MODEL ?? DEFAULT_MISTRAL_MODEL,
process.env,
env,
),
endpointLabel: getSafeDisplayValue(
env.MISTRAL_BASE_URL ?? DEFAULT_MISTRAL_BASE_URL,
process.env,
env,
),
credentialLabel:
maskSecretForDisplay(env.MISTRAL_API_KEY) !== undefined
? 'configured'
: undefined,
}
case 'codex':
return {
providerLabel: 'Codex',
@@ -389,10 +322,6 @@ export function buildProfileSaveMessage(
profile: ProviderProfile,
env: ProfileEnv,
filePath: string,
options?: {
activatedInSession?: boolean
activationWarning?: string | null
},
): string {
const summary = buildSavedProfileSummary(profile, env)
const lines = [
@@ -406,24 +335,13 @@ export function buildProfileSaveMessage(
}
lines.push(`Profile: ${filePath}`)
if (options?.activatedInSession) {
lines.push('OpenClaude switched to it for this session.')
} else if (options?.activationWarning) {
lines.push(
`Saved for next startup. Warning: could not activate it in this session (${options.activationWarning}).`,
)
} else {
lines.push('Restart OpenClaude to use it.')
}
lines.push('Restart OpenClaude to use it.')
return lines.join('\n')
}
function buildUsageText(): string {
const summary = buildCurrentProviderSummary()
const availableProviders = isBareMode()
? 'Choose Auto, Ollama, OpenAI-compatible, Gemini, or Codex, then save a provider profile.'
: 'Choose Auto, Ollama, OpenAI-compatible, Gemini, Codex, or Codex OAuth, then save a provider profile.'
return [
'Usage: /provider',
'',
@@ -434,7 +352,7 @@ function buildUsageText(): string {
`Current endpoint: ${summary.endpointLabel}`,
`Saved profile: ${summary.savedProfileLabel}`,
'',
availableProviders,
'Choose Auto, Ollama, OpenAI-compatible, Gemini, or Codex, then save a profile for the next OpenClaude restart.',
].join('\n')
}
@@ -443,45 +361,12 @@ function finishProfileSave(
profile: ProviderProfile,
env: ProfileEnv,
): void {
void saveProfileAndNotify(onDone, profile, env)
}
export function buildCodexOAuthProfileEnv(
tokens: Pick<CodexOAuthTokens, 'accessToken' | 'idToken' | 'accountId'>,
): ProfileEnv | null {
return buildSharedCodexOAuthProfileEnv(tokens)
}
export async function applySavedProfileToCurrentSession(options: {
profileFile: ProfileFile
processEnv?: NodeJS.ProcessEnv
}): Promise<string | null> {
return applySharedProfileToCurrentSession(options)
}
async function saveProfileAndNotify(
onDone: LocalJSXCommandOnDone,
profile: ProviderProfile,
env: ProfileEnv,
): Promise<void> {
try {
const profileFile = createProfileFile(profile, env)
const filePath = saveProfileFile(profileFile)
const shouldActivateInSession = profile === 'codex'
const activationWarning = shouldActivateInSession
? await applySharedProfileToCurrentSession({ profileFile })
: null
onDone(
buildProfileSaveMessage(profile, env, filePath, {
activatedInSession:
shouldActivateInSession && activationWarning === null,
activationWarning,
}),
{
display: 'system',
},
)
onDone(buildProfileSaveMessage(profile, env, filePath), {
display: 'system',
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
onDone(`Failed to save provider profile: ${message}`, {
@@ -565,10 +450,6 @@ function ProviderChooser({
onCancel: () => void
}): React.ReactNode {
const summary = buildCurrentProviderSummary()
const canUseCodexOAuth = !isBareMode()
const helperText = canUseCodexOAuth
? 'Save a provider profile without editing environment variables first. Codex profiles backed by env, auth.json, or OpenClaude secure storage can switch this session immediately when validation succeeds.'
: 'Save a provider profile without editing environment variables first. Codex profiles backed by env or auth.json can switch this session immediately.'
const options: OptionWithDescription<ProviderChoice>[] = [
{
label: 'Auto',
@@ -592,26 +473,11 @@ function ProviderChooser({
value: 'gemini',
description: 'Use Google Gemini with API key, access token, or local ADC',
},
{
label: 'Mistral',
value: 'mistral',
description: 'Use Mistral with API key'
},
{
label: 'Codex',
value: 'codex',
description: 'Use existing ChatGPT Codex CLI auth or env credentials',
},
...(canUseCodexOAuth
? [
{
label: 'Codex OAuth',
value: 'codex-oauth' as const,
description:
'Sign in with ChatGPT in your browser and store Codex tokens securely',
},
]
: []),
]
if (summary.savedProfileLabel !== 'none') {
@@ -629,7 +495,10 @@ function ProviderChooser({
onCancel={onCancel}
>
<Box flexDirection="column" gap={1}>
<Text>{helperText}</Text>
<Text>
Save a provider profile for the next OpenClaude restart without
editing environment variables first.
</Text>
<Box flexDirection="column">
<Text dimColor>Current model: {summary.modelLabel}</Text>
<Text dimColor>Current endpoint: {summary.endpointLabel}</Text>
@@ -781,9 +650,7 @@ function AutoRecommendationStep({
{ label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' },
]}
onChange={(value: string) =>
value === 'back' ? onBack() : onCancel()
}
onChange={value => (value === 'back' ? onBack() : onCancel())}
onCancel={onCancel}
/>
</Box>
@@ -806,7 +673,7 @@ function AutoRecommendationStep({
{ label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' },
]}
onChange={(value: string) => {
onChange={value => {
if (value === 'continue') {
onNeedOpenAI(status.defaultModel)
} else if (value === 'back') {
@@ -839,7 +706,7 @@ function AutoRecommendationStep({
{ label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' },
]}
onChange={(value: string) => {
onChange={value => {
if (value === 'save') {
onSave(
'ollama',
@@ -941,9 +808,7 @@ function OllamaModelStep({
{ label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' },
]}
onChange={(value: string) =>
value === 'back' ? onBack() : onCancel()
}
onChange={value => (value === 'back' ? onBack() : onCancel())}
onCancel={onCancel}
/>
</Box>
@@ -964,7 +829,7 @@ function OllamaModelStep({
defaultFocusValue={status.defaultValue}
inlineDescriptions
visibleOptionCount={Math.min(8, status.options.length)}
onChange={(value: string) => {
onChange={value => {
onSave(
'ollama',
buildOllamaProfileEnv(value, {
@@ -979,84 +844,6 @@ function OllamaModelStep({
)
}
function CodexOAuthStep({
onSave,
onBack,
onCancel,
}: {
onSave: (profile: ProviderProfile, env: ProfileEnv) => void
onBack: () => void
onCancel: () => void
}): React.ReactNode {
const handleAuthenticated = React.useCallback(async (
tokens: CodexOAuthTokens,
persistCredentials: (options?: { profileId?: string }) => void,
) => {
const env = buildCodexOAuthProfileEnv(tokens)
if (!env) {
throw new Error(
'Codex OAuth succeeded, but OpenClaude could not build a Codex profile from the stored credentials.',
)
}
persistCredentials()
onSave('codex', env)
}, [onSave])
const status = useCodexOAuthFlow({
onAuthenticated: handleAuthenticated,
})
if (status.state === 'error') {
return (
<Dialog title="Codex OAuth failed" onCancel={onCancel} color="warning">
<Box flexDirection="column" gap={1}>
<Text>{status.message}</Text>
<Select
options={[
{ label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' },
]}
onChange={(value: string) =>
value === 'back' ? onBack() : onCancel()
}
onCancel={onCancel}
/>
</Box>
</Dialog>
)
}
if (status.state === 'starting') {
return <LoadingState message="Starting Codex OAuth..." />
}
return (
<Dialog title="Codex OAuth" onCancel={onBack}>
<Box flexDirection="column" gap={1}>
<Text>
Finish signing in with ChatGPT in your browser. OpenClaude will store
the resulting Codex credentials securely for future sessions.
</Text>
{status.browserOpened === false ? (
<Text color="warning">
Browser did not open automatically. Visit this URL to continue:
</Text>
) : status.browserOpened === true ? (
<Text dimColor>
Browser opened. Complete the sign-in there, then OpenClaude will
finish setup automatically.
</Text>
) : (
<Text dimColor>Opening your browser...</Text>
)}
<Text>{status.authUrl}</Text>
<Text dimColor>Press Esc to cancel and go back.</Text>
</Box>
</Dialog>
)
}
function CodexCredentialStep({
onSave,
onBack,
@@ -1078,9 +865,7 @@ function CodexCredentialStep({
{ label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' },
]}
onChange={(value: string) =>
value === 'back' ? onBack() : onCancel()
}
onChange={value => (value === 'back' ? onBack() : onCancel())}
onCancel={onCancel}
/>
</Box>
@@ -1114,10 +899,9 @@ function CodexCredentialStep({
defaultFocusValue="codexplan"
inlineDescriptions
visibleOptionCount={options.length}
onChange={(value: string) => {
onChange={value => {
const env = buildCodexProfileEnv({
model: value,
credentialSource: credentials.credentialSource,
processEnv: process.env,
})
if (env) {
@@ -1132,16 +916,9 @@ function CodexCredentialStep({
}
function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv):
| {
ok: true
sourceDescription: string
credentialSource: 'oauth' | 'existing'
}
| { ok: true; sourceDescription: string }
| { ok: false; message: string } {
const credentials = resolveCodexApiCredentials(processEnv)
const oauthHint = isBareMode()
? 'Re-login with the Codex CLI'
: 'Choose Codex OAuth in /provider, or re-login with the Codex CLI'
if (!credentials.apiKey) {
const authHint = credentials.authPath
@@ -1149,7 +926,7 @@ function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv):
: 'Set CODEX_API_KEY or re-login with the Codex CLI.'
return {
ok: false,
message: `Codex setup needs existing credentials. ${oauthHint}, or set CODEX_API_KEY. ${authHint}`,
message: `Codex setup needs existing credentials. Re-login with the Codex CLI or set CODEX_API_KEY. ${authHint}`,
}
}
@@ -1157,19 +934,15 @@ function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv):
return {
ok: false,
message:
`Codex auth is missing chatgpt_account_id. ${oauthHint}, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID first.`,
'Codex auth is missing chatgpt_account_id. Re-login with the Codex CLI or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID first.',
}
}
return {
ok: true,
credentialSource:
credentials.source === 'secure-storage' ? 'oauth' : 'existing',
sourceDescription:
credentials.source === 'env'
? 'the current shell environment'
: credentials.source === 'secure-storage'
? 'OpenClaude secure storage'
: credentials.authPath ?? DEFAULT_CODEX_BASE_URL,
}
}
@@ -1198,13 +971,6 @@ export function ProviderWizard({
})
} else if (value === 'gemini') {
setStep({ name: 'gemini-auth-method' })
} else if (value === 'mistral') {
setStep({
name: 'mistral-key',
defaultModel: defaults.mistralModel,
})
} else if (value === 'codex-oauth') {
setStep({ name: 'codex-oauth' })
} else if (value === 'clear') {
const filePath = deleteProfileFile()
onDone(`Removed saved provider profile at ${filePath}. Restart OpenClaude to go back to normal startup.`, {
@@ -1344,101 +1110,6 @@ export function ProviderWizard({
/>
)
case 'mistral-key':
return (
<TextEntryDialog
resetStateKey={step.name}
title="Mistral setup"
subtitle="Step 1 of 3"
description={
process.env.MISTRAL_API_KEY
? 'Enter an API key, or leave this blank to reuse the current MISTRAL_API_KEY from this session.'
: 'Enter the API key for your Mistral provider.'
}
initialValue=""
placeholder="..."
mask="*"
allowEmpty={Boolean(process.env.MISTRAL_API_KEY)}
validate={value => {
const candidate = value.trim() || process.env.MISTRAL_API_KEY || ''
return sanitizeApiKey(candidate)
? null
: 'Enter a real API key. Placeholder values like SUA_CHAVE are not valid.'
}}
onSubmit={value => {
const apiKey = value.trim() || process.env.MISTRAL_API_KEY || ''
setStep({
name: 'mistral-base',
apiKey,
defaultModel: step.defaultModel,
})
}}
onCancel={() => setStep({ name: 'choose' })}
/>
)
case 'mistral-base':
return (
<TextEntryDialog
resetStateKey={step.name}
title="Mistral setup"
subtitle="Step 2 of 3"
description={`Optionally enter a base URL. Leave blank for ${DEFAULT_MISTRAL_BASE_URL}.`}
initialValue={
defaults.mistralBaseUrl === DEFAULT_MISTRAL_BASE_URL
? ''
: defaults.mistralBaseUrl
}
placeholder={DEFAULT_MISTRAL_BASE_URL}
allowEmpty
onSubmit={value => {
setStep({
name: 'mistral-model',
apiKey: step.apiKey,
baseUrl: value.trim() || null,
defaultModel: step.defaultModel,
})
}}
onCancel={() =>
setStep({
name: 'mistral-key',
defaultModel: step.defaultModel,
})
}
/>
)
case 'mistral-model':
return (
<TextEntryDialog
resetStateKey={step.name}
title="Mistral setup"
subtitle="Step 3 of 3"
description={`Enter a model name. Leave blank for ${step.defaultModel}.`}
initialValue={defaults.mistralModel ?? step.defaultModel}
placeholder={step.defaultModel}
allowEmpty
onSubmit={value => {
const env = buildMistralProfileEnv({
model: value.trim() || step.defaultModel,
baseUrl: step.baseUrl,
apiKey: step.apiKey,
processEnv: process.env,
})
if (env) {
finishProfileSave(onDone, 'mistral', env)
}
}}
onCancel={() =>
setStep({
name: 'mistral-base',
apiKey: step.apiKey,
defaultModel: step.defaultModel,
})
}
/>
)
case 'gemini-auth-method': {
const hasShellGeminiKey = Boolean(
process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY,
@@ -1484,7 +1155,7 @@ export function ProviderWizard({
options={options}
inlineDescriptions
visibleOptionCount={options.length}
onChange={(value: string) => {
onChange={value => {
if (value === 'api-key') {
setStep({ name: 'gemini-key' })
} else if (value === 'access-token') {
@@ -1640,15 +1311,6 @@ export function ProviderWizard({
onCancel={() => onDone()}
/>
)
case 'codex-oauth':
return (
<CodexOAuthStep
onSave={(profile, env) => finishProfileSave(onDone, profile, env)}
onBack={() => setStep({ name: 'choose' })}
onCancel={() => onDone()}
/>
)
}
}

View File

@@ -1,17 +0,0 @@
/**
* /repomap command - minimal metadata only.
* Implementation is lazy-loaded from repomap.ts to reduce startup time.
*/
import type { Command } from '../../commands.js'
const repomap = {
type: 'local',
name: 'repomap',
description:
'Show or configure the repository structural map (codebase intelligence)',
isHidden: false,
supportsNonInteractive: true,
load: () => import('./repomap.js'),
} satisfies Command
export default repomap

View File

@@ -1,56 +0,0 @@
import { describe, expect, test } from 'bun:test'
import { parseArgs } from './repomap.js'
describe('/repomap argument parsing', () => {
test('defaults to 1024 tokens with no flags', () => {
const result = parseArgs('')
expect(result.tokens).toBe(2048)
expect(result.focus).toEqual([])
expect(result.invalidate).toBe(false)
expect(result.stats).toBe(false)
})
test('parses --tokens flag', () => {
const result = parseArgs('--tokens 4096')
expect(result.tokens).toBe(4096)
})
test('rejects --tokens below 256', () => {
const result = parseArgs('--tokens 100')
expect(result.tokens).toBe(2048) // falls back to default
})
test('rejects --tokens above 16384', () => {
const result = parseArgs('--tokens 20000')
expect(result.tokens).toBe(2048) // falls back to default
})
test('parses --focus flag', () => {
const result = parseArgs('--focus src/tools/')
expect(result.focus).toEqual(['src/tools/'])
})
test('parses multiple --focus flags', () => {
const result = parseArgs('--focus src/tools/ --focus src/context.ts')
expect(result.focus).toEqual(['src/tools/', 'src/context.ts'])
})
test('parses --invalidate flag', () => {
const result = parseArgs('--invalidate')
expect(result.invalidate).toBe(true)
expect(result.stats).toBe(false)
})
test('parses --stats flag', () => {
const result = parseArgs('--stats')
expect(result.stats).toBe(true)
expect(result.invalidate).toBe(false)
})
test('parses combined flags', () => {
const result = parseArgs('--tokens 2048 --focus src/tools/ --invalidate')
expect(result.tokens).toBe(2048)
expect(result.focus).toEqual(['src/tools/'])
expect(result.invalidate).toBe(true)
})
})

View File

@@ -1,93 +0,0 @@
import type { LocalCommandCall } from '../../types/command.js'
import { getCwd } from '../../utils/cwd.js'
/** Parse CLI-style arguments from the command string. */
export function parseArgs(args: string): {
tokens: number
focus: string[]
invalidate: boolean
stats: boolean
} {
const parts = args.trim().split(/\s+/).filter(Boolean)
let tokens = 2048
const focus: string[] = []
let invalidate = false
let stats = false
for (let i = 0; i < parts.length; i++) {
const part = parts[i]!
if (part === '--tokens' && i + 1 < parts.length) {
const n = parseInt(parts[i + 1]!, 10)
if (!isNaN(n) && n >= 256 && n <= 16384) {
tokens = n
}
i++
} else if (part === '--focus' && i + 1 < parts.length) {
focus.push(parts[i + 1]!)
i++
} else if (part === '--invalidate') {
invalidate = true
} else if (part === '--stats') {
stats = true
}
}
return { tokens, focus, invalidate, stats }
}
export const call: LocalCommandCall = async (args) => {
const root = getCwd()
const { tokens, focus, invalidate, stats } = parseArgs(args ?? '')
// Lazy import to avoid loading tree-sitter at startup
const {
buildRepoMap,
invalidateCache,
getCacheStats,
} = await import('../../context/repoMap/index.js')
if (stats) {
const cacheStats = getCacheStats(root)
const lines = [
`Repository map cache stats:`,
` Cache directory: ${cacheStats.cacheDir}`,
` Cache file: ${cacheStats.cacheFile ?? '(none)'}`,
` Cached entries: ${cacheStats.entryCount}`,
` Cache exists: ${cacheStats.exists}`,
]
return { type: 'text', value: lines.join('\n') }
}
if (invalidate) {
invalidateCache(root)
const result = await buildRepoMap({
root,
maxTokens: tokens,
focusFiles: focus.length > 0 ? focus : undefined,
})
return {
type: 'text',
value: [
`Cache invalidated and rebuilt.`,
`Files: ${result.fileCount} ranked (${result.totalFileCount} total) | Tokens: ${result.tokenCount} | Time: ${result.buildTimeMs}ms | Cache hit: ${result.cacheHit}`,
'',
result.map,
].join('\n'),
}
}
const result = await buildRepoMap({
root,
maxTokens: tokens,
focusFiles: focus.length > 0 ? focus : undefined,
})
return {
type: 'text',
value: [
`Repository map: ${result.fileCount} files ranked (${result.totalFileCount} total) | Tokens: ${result.tokenCount} | Time: ${result.buildTimeMs}ms | Cache hit: ${result.cacheHit}`,
'',
result.map,
].join('\n'),
}
}

View File

@@ -65,7 +65,7 @@ export async function call(onDone: (result?: string) => void, _context: unknown,
// Get the local settings path and make it relative to cwd
const localSettingsPath = getSettingsFilePathForSource('localSettings');
const relativePath = localSettingsPath ? relative(getCwdState(), localSettingsPath) : '.openclaude/settings.local.json';
const relativePath = localSettingsPath ? relative(getCwdState(), localSettingsPath) : '.claude/settings.local.json';
const message = color('success', themeName)(`Added "${cleanPattern}" to excluded commands in ${relativePath}`);
onDone(message);
return null;

View File

@@ -1,12 +0,0 @@
import type { Command } from '../../commands.js'
const wiki = {
type: 'local-jsx',
name: 'wiki',
description: 'Initialize and inspect the OpenClaude project wiki',
argumentHint: '[init|status]',
immediate: true,
load: () => import('./wiki.js'),
} satisfies Command
export default wiki

View File

@@ -1,123 +0,0 @@
import React from 'react'
import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js'
import { ingestLocalWikiSource } from '../../services/wiki/ingest.js'
import { initializeWiki } from '../../services/wiki/init.js'
import { getWikiStatus } from '../../services/wiki/status.js'
import type {
LocalJSXCommandCall,
LocalJSXCommandOnDone,
} from '../../types/command.js'
import { getCwd } from '../../utils/cwd.js'
function renderHelp(): string {
return `Usage: /wiki [init|status|ingest <path>]
Manage the OpenClaude project wiki stored in .openclaude/wiki.
Commands:
/wiki init Initialize the wiki structure in the current project
/wiki status Show wiki status and page/source counts
/wiki ingest Ingest a local file into wiki sources
Examples:
/wiki init
/wiki status
/wiki ingest README.md`
}
function formatInitResult(result: Awaited<ReturnType<typeof initializeWiki>>): string {
const lines = [`Initialized OpenClaude wiki at ${result.root}`]
if (result.alreadyExisted) {
lines.push('', 'Wiki already existed. No new files were created.')
return lines.join('\n')
}
if (result.createdFiles.length > 0) {
lines.push('', 'Created files:')
for (const file of result.createdFiles) {
lines.push(`- ${file}`)
}
}
return lines.join('\n')
}
function formatStatus(status: Awaited<ReturnType<typeof getWikiStatus>>): string {
if (!status.initialized) {
return `OpenClaude wiki is not initialized in this project.\n\nRun /wiki init to create ${status.root}.`
}
return [
'OpenClaude wiki status',
'',
`Root: ${status.root}`,
`Pages: ${status.pageCount}`,
`Sources: ${status.sourceCount}`,
`Schema: ${status.hasSchema ? 'present' : 'missing'}`,
`Index: ${status.hasIndex ? 'present' : 'missing'}`,
`Log: ${status.hasLog ? 'present' : 'missing'}`,
`Last updated: ${status.lastUpdatedAt ?? 'unknown'}`,
].join('\n')
}
function formatIngestResult(
result: Awaited<ReturnType<typeof ingestLocalWikiSource>>,
): string {
return [
`Ingested ${result.sourceFile} into the OpenClaude wiki.`,
'',
`Title: ${result.title}`,
`Source note: ${result.sourceNote}`,
`Summary: ${result.summary}`,
].join('\n')
}
async function runWikiCommand(
onDone: LocalJSXCommandOnDone,
args: string,
): Promise<void> {
const cwd = getCwd()
const normalized = args.trim().toLowerCase()
if (COMMON_HELP_ARGS.includes(normalized) || COMMON_INFO_ARGS.includes(normalized)) {
onDone(renderHelp(), { display: 'system' })
return
}
if (!normalized || normalized === 'status') {
onDone(formatStatus(await getWikiStatus(cwd)), { display: 'system' })
return
}
if (normalized === 'init') {
onDone(formatInitResult(await initializeWiki(cwd)), { display: 'system' })
return
}
if (normalized.startsWith('ingest')) {
const pathArg = args.trim().slice('ingest'.length).trim()
if (!pathArg) {
onDone('Usage: /wiki ingest <local-file-path>', { display: 'system' })
return
}
onDone(formatIngestResult(await ingestLocalWikiSource(cwd, pathArg)), {
display: 'system',
})
return
}
onDone(`Unknown wiki subcommand: ${args.trim()}\n\n${renderHelp()}`, {
display: 'system',
})
}
export const call: LocalJSXCommandCall = async (
onDone,
_context,
args,
): Promise<React.ReactNode> => {
await runWikiCommand(onDone, args ?? '')
return null
}

View File

@@ -188,9 +188,9 @@ export function AutoUpdater({
Update installed · Restart to apply
</Text>}
{(autoUpdaterResult?.status === 'install_failed' || autoUpdaterResult?.status === 'no_permissions') && <Text color="error" wrap="truncate">
Auto-update failed &middot; Try <Text bold>openclaude doctor</Text> or{' '}
Auto-update failed &middot; Try <Text bold>claude doctor</Text> or{' '}
<Text bold>
{hasLocalInstall ? `cd ~/.openclaude/local && npm update ${MACRO.PACKAGE_URL}` : `npm i -g ${MACRO.PACKAGE_URL}`}
{hasLocalInstall ? `cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}` : `npm i -g ${MACRO.PACKAGE_URL}`}
</Text>
</Text>}
</Box>;

View File

@@ -31,11 +31,9 @@ export function BaseTextInput(t0) {
} = t0;
const {
onInput,
value,
renderedValue,
cursorLine,
cursorColumn,
offset,
cursorColumn
} = inputState;
const t1 = Boolean(props.focus && props.showCursor && terminalFocus);
let t2;
@@ -80,7 +78,7 @@ export function BaseTextInput(t0) {
renderedPlaceholder
} = renderPlaceholder({
placeholder: props.placeholder,
value,
value: props.value,
showCursor: props.showCursor,
focus: props.focus,
terminalFocus,
@@ -90,9 +88,9 @@ export function BaseTextInput(t0) {
useInput(wrappedOnInput, {
isActive: props.focus
});
const commandWithoutArgs = value && value.trim().indexOf(" ") === -1 || value && value.endsWith(" ");
const showArgumentHint = Boolean(props.argumentHint && value && commandWithoutArgs && value.startsWith("/"));
const cursorFiltered = props.showCursor && props.highlights ? props.highlights.filter(h => h.dimColor || offset < h.start || offset >= h.end) : props.highlights;
const commandWithoutArgs = props.value && props.value.trim().indexOf(" ") === -1 || props.value && props.value.endsWith(" ");
const showArgumentHint = Boolean(props.argumentHint && props.value && commandWithoutArgs && props.value.startsWith("/"));
const cursorFiltered = props.showCursor && props.highlights ? props.highlights.filter(h => h.dimColor || props.cursorOffset < h.start || props.cursorOffset >= h.end) : props.highlights;
const {
viewportCharOffset,
viewportCharEnd
@@ -104,13 +102,13 @@ export function BaseTextInput(t0) {
})) : cursorFiltered;
const hasHighlights = filteredHighlights && filteredHighlights.length > 0;
if (hasHighlights) {
return <Box ref={cursorRef}><HighlightedInput text={renderedValue} highlights={filteredHighlights} />{showArgumentHint && <Text dimColor={true}>{value.endsWith(" ") ? "" : " "}{props.argumentHint}</Text>}{children}</Box>;
return <Box ref={cursorRef}><HighlightedInput text={renderedValue} highlights={filteredHighlights} />{showArgumentHint && <Text dimColor={true}>{props.value?.endsWith(" ") ? "" : " "}{props.argumentHint}</Text>}{children}</Box>;
}
const T0 = Box;
const T1 = Text;
const t4 = "truncate-end";
const t5 = showPlaceholder && props.placeholderElement ? props.placeholderElement : showPlaceholder && renderedPlaceholder ? <Ansi>{renderedPlaceholder}</Ansi> : <Ansi>{renderedValue}</Ansi>;
const t6 = showArgumentHint && <Text dimColor={true}>{value.endsWith(" ") ? "" : " "}{props.argumentHint}</Text>;
const t6 = showArgumentHint && <Text dimColor={true}>{props.value?.endsWith(" ") ? "" : " "}{props.argumentHint}</Text>;
let t7;
if ($[4] !== T1 || $[5] !== children || $[6] !== props || $[7] !== t5 || $[8] !== t6) {
t7 = <T1 wrap={t4} dimColor={props.dimColor}>{t5}{t6}{children}</T1>;

View File

@@ -103,7 +103,7 @@ test('login picker shows the third-party platform option', async () => {
expect(output).toContain('3rd-party platform')
})
test('third-party provider branch opens the first-run provider manager', async () => {
test('third-party provider branch opens the provider wizard', async () => {
const output = await renderFrame(
<ConsoleOAuthFlow
initialStatus={{ state: 'platform_setup' }}
@@ -111,9 +111,7 @@ test('third-party provider branch opens the first-run provider manager', async (
/>,
)
expect(output).toContain('Set up provider')
expect(output).toContain('Anthropic')
expect(output).toContain('OpenAI')
expect(output).toContain('Set up a provider profile')
expect(output).toContain('OpenAI-compatible')
expect(output).toContain('Ollama')
expect(output).toContain('LM Studio')
})

View File

@@ -12,7 +12,7 @@ import { OAuthService } from '../services/oauth/index.js';
import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js';
import { logError } from '../utils/log.js';
import { getSettings_DEPRECATED } from '../utils/settings/settings.js';
import { ProviderManager } from './ProviderManager.js';
import { ProviderWizard } from '../commands/provider/provider.js';
import { Select } from './CustomSelect/select.js';
import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js';
import { Spinner } from './Spinner.js';
@@ -450,17 +450,16 @@ function OAuthStatusMessage({
case 'platform_setup':
return (
<ProviderManager
mode="first-run"
<ProviderWizard
onDone={result => {
if (!result || result.action !== 'saved' || !result.message) {
if (!result) {
setOAuthStatus({ state: 'idle' })
return
}
setOAuthStatus({
state: 'platform_setup_complete',
message: result.message,
message: result,
})
}}
/>

View File

@@ -285,7 +285,7 @@ export function Select(t0) {
onChange,
onCancel,
onFocus,
defaultFocusValue,
focusValue: defaultFocusValue
};
$[7] = defaultFocusValue;
$[8] = defaultValue;

View File

@@ -1,4 +1,5 @@
import { useCallback, useState } from 'react'
import { isDeepStrictEqual } from 'util'
import { useRegisterOverlay } from '../../context/overlayContext.js'
import type { InputEvent } from '../../ink/events/input-event.js'
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw space/arrow multiselect input
@@ -8,7 +9,6 @@ import {
normalizeFullWidthSpace,
} from '../../utils/stringUtils.js'
import type { OptionWithDescription } from './select.js'
import { optionsNavigateEqual } from './use-select-navigation.js'
import { useSelectNavigation } from './use-select-navigation.js'
export type UseMultiSelectStateProps<T> = {
@@ -174,7 +174,7 @@ export function useMultiSelectState<T>({
// and the deleted ui/useMultiSelectState.ts — without this, MCPServerDesktopImportDialog
// keeps colliding servers checked after getAllMcpConfigs() resolves.
const [lastOptions, setLastOptions] = useState(options)
if (options !== lastOptions && !optionsNavigateEqual(options, lastOptions)) {
if (options !== lastOptions && !isDeepStrictEqual(options, lastOptions)) {
setSelectedValues(defaultValue)
setLastOptions(options)
}

View File

@@ -6,34 +6,10 @@ import {
useRef,
useState,
} from 'react'
import { isDeepStrictEqual } from 'util'
import OptionMap from './option-map.js'
import type { OptionWithDescription } from './select.js'
/**
* Compare two option arrays for structural equality on properties that
* affect navigation behavior. ReactNode `label` and function `onChange`
* are intentionally excluded — they are identity-unstable (new reference
* each render) but don't change navigation semantics.
*/
export function optionsNavigateEqual<T>(
a: OptionWithDescription<T>[],
b: OptionWithDescription<T>[],
): boolean {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
const ao = a[i]!
const bo = b[i]!
if (
ao.value !== bo.value ||
ao.disabled !== bo.disabled ||
ao.type !== bo.type
) {
return false
}
}
return true
}
type State<T> = {
/**
* Map where key is option's value and value is option's index.
@@ -548,7 +524,7 @@ export function useSelectNavigation<T>({
const [lastOptions, setLastOptions] = useState(options)
if (options !== lastOptions && !optionsNavigateEqual(options, lastOptions)) {
if (options !== lastOptions && !isDeepStrictEqual(options, lastOptions)) {
dispatch({
type: 'reset',
state: createDefaultState({

View File

@@ -35,11 +35,6 @@ export type UseSelectStateProps<T> = {
*/
onFocus?: (value: T) => void
/**
* Initial value to focus when the component mounts.
*/
defaultFocusValue?: T
/**
* Value to focus
*/
@@ -136,7 +131,6 @@ export function useSelectState<T>({
onChange,
onCancel,
onFocus,
defaultFocusValue,
focusValue,
}: UseSelectStateProps<T>): SelectState<T> {
const [value, setValue] = useState<T | undefined>(defaultValue)
@@ -144,7 +138,7 @@ export function useSelectState<T>({
const navigation = useSelectNavigation<T>({
visibleOptionCount,
options,
initialFocusValue: defaultFocusValue,
initialFocusValue: undefined,
onFocus,
focusValue,
})

View File

@@ -101,9 +101,9 @@ export function EffortPicker({ onSelect, onCancel }: Props) {
<Box marginBottom={1} flexDirection="column">
<Text color="remember" bold={true}>Set effort level</Text>
<Text dimColor={true}>
{supportsEffort && usesOpenAIEffort
? `OpenAI/Codex provider (${provider})`
: supportsEffort
{usesOpenAIEffort
? `OpenAI/Codex provider (${provider})`
: supportsEffort
? `Claude model · ${provider} provider`
: `Effort not supported for this model`
}

View File

@@ -112,7 +112,7 @@ export function HelpV2(t0) {
}
tabs.push(t6);
if (false && antOnlyCommands.length > 0) {
let t7;
let t7;
if ($[26] !== antOnlyCommands || $[27] !== close || $[28] !== columns || $[29] !== maxHeight) {
t7 = <Tab key="internal-only" title="[internal-only]"><Commands commands={antOnlyCommands} maxHeight={maxHeight} columns={columns} title="Browse internal-only commands:" onCancel={close} /></Tab>;
$[26] = antOnlyCommands;

View File

@@ -252,24 +252,14 @@ function PromptInput({
show: false
});
const [cursorOffset, setCursorOffset] = useState<number>(input.length);
// Track the last input value set via internal handlers so external updates
// (for example speech-to-text injection) can still move the cursor to end
// without clobbering a pending internal keystroke during render.
// Track the last input value set via internal handlers so we can detect
// external input changes (e.g. speech-to-text injection) and move cursor to end.
const lastInternalInputRef = React.useRef(input);
const lastPropInputRef = React.useRef(input);
React.useLayoutEffect(() => {
if (input === lastPropInputRef.current) {
return;
}
lastPropInputRef.current = input;
if (input === lastInternalInputRef.current) {
return;
}
if (input !== lastInternalInputRef.current) {
// Input changed externally (not through any internal handler) — move cursor to end
setCursorOffset(input.length);
lastInternalInputRef.current = input;
setCursorOffset(prev => prev === input.length ? prev : input.length);
}, [input]);
}
// Wrap onInputChange to track internal changes before they trigger re-render
const trackAndSetInput = React.useCallback((value: string) => {
lastInternalInputRef.current = value;
@@ -2211,7 +2201,7 @@ function PromptInput({
multiline: true,
onSubmit,
onChange,
value: isSearchingHistory && historyMatch ? getValueFromInput(typeof historyMatch === 'string' ? historyMatch : historyMatch.display) : input,
value: historyMatch ? getValueFromInput(typeof historyMatch === 'string' ? historyMatch : historyMatch.display) : input,
// History navigation is handled via TextInput props (onHistoryUp/onHistoryDown),
// NOT via useKeybindings. This allows useTextInput's upOrHistoryUp/downOrHistoryDown
// to try cursor movement first and only fall through to history navigation when the

View File

@@ -5,14 +5,12 @@ import React from 'react'
import stripAnsi from 'strip-ansi'
import { createRoot } from '../ink.js'
import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'
import { AppStateProvider } from '../state/AppState.js'
const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l'
const ORIGINAL_ENV = {
CLAUDE_CODE_SIMPLE: process.env.CLAUDE_CODE_SIMPLE,
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
GH_TOKEN: process.env.GH_TOKEN,
@@ -108,154 +106,42 @@ function createDeferred<T>(): {
return { promise, resolve }
}
function mockProviderProfilesModule(options?: {
addProviderProfile?: (...args: unknown[]) => unknown
getProviderProfiles?: () => unknown[]
updateProviderProfile?: (...args: unknown[]) => unknown
setActiveProviderProfile?: (...args: unknown[]) => unknown
}): void {
function mockProviderProfilesModule(): void {
mock.module('../utils/providerProfiles.js', () => ({
addProviderProfile: options?.addProviderProfile ?? (() => null),
addProviderProfile: () => null,
applyActiveProviderProfileFromConfig: () => {},
deleteProviderProfile: () => ({ removed: false, activeProfileId: null }),
getActiveProviderProfile: () => null,
getProviderPresetDefaults: (preset: string) =>
preset === 'ollama'
? {
provider: 'openai',
name: 'Ollama',
baseUrl: 'http://localhost:11434/v1',
model: 'llama3.1:8b',
apiKey: '',
}
: {
provider: 'openai',
name: 'Mock provider',
baseUrl: 'http://localhost:11434/v1',
model: 'mock-model',
apiKey: '',
},
getProviderProfiles: options?.getProviderProfiles ?? (() => []),
setActiveProviderProfile: options?.setActiveProviderProfile ?? (() => null),
updateProviderProfile: options?.updateProviderProfile ?? (() => null),
getProviderPresetDefaults: () => ({
provider: 'openai',
name: 'Mock provider',
baseUrl: 'http://localhost:11434/v1',
model: 'mock-model',
apiKey: '',
}),
getProviderProfiles: () => [],
setActiveProviderProfile: () => null,
updateProviderProfile: () => null,
}))
}
function mockProviderManagerDependencies(
githubSyncRead: () => string | undefined,
githubAsyncRead: () => Promise<string | undefined>,
options?: {
addProviderProfile?: (...args: unknown[]) => unknown
applySavedProfileToCurrentSession?: (...args: unknown[]) => Promise<string | null>
clearCodexCredentials?: () => { success: boolean; warning?: string }
getProviderProfiles?: () => unknown[]
hasLocalOllama?: () => Promise<boolean>
listOllamaModels?: () => Promise<
Array<{
name: string
sizeBytes?: number | null
family?: string | null
families?: string[]
parameterSize?: string | null
quantizationLevel?: string | null
}>
>
codexSyncRead?: () => unknown
codexAsyncRead?: () => Promise<unknown>
updateProviderProfile?: (...args: unknown[]) => unknown
setActiveProviderProfile?: (...args: unknown[]) => unknown
useCodexOAuthFlow?: (options: {
onAuthenticated: (tokens: {
accessToken: string
refreshToken: string
accountId?: string
idToken?: string
apiKey?: string
}, persistCredentials: (options?: { profileId?: string }) => void) =>
void | Promise<void>
}) => {
state: 'starting' | 'waiting' | 'error'
authUrl?: string
browserOpened?: boolean | null
message?: string
}
},
syncRead: () => string | undefined,
asyncRead: () => Promise<string | undefined>,
): void {
mockProviderProfilesModule({
addProviderProfile: options?.addProviderProfile,
getProviderProfiles: options?.getProviderProfiles,
updateProviderProfile: options?.updateProviderProfile,
setActiveProviderProfile: options?.setActiveProviderProfile,
})
mock.module('../utils/providerDiscovery.js', () => ({
hasLocalOllama: options?.hasLocalOllama ?? (async () => false),
listOllamaModels: options?.listOllamaModels ?? (async () => []),
}))
mockProviderProfilesModule()
mock.module('../utils/githubModelsCredentials.js', () => ({
clearGithubModelsToken: () => ({ success: true }),
GITHUB_MODELS_HYDRATED_ENV_MARKER: 'CLAUDE_CODE_GITHUB_TOKEN_HYDRATED',
hydrateGithubModelsTokenFromSecureStorage: () => {},
readGithubModelsToken: githubSyncRead,
readGithubModelsTokenAsync: githubAsyncRead,
}))
mock.module('../utils/codexCredentials.js', () => ({
attachCodexProfileIdToStoredCredentials: () => ({ success: true }),
clearCodexCredentials:
options?.clearCodexCredentials ?? (() => ({ success: true })),
readCodexCredentials:
options?.codexSyncRead ?? (() => undefined),
readCodexCredentialsAsync:
options?.codexAsyncRead ?? (async () => undefined),
}))
mock.module('../utils/providerProfile.js', () => ({
applySavedProfileToCurrentSession:
options?.applySavedProfileToCurrentSession ?? (async () => null),
buildCodexOAuthProfileEnv: (tokens: {
accessToken: string
accountId?: string
idToken?: string
}) => {
const accountId =
tokens.accountId ??
(tokens.idToken ? 'acct_from_id_token' : undefined) ??
(tokens.accessToken ? 'acct_from_access_token' : undefined)
if (!accountId) {
return null
}
return {
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
OPENAI_MODEL: 'codexplan',
CHATGPT_ACCOUNT_ID: accountId,
CODEX_CREDENTIAL_SOURCE: 'oauth' as const,
}
},
clearPersistedCodexOAuthProfile: () => null,
createProfileFile: (profile: string, env: Record<string, unknown>) => ({
profile,
env,
createdAt: '2026-04-10T00:00:00.000Z',
}),
readGithubModelsToken: syncRead,
readGithubModelsTokenAsync: asyncRead,
}))
mock.module('../utils/settings/settings.js', () => ({
updateSettingsForSource: () => ({ error: null }),
}))
mock.module('./useCodexOAuthFlow.js', () => ({
useCodexOAuthFlow:
options?.useCodexOAuthFlow ??
(() => ({
state: 'waiting' as const,
authUrl: 'https://chatgpt.com/codex',
browserOpened: true,
})),
}))
}
async function waitForFrameOutput(
@@ -276,14 +162,9 @@ async function waitForFrameOutput(
async function mountProviderManager(
ProviderManager: React.ComponentType<{
mode: 'first-run' | 'manage'
onDone: (result?: unknown) => void
onDone: () => void
}>,
options?: {
mode?: 'first-run' | 'manage'
onDone?: (result?: unknown) => void
},
): Promise<{
stdin: PassThrough
getOutput: () => string
dispose: () => Promise<void>
}> {
@@ -296,17 +177,14 @@ async function mountProviderManager(
root.render(
<AppStateProvider>
<KeybindingSetup>
<ProviderManager
mode={options?.mode ?? 'manage'}
onDone={options?.onDone ?? (() => {})}
/>
</KeybindingSetup>
<ProviderManager
mode="manage"
onDone={() => {}}
/>
</AppStateProvider>,
)
return {
stdin,
getOutput,
dispose: async () => {
root.unmount()
@@ -320,17 +198,14 @@ async function mountProviderManager(
async function renderProviderManagerFrame(
ProviderManager: React.ComponentType<{
mode: 'first-run' | 'manage'
onDone: (result?: unknown) => void
onDone: () => void
}>,
options?: {
mode?: 'first-run' | 'manage'
waitForOutput?: (output: string) => boolean
timeoutMs?: number
},
): Promise<string> {
const mounted = await mountProviderManager(ProviderManager, {
mode: options?.mode,
})
const mounted = await mountProviderManager(ProviderManager)
const output = await waitForFrameOutput(
mounted.getOutput,
frame => {
@@ -428,502 +303,3 @@ test('ProviderManager avoids first-frame false negative while stored-token looku
expect(syncRead).not.toHaveBeenCalled()
expect(asyncRead).toHaveBeenCalled()
})
test('ProviderManager first-run Ollama preset auto-detects installed models', async () => {
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const onDone = mock(() => {})
const addProviderProfile = mock((payload: {
provider: string
name: string
baseUrl: string
model: string
apiKey?: string
}) => ({
id: 'provider_ollama',
provider: payload.provider,
name: payload.name,
baseUrl: payload.baseUrl,
model: payload.model,
apiKey: payload.apiKey,
}))
mockProviderManagerDependencies(
() => undefined,
async () => undefined,
{
addProviderProfile,
hasLocalOllama: async () => true,
listOllamaModels: async () => [
{
name: 'gemma4:31b-cloud',
family: 'gemma',
parameterSize: '31b',
},
{
name: 'kimi-k2.5:cloud',
family: 'kimi',
parameterSize: '2.5b',
},
],
},
)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager, {
mode: 'first-run',
onDone,
})
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Set up provider') && frame.includes('Ollama'),
)
mounted.stdin.write('j')
await Bun.sleep(50)
mounted.stdin.write('\r')
const modelFrame = await waitForFrameOutput(
mounted.getOutput,
frame =>
frame.includes('Choose an Ollama model') &&
frame.includes('gemma4:31b-cloud') &&
frame.includes('kimi-k2.5:cloud'),
)
expect(modelFrame).toContain('Choose an Ollama model')
expect(modelFrame).toContain('gemma4:31b-cloud')
await Bun.sleep(25)
mounted.stdin.write('\r')
await waitForCondition(() => onDone.mock.calls.length > 0)
expect(addProviderProfile).toHaveBeenCalled()
expect(addProviderProfile.mock.calls[0]?.[0]).toMatchObject({
name: 'Ollama',
baseUrl: 'http://localhost:11434/v1',
model: 'gemma4:31b-cloud',
})
expect(onDone).toHaveBeenCalledWith(
expect.objectContaining({
action: 'saved',
message: 'Provider configured: Ollama',
}),
)
await mounted.dispose()
})
test('ProviderManager first-run Codex OAuth switches the current session after login completes', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const onDone = mock(() => {})
const applySavedProfileToCurrentSession = mock(async () => null)
const persistCredentials = mock(() => {})
const addProviderProfile = mock((payload: {
provider: string
name: string
baseUrl: string
model: string
apiKey?: string
}) => ({
id: 'provider_codex_oauth',
provider: payload.provider,
name: payload.name,
baseUrl: payload.baseUrl,
model: payload.model,
apiKey: payload.apiKey,
}))
mockProviderManagerDependencies(
() => undefined,
async () => undefined,
{
addProviderProfile,
applySavedProfileToCurrentSession,
useCodexOAuthFlow: ({ onAuthenticated }) => {
React.useEffect(() => {
void onAuthenticated({
accessToken: 'oauth-access-token',
refreshToken: 'oauth-refresh-token',
accountId: 'acct_oauth',
}, persistCredentials)
}, [onAuthenticated])
return {
state: 'waiting',
authUrl: 'https://chatgpt.com/codex',
browserOpened: true,
}
},
},
)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager, {
mode: 'first-run',
onDone,
})
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Set up provider') && frame.includes('Codex OAuth'),
)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('\r')
await waitForCondition(() => onDone.mock.calls.length > 0)
expect(addProviderProfile).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'openai',
name: 'Codex OAuth',
baseUrl: 'https://chatgpt.com/backend-api/codex',
model: 'codexplan',
apiKey: '',
}),
expect.objectContaining({ makeActive: true }),
)
expect(applySavedProfileToCurrentSession).toHaveBeenCalled()
expect(persistCredentials).toHaveBeenCalledWith({
profileId: 'provider_codex_oauth',
})
expect(onDone).toHaveBeenCalledWith(
expect.objectContaining({
action: 'saved',
message:
'Codex OAuth configured. OpenClaude switched to it for this session.',
}),
)
await mounted.dispose()
})
test('ProviderManager first-run Codex OAuth reports next-startup fallback when session activation fails', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const onDone = mock(() => {})
const applySavedProfileToCurrentSession = mock(
async () => 'validation failed',
)
const persistCredentials = mock(() => {})
const addProviderProfile = mock((payload: {
provider: string
name: string
baseUrl: string
model: string
apiKey?: string
}) => ({
id: 'provider_codex_oauth',
provider: payload.provider,
name: payload.name,
baseUrl: payload.baseUrl,
model: payload.model,
apiKey: payload.apiKey,
}))
mockProviderManagerDependencies(
() => undefined,
async () => undefined,
{
addProviderProfile,
applySavedProfileToCurrentSession,
useCodexOAuthFlow: ({ onAuthenticated }) => {
React.useEffect(() => {
void onAuthenticated({
accessToken: 'oauth-access-token',
refreshToken: 'oauth-refresh-token',
accountId: 'acct_oauth',
}, persistCredentials)
}, [onAuthenticated])
return {
state: 'waiting',
authUrl: 'https://chatgpt.com/codex',
browserOpened: true,
}
},
},
)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager, {
mode: 'first-run',
onDone,
})
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Set up provider') && frame.includes('Codex OAuth'),
)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('\r')
await waitForCondition(() => onDone.mock.calls.length > 0)
expect(persistCredentials).toHaveBeenCalledWith({
profileId: 'provider_codex_oauth',
})
expect(onDone).toHaveBeenCalledWith(
expect.objectContaining({
action: 'saved',
message:
'Codex OAuth configured. Saved for next startup. Warning: validation failed.',
}),
)
await mounted.dispose()
})
test('ProviderManager does not hijack a manual Codex profile when OAuth credentials are not yet linked', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const onDone = mock(() => {})
const manualProfile = {
id: 'provider_manual_codex',
provider: 'openai',
name: 'Codex OAuth',
baseUrl: 'https://chatgpt.com/backend-api/codex',
model: 'gpt-5.4',
apiKey: 'manual-key',
}
const addProviderProfile = mock((payload: {
provider: string
name: string
baseUrl: string
model: string
apiKey?: string
}) => ({
id: 'provider_codex_oauth',
provider: payload.provider,
name: payload.name,
baseUrl: payload.baseUrl,
model: payload.model,
apiKey: payload.apiKey,
}))
const updateProviderProfile = mock(() => manualProfile)
const persistCredentials = mock(() => {})
mockProviderManagerDependencies(
() => undefined,
async () => undefined,
{
addProviderProfile,
getProviderProfiles: () => [manualProfile],
updateProviderProfile,
useCodexOAuthFlow: ({ onAuthenticated }) => {
const hasAuthenticated = React.useRef(false)
React.useEffect(() => {
if (hasAuthenticated.current) {
return
}
hasAuthenticated.current = true
void onAuthenticated({
accessToken: 'oauth-access-token',
refreshToken: 'oauth-refresh-token',
accountId: 'acct_oauth',
}, persistCredentials)
}, [onAuthenticated])
return {
state: 'waiting',
authUrl: 'https://chatgpt.com/codex',
browserOpened: true,
}
},
},
)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager, {
mode: 'first-run',
onDone,
})
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Set up provider') && frame.includes('Codex OAuth'),
)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('\r')
await waitForCondition(() => onDone.mock.calls.length > 0)
expect(addProviderProfile).toHaveBeenCalledTimes(1)
expect(updateProviderProfile).not.toHaveBeenCalled()
expect(persistCredentials).toHaveBeenCalledWith({
profileId: 'provider_codex_oauth',
})
await mounted.dispose()
})
test('ProviderManager keeps Codex OAuth as next-startup only when activating the session fails from the menu', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const codexProfile = {
id: 'provider_codex_oauth',
provider: 'openai',
name: 'Codex OAuth',
baseUrl: 'https://chatgpt.com/backend-api/codex',
model: 'codexplan',
apiKey: '',
}
const applySavedProfileToCurrentSession = mock(
async () => 'validation failed',
)
const setActiveProviderProfile = mock(() => codexProfile)
mockProviderManagerDependencies(
() => undefined,
async () => undefined,
{
applySavedProfileToCurrentSession,
getProviderProfiles: () => [codexProfile],
setActiveProviderProfile,
codexAsyncRead: async () => ({
accessToken: 'oauth-access-token',
refreshToken: 'oauth-refresh-token',
accountId: 'acct_oauth',
profileId: 'provider_codex_oauth',
}),
},
)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager)
await waitForFrameOutput(
mounted.getOutput,
frame =>
frame.includes('Provider manager') &&
frame.includes('Set active provider') &&
frame.includes('Log out Codex OAuth'),
)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Set active provider') && frame.includes('Codex OAuth'),
)
await Bun.sleep(25)
mounted.stdin.write('\r')
await waitForCondition(() => setActiveProviderProfile.mock.calls.length > 0)
await waitForCondition(
() => applySavedProfileToCurrentSession.mock.calls.length > 0,
)
await Bun.sleep(50)
const output = stripAnsi(extractLastFrame(mounted.getOutput()))
expect(output).toContain(
'Active provider: Codex OAuth. Saved for next startup. Warning: validation failed.',
)
expect(applySavedProfileToCurrentSession).toHaveBeenCalled()
expect(setActiveProviderProfile).toHaveBeenCalledWith('provider_codex_oauth')
await mounted.dispose()
})
test('ProviderManager resolves Codex OAuth state from async storage without sync reads in render flow', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const githubSyncRead = mock(() => undefined)
const githubAsyncRead = mock(async () => undefined)
const codexSyncRead = mock(() => {
throw new Error('sync codex credential read should not run in ProviderManager render flow')
})
const codexAsyncRead = mock(async () => ({
accessToken: 'codex-access-token',
refreshToken: 'codex-refresh-token',
}))
mockProviderManagerDependencies(githubSyncRead, githubAsyncRead, {
codexSyncRead,
codexAsyncRead,
})
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const output = await renderProviderManagerFrame(ProviderManager, {
waitForOutput: frame =>
frame.includes('Provider manager') &&
frame.includes('Log out Codex OAuth'),
})
expect(output).toContain('Provider manager')
expect(output).toContain('Log out Codex OAuth')
expect(codexSyncRead).not.toHaveBeenCalled()
expect(codexAsyncRead).toHaveBeenCalled()
})
test('ProviderManager hides Codex OAuth setup in bare mode', async () => {
process.env.CLAUDE_CODE_SIMPLE = '1'
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const githubSyncRead = mock(() => undefined)
const githubAsyncRead = mock(async () => undefined)
mockProviderManagerDependencies(githubSyncRead, githubAsyncRead)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const output = await renderProviderManagerFrame(ProviderManager, {
mode: 'first-run',
waitForOutput: frame =>
frame.includes('Set up provider') && frame.includes('OpenAI'),
})
expect(output).toContain('Set up provider')
expect(output).not.toContain('Codex OAuth')
})

View File

@@ -1,20 +1,8 @@
import figures from 'figures'
import * as React from 'react'
import { DEFAULT_CODEX_BASE_URL } from '../services/api/providerConfig.js'
import { Box, Text } from '../ink.js'
import { useKeybinding } from '../keybindings/useKeybinding.js'
import type { ProviderProfile } from '../utils/config.js'
import {
clearCodexCredentials,
readCodexCredentialsAsync,
} from '../utils/codexCredentials.js'
import { isBareMode, isEnvTruthy } from '../utils/envUtils.js'
import {
applySavedProfileToCurrentSession,
buildCodexOAuthProfileEnv,
clearPersistedCodexOAuthProfile,
createProfileFile,
} from '../utils/providerProfile.js'
import {
addProviderProfile,
applyActiveProviderProfileFromConfig,
@@ -34,22 +22,11 @@ import {
readGithubModelsToken,
readGithubModelsTokenAsync,
} from '../utils/githubModelsCredentials.js'
import {
hasLocalOllama,
listOllamaModels,
} from '../utils/providerDiscovery.js'
import {
rankOllamaModels,
recommendOllamaModel,
} from '../utils/providerRecommendation.js'
import { isEnvTruthy } from '../utils/envUtils.js'
import { updateSettingsForSource } from '../utils/settings/settings.js'
import {
type OptionWithDescription,
Select,
} from './CustomSelect/index.js'
import { Select } from './CustomSelect/index.js'
import { Pane } from './design-system/Pane.js'
import TextInput from './TextInput.js'
import { useCodexOAuthFlow } from './useCodexOAuthFlow.js'
export type ProviderManagerResult = {
action: 'saved' | 'cancelled'
@@ -65,8 +42,6 @@ type Props = {
type Screen =
| 'menu'
| 'select-preset'
| 'select-ollama-model'
| 'codex-oauth'
| 'form'
| 'select-active'
| 'select-edit'
@@ -76,16 +51,6 @@ type DraftField = 'name' | 'baseUrl' | 'model' | 'apiKey'
type ProviderDraft = Record<DraftField, string>
type OllamaSelectionState =
| { state: 'idle' }
| { state: 'loading' }
| {
state: 'ready'
options: OptionWithDescription<string>[]
defaultValue?: string
}
| { state: 'unavailable'; message: string }
const FORM_STEPS: Array<{
key: DraftField
label: string
@@ -124,8 +89,6 @@ const GITHUB_PROVIDER_ID = '__github_models__'
const GITHUB_PROVIDER_LABEL = 'GitHub Models'
const GITHUB_PROVIDER_DEFAULT_MODEL = 'github:copilot'
const GITHUB_PROVIDER_DEFAULT_BASE_URL = 'https://models.github.ai/inference'
const CODEX_OAUTH_PROVIDER_NAME = 'Codex OAuth'
const CODEX_OAUTH_PROVIDER_MODEL = 'codexplan'
type GithubCredentialSource = 'stored' | 'env' | 'none'
@@ -214,111 +177,6 @@ function getGithubProviderSummary(
return `github-models · ${GITHUB_PROVIDER_DEFAULT_BASE_URL} · ${getGithubProviderModel(processEnv)} · ${credentialSummary}${activeSuffix}`
}
function findCodexOAuthProfile(
profiles: ProviderProfile[],
profileId?: string,
): ProviderProfile | undefined {
if (!profileId) {
return undefined
}
return profiles.find(profile => profile.id === profileId)
}
function isCodexOAuthProfile(
profile: ProviderProfile | null | undefined,
profileId?: string,
): boolean {
return Boolean(profile && profileId && profile.id === profileId)
}
function CodexOAuthSetup({
onBack,
onConfigured,
}: {
onBack: () => void
onConfigured: (tokens: {
accessToken: string
refreshToken: string
accountId?: string
idToken?: string
apiKey?: string
}, persistCredentials: (options?: { profileId?: string }) => void) => void | Promise<void>
}): React.ReactNode {
const handleAuthenticated = React.useCallback(async (tokens: {
accessToken: string
refreshToken: string
accountId?: string
idToken?: string
apiKey?: string
}, persistCredentials: (options?: { profileId?: string }) => void) => {
await onConfigured(tokens, persistCredentials)
}, [onConfigured])
useKeybinding('confirm:no', onBack, [onBack])
const status = useCodexOAuthFlow({
onAuthenticated: handleAuthenticated,
})
if (status.state === 'error') {
return (
<Box flexDirection="column" gap={1}>
<Text color="error" bold>
Codex OAuth failed
</Text>
<Text>{status.message}</Text>
<Text dimColor>Press Enter or Esc to go back.</Text>
<Select
options={[
{
value: 'back',
label: 'Back',
description: 'Return to provider presets',
},
]}
onChange={onBack}
onCancel={onBack}
visibleOptionCount={1}
/>
</Box>
)
}
return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
Codex OAuth
</Text>
<Text>
Sign in with your ChatGPT account in the browser. OpenClaude will store
the resulting Codex credentials securely and switch this session to the
new Codex login when setup completes.
</Text>
{status.state === 'starting' ? (
<Text dimColor>Starting local callback and preparing your browser...</Text>
) : status.browserOpened === false ? (
<>
<Text color="warning">
Browser did not open automatically. Visit this URL to continue:
</Text>
<Text>{status.authUrl}</Text>
</>
) : status.browserOpened === true ? (
<>
<Text dimColor>
Browser opened. Finish the ChatGPT sign-in there and this setup will
complete automatically.
</Text>
<Text>{status.authUrl}</Text>
</>
) : (
<Text dimColor>Opening your browser...</Text>
)}
<Text dimColor>Press Esc to cancel and go back.</Text>
</Box>
)
}
export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const initialGithubCredentialSource = getGithubCredentialSourceFromEnv()
const initialIsGithubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
@@ -338,7 +196,6 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const [isGithubCredentialSourceResolved, setIsGithubCredentialSourceResolved] =
React.useState(() => initialHasGithubCredential || initialIsGithubActive)
const githubRefreshEpochRef = React.useRef(0)
const codexRefreshEpochRef = React.useRef(0)
const [screen, setScreen] = React.useState<Screen>(
mode === 'first-run' ? 'select-preset' : 'menu',
)
@@ -353,13 +210,6 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const [cursorOffset, setCursorOffset] = React.useState(0)
const [statusMessage, setStatusMessage] = React.useState<string | undefined>()
const [errorMessage, setErrorMessage] = React.useState<string | undefined>()
const [hasStoredCodexOAuthCredentials, setHasStoredCodexOAuthCredentials] =
React.useState(false)
const [storedCodexOAuthProfileId, setStoredCodexOAuthProfileId] =
React.useState<string | undefined>()
const [ollamaSelection, setOllamaSelection] = React.useState<OllamaSelectionState>({
state: 'idle',
})
const currentStep = FORM_STEPS[formStepIndex] ?? FORM_STEPS[0]
const currentStepKey = currentStep.key
@@ -394,102 +244,19 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
})()
}, [])
const refreshCodexOAuthCredentialState = React.useCallback((): void => {
if (isBareMode()) {
codexRefreshEpochRef.current += 1
setHasStoredCodexOAuthCredentials(false)
setStoredCodexOAuthProfileId(undefined)
return
}
const refreshEpoch = ++codexRefreshEpochRef.current
void (async () => {
const credentials = await readCodexCredentialsAsync()
if (refreshEpoch !== codexRefreshEpochRef.current) {
return
}
setHasStoredCodexOAuthCredentials(
Boolean(
credentials?.apiKey ||
credentials?.accessToken ||
credentials?.refreshToken ||
credentials?.idToken,
),
)
setStoredCodexOAuthProfileId(credentials?.profileId)
})()
}, [])
React.useEffect(() => {
refreshGithubProviderState()
refreshCodexOAuthCredentialState()
return () => {
githubRefreshEpochRef.current += 1
codexRefreshEpochRef.current += 1
}
}, [refreshCodexOAuthCredentialState, refreshGithubProviderState])
React.useEffect(() => {
if (screen !== 'select-ollama-model') {
return
}
let cancelled = false
setOllamaSelection({ state: 'loading' })
void (async () => {
const available = await hasLocalOllama(draft.baseUrl)
if (!available) {
if (!cancelled) {
setOllamaSelection({
state: 'unavailable',
message:
'Could not reach Ollama. Start Ollama first, or enter the endpoint manually.',
})
}
return
}
const models = await listOllamaModels(draft.baseUrl)
if (models.length === 0) {
if (!cancelled) {
setOllamaSelection({
state: 'unavailable',
message:
'Ollama is running, but no installed models were found. Pull a chat model such as qwen2.5-coder:7b or llama3.1:8b first, or enter details manually.',
})
}
return
}
const ranked = rankOllamaModels(models, 'balanced')
const recommended = recommendOllamaModel(models, 'balanced')
if (!cancelled) {
setOllamaSelection({
state: 'ready',
defaultValue: recommended?.name ?? ranked[0]?.name,
options: ranked.map(model => ({
label: model.name,
value: model.name,
description: model.summary,
})),
})
}
})()
return () => {
cancelled = true
}
}, [draft.baseUrl, screen])
}, [refreshGithubProviderState])
function refreshProfiles(): void {
const nextProfiles = getProviderProfiles()
setProfiles(nextProfiles)
setActiveProfileId(getActiveProviderProfile()?.id)
refreshGithubProviderState()
refreshCodexOAuthCredentialState()
}
function clearStartupProviderOverrideFromUserSettings(): string | null {
@@ -506,123 +273,6 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
return error ? error.message : null
}
function buildCodexOAuthActivationMessage(options: {
prefix: string
activationWarning: string | null
warnings: string[]
}): string {
if (options.activationWarning) {
return `${options.prefix}. Saved for next startup. Warning: ${options.warnings.join('; ')}.`
}
if (options.warnings.length > 0) {
return `${options.prefix}. OpenClaude switched to it for this session with warnings: ${options.warnings.join('; ')}.`
}
return `${options.prefix}. OpenClaude switched to it for this session.`
}
async function activateCodexOAuthSession(tokens?: {
accessToken: string
refreshToken?: string
accountId?: string
idToken?: string
}): Promise<string | null> {
const oauthEnv = buildCodexOAuthProfileEnv({
accessToken: tokens?.accessToken ?? '',
accountId: tokens?.accountId,
idToken: tokens?.idToken,
})
if (oauthEnv) {
return applySavedProfileToCurrentSession({
profileFile: createProfileFile('codex', oauthEnv),
})
}
const storedCredentials = await readCodexCredentialsAsync()
if (!storedCredentials) {
return 'stored Codex OAuth credentials could not be loaded'
}
const storedEnv = buildCodexOAuthProfileEnv({
accessToken: storedCredentials.accessToken,
accountId: storedCredentials.accountId,
idToken: storedCredentials.idToken,
})
if (!storedEnv) {
return 'stored Codex OAuth credentials are missing a ChatGPT account id'
}
return applySavedProfileToCurrentSession({
profileFile: createProfileFile('codex', storedEnv),
})
}
async function activateSelectedProvider(profileId: string): Promise<void> {
let providerLabel = 'provider'
try {
if (profileId === GITHUB_PROVIDER_ID) {
providerLabel = GITHUB_PROVIDER_LABEL
const githubError = activateGithubProvider()
if (githubError) {
setErrorMessage(`Could not activate GitHub provider: ${githubError}`)
setScreen('menu')
return
}
refreshProfiles()
setStatusMessage(`Active provider: ${GITHUB_PROVIDER_LABEL}`)
setScreen('menu')
return
}
const active = setActiveProviderProfile(profileId)
if (!active) {
setErrorMessage('Could not change active provider.')
setScreen('menu')
return
}
providerLabel = active.name
const settingsOverrideError =
clearStartupProviderOverrideFromUserSettings()
const isActiveCodexOAuth = isCodexOAuthProfile(
active,
storedCodexOAuthProfileId,
)
const activationWarning = isActiveCodexOAuth
? await activateCodexOAuthSession()
: null
refreshProfiles()
setStatusMessage(
isActiveCodexOAuth
? buildCodexOAuthActivationMessage({
prefix: `Active provider: ${active.name}`,
activationWarning,
warnings: [
activationWarning,
settingsOverrideError
? `could not clear startup provider override (${settingsOverrideError})`
: null,
].filter((warning): warning is string => Boolean(warning)),
})
: settingsOverrideError
? `Active provider: ${active.name}. Warning: could not clear startup provider override (${settingsOverrideError}).`
: `Active provider: ${active.name}`,
)
setScreen('menu')
} catch (error) {
refreshProfiles()
setStatusMessage(undefined)
const detail = error instanceof Error ? error.message : String(error)
setErrorMessage(`Could not finish activating ${providerLabel}: ${detail}`)
setScreen('menu')
}
}
function closeWithCancelled(message: string): void {
onDone({ action: 'cancelled', message })
}
@@ -728,13 +378,6 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
setFormStepIndex(0)
setCursorOffset(nextDraft.name.length)
setErrorMessage(undefined)
if (preset === 'ollama') {
setOllamaSelection({ state: 'loading' })
setScreen('select-ollama-model')
return
}
setScreen('form')
}
@@ -754,13 +397,13 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
setScreen('form')
}
function persistDraft(nextDraft: ProviderDraft = draft): void {
function persistDraft(): void {
const payload: ProviderProfileInput = {
provider: draftProvider,
name: nextDraft.name,
baseUrl: nextDraft.baseUrl,
model: nextDraft.model,
apiKey: nextDraft.apiKey,
name: draft.name,
baseUrl: draft.baseUrl,
model: draft.model,
apiKey: draft.apiKey,
}
const saved = editingProfileId
@@ -803,83 +446,6 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
setScreen('menu')
}
function renderOllamaSelection(): React.ReactNode {
if (ollamaSelection.state === 'loading' || ollamaSelection.state === 'idle') {
return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
Checking Ollama
</Text>
<Text dimColor>Looking for installed Ollama models...</Text>
</Box>
)
}
if (ollamaSelection.state === 'unavailable') {
return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
Ollama setup
</Text>
<Text dimColor>{ollamaSelection.message}</Text>
<Select
options={[
{
value: 'manual',
label: 'Enter manually',
description: 'Fill in the base URL and model yourself',
},
{
value: 'back',
label: 'Back',
description: 'Choose another provider preset',
},
]}
onChange={(value: string) => {
if (value === 'manual') {
setFormStepIndex(0)
setCursorOffset(draft.name.length)
setScreen('form')
return
}
setScreen('select-preset')
}}
onCancel={() => setScreen('select-preset')}
visibleOptionCount={2}
/>
</Box>
)
}
return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
Choose an Ollama model
</Text>
<Text dimColor>
Pick one of the installed Ollama models to save into a local provider
profile.
</Text>
<Select
options={ollamaSelection.options}
defaultValue={ollamaSelection.defaultValue}
defaultFocusValue={ollamaSelection.defaultValue}
inlineDescriptions
visibleOptionCount={Math.min(8, ollamaSelection.options.length)}
onChange={(value: string) => {
const nextDraft = {
...draft,
model: value,
}
setDraft(nextDraft)
persistDraft(nextDraft)
}}
onCancel={() => setScreen('select-preset')}
/>
</Box>
)
}
function handleFormSubmit(value: string): void {
const trimmed = value.trim()
@@ -904,7 +470,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
return
}
persistDraft(nextDraft)
persistDraft()
}
function handleBackFromForm(): void {
@@ -932,7 +498,6 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
})
function renderPresetSelection(): React.ReactNode {
const canUseCodexOAuth = !isBareMode()
const options = [
{
value: 'anthropic',
@@ -949,16 +514,6 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
label: 'OpenAI',
description: 'OpenAI API with API key',
},
...(canUseCodexOAuth
? [
{
value: 'codex-oauth',
label: 'Codex OAuth',
description:
'Sign in with ChatGPT in your browser and store Codex credentials securely',
},
]
: []),
{
value: 'moonshotai',
label: 'Moonshot AI',
@@ -1030,15 +585,11 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
</Text>
<Select
options={options}
onChange={(value: string) => {
onChange={value => {
if (value === 'skip') {
closeWithCancelled('Provider setup skipped')
return
}
if (value === 'codex-oauth') {
setScreen('codex-oauth')
return
}
startCreateFromPreset(value as ProviderPreset)
}}
onCancel={() => {
@@ -1048,7 +599,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
}
setScreen('menu')
}}
visibleOptionCount={Math.min(13, options.length)}
visibleOptionCount={Math.min(12, options.length)}
/>
</Box>
)
@@ -1125,15 +676,6 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
description: 'Remove a provider profile',
disabled: !hasSelectableProviders,
},
...(hasStoredCodexOAuthCredentials
? [
{
value: 'logout-codex-oauth',
label: 'Log out Codex OAuth',
description: 'Clear securely stored Codex OAuth credentials',
},
]
: []),
{
value: 'done',
label: 'Done',
@@ -1178,7 +720,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
</Box>
<Select
options={options}
onChange={(value: string) => {
onChange={value => {
setErrorMessage(undefined)
switch (value) {
case 'add':
@@ -1199,47 +741,6 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
setScreen('select-delete')
}
break
case 'logout-codex-oauth': {
const cleared = clearCodexCredentials()
if (!cleared.success) {
setErrorMessage(
cleared.warning ??
'Could not clear Codex OAuth credentials.',
)
break
}
setHasStoredCodexOAuthCredentials(false)
setStoredCodexOAuthProfileId(undefined)
const codexProfile = findCodexOAuthProfile(
getProviderProfiles(),
storedCodexOAuthProfileId,
)
let settingsOverrideError: string | null = null
if (codexProfile) {
const result = deleteProviderProfile(codexProfile.id)
if (!result.removed) {
setErrorMessage(
'Codex OAuth credentials were cleared, but the Codex profile could not be removed.',
)
refreshProfiles()
break
}
clearPersistedCodexOAuthProfile()
settingsOverrideError = result.activeProfileId
? clearStartupProviderOverrideFromUserSettings()
: null
}
refreshProfiles()
setStatusMessage(
settingsOverrideError
? `Codex OAuth logged out. Warning: could not clear startup provider override (${settingsOverrideError}).`
: 'Codex OAuth logged out.',
)
break
}
default:
closeWithCancelled('Provider manager closed')
break
@@ -1322,85 +823,6 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
case 'select-preset':
content = renderPresetSelection()
break
case 'select-ollama-model':
content = renderOllamaSelection()
break
case 'codex-oauth':
content = (
<CodexOAuthSetup
onBack={() => setScreen('select-preset')}
onConfigured={async (tokens, persistCredentials) => {
const payload: ProviderProfileInput = {
provider: 'openai',
name: CODEX_OAUTH_PROVIDER_NAME,
baseUrl: DEFAULT_CODEX_BASE_URL,
model: CODEX_OAUTH_PROVIDER_MODEL,
apiKey: '',
}
const existing = findCodexOAuthProfile(
getProviderProfiles(),
storedCodexOAuthProfileId,
)
const saved = existing
? updateProviderProfile(existing.id, payload)
: addProviderProfile(payload, { makeActive: true })
if (!saved) {
setErrorMessage(
'Codex OAuth login finished, but the provider profile could not be saved.',
)
setScreen('menu')
return
}
const active =
existing && activeProfileId !== saved.id
? setActiveProviderProfile(saved.id)
: saved
if (!active) {
setErrorMessage(
'Codex OAuth login finished, but the provider could not be set as the startup provider.',
)
setScreen('menu')
return
}
persistCredentials({ profileId: saved.id })
const settingsOverrideError =
clearStartupProviderOverrideFromUserSettings()
const activationWarning = await activateCodexOAuthSession(tokens)
setHasStoredCodexOAuthCredentials(true)
setStoredCodexOAuthProfileId(saved.id)
refreshProfiles()
const warnings = [
activationWarning,
settingsOverrideError
? `could not clear startup provider override (${settingsOverrideError})`
: null,
].filter((warning): warning is string => Boolean(warning))
const message = buildCodexOAuthActivationMessage({
prefix: 'Codex OAuth configured',
activationWarning,
warnings,
})
if (mode === 'first-run') {
onDone({
action: 'saved',
activeProfileId: active.id,
message,
})
return
}
setStatusMessage(message)
setErrorMessage(undefined)
setScreen('menu')
}}
/>
)
break
case 'form':
content = renderForm()
break
@@ -1409,9 +831,36 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
'Set active provider',
'No providers available. Add one first.',
profileId => {
void activateSelectedProvider(profileId)
if (profileId === GITHUB_PROVIDER_ID) {
const githubError = activateGithubProvider()
if (githubError) {
setErrorMessage(`Could not activate GitHub provider: ${githubError}`)
setScreen('menu')
return
}
refreshProfiles()
setStatusMessage(`Active provider: ${GITHUB_PROVIDER_LABEL}`)
setScreen('menu')
return
}
const active = setActiveProviderProfile(profileId)
if (!active) {
setErrorMessage('Could not change active provider.')
setScreen('menu')
return
}
const settingsOverrideError =
clearStartupProviderOverrideFromUserSettings()
refreshProfiles()
setStatusMessage(
settingsOverrideError
? `Active provider: ${active.name}. Warning: could not clear startup provider override (${settingsOverrideError}).`
: `Active provider: ${active.name}`,
)
setScreen('menu')
},
{ includeGithub: true },
{ includeGithub: true },
)
break
case 'select-edit':
@@ -1440,27 +889,10 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
return
}
const deletedCodexOAuthProfile =
findCodexOAuthProfile(
profiles,
storedCodexOAuthProfileId,
)?.id === profileId
const result = deleteProviderProfile(profileId)
if (!result.removed) {
setErrorMessage('Could not delete provider.')
} else {
if (deletedCodexOAuthProfile) {
const cleared = clearCodexCredentials()
if (!cleared.success) {
setErrorMessage(
cleared.warning ??
'Provider deleted, but Codex OAuth credentials could not be cleared.',
)
} else {
setStoredCodexOAuthProfileId(undefined)
}
clearPersistedCodexOAuthProfile()
}
const settingsOverrideError = result.activeProfileId
? clearStartupProviderOverrideFromUserSettings()
: null

View File

@@ -5,10 +5,8 @@
* Addresses: https://github.com/Gitlawb/openclaude/issues/55
*/
import { isLocalProviderUrl, resolveProviderRequest } from '../services/api/providerConfig.js'
import { isLocalProviderUrl } from '../services/api/providerConfig.js'
import { getLocalOpenAICompatibleProviderLabel } from '../utils/providerDiscovery.js'
import { getSettings_DEPRECATED } from '../utils/settings/settings.js'
import { parseUserSpecifiedModel } from '../utils/model/model.js'
declare const MACRO: { VERSION: string; DISPLAY_VERSION?: string }
@@ -87,7 +85,6 @@ function detectProvider(): { name: string; model: string; baseUrl: string; isLoc
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'
const useMistral = process.env.CLAUDE_CODE_USE_MISTRAL === '1' || process.env.CLAUDE_CODE_USE_MISTRAL === 'true'
if (useGemini) {
const model = process.env.GEMINI_MODEL || 'gemini-2.0-flash'
@@ -95,32 +92,19 @@ function detectProvider(): { name: string; model: string; baseUrl: string; isLoc
return { name: 'Google Gemini', model, baseUrl, isLocal: false }
}
if (useMistral) {
const model = process.env.MISTRAL_MODEL || 'devstral-latest'
const baseUrl = process.env.MISTRAL_BASE_URL || 'https://api.mistral.ai/v1'
return { name: 'Mistral', model, baseUrl, isLocal: false }
}
if (useGithub) {
const model = process.env.OPENAI_MODEL || 'github:copilot'
const baseUrl =
process.env.OPENAI_BASE_URL || 'https://api.githubcopilot.com'
return { name: 'GitHub Copilot', model, baseUrl, isLocal: false }
process.env.OPENAI_BASE_URL || 'https://models.github.ai/inference'
return { name: 'GitHub Models', model, baseUrl, isLocal: false }
}
if (useOpenAI) {
const rawModel = process.env.OPENAI_MODEL || 'gpt-4o'
const resolvedRequest = resolveProviderRequest({
model: rawModel,
baseUrl: process.env.OPENAI_BASE_URL,
})
const baseUrl = resolvedRequest.baseUrl
const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
const isLocal = isLocalProviderUrl(baseUrl)
let name = 'OpenAI'
// Override to Codex when resolved endpoint is Codex
if (resolvedRequest.transport === 'codex_responses' || baseUrl.includes('chatgpt.com/backend-api/codex')) {
name = 'Codex'
} else if (/deepseek/i.test(baseUrl) || /deepseek/i.test(rawModel)) name = 'DeepSeek'
if (/deepseek/i.test(baseUrl) || /deepseek/i.test(rawModel)) name = 'DeepSeek'
else if (/openrouter/i.test(baseUrl)) name = 'OpenRouter'
else if (/together/i.test(baseUrl)) name = 'Together AI'
else if (/groq/i.test(baseUrl)) name = 'Groq'
@@ -130,19 +114,34 @@ function detectProvider(): { name: string; model: string; baseUrl: string; isLoc
else if (isLocal) name = getLocalOpenAICompatibleProviderLabel(baseUrl)
// Resolve model alias to actual model name + reasoning effort
let displayModel = resolvedRequest.resolvedModel
if (resolvedRequest.reasoning?.effort) {
displayModel = `${displayModel} (${resolvedRequest.reasoning.effort})`
let displayModel = rawModel
const codexAliases: Record<string, { model: string; reasoningEffort?: string }> = {
codexplan: { model: 'gpt-5.4', reasoningEffort: 'high' },
'gpt-5.4': { model: 'gpt-5.4', reasoningEffort: 'high' },
'gpt-5.3-codex': { model: 'gpt-5.3-codex', reasoningEffort: 'high' },
'gpt-5.3-codex-spark': { model: 'gpt-5.3-codex-spark' },
codexspark: { model: 'gpt-5.3-codex-spark' },
'gpt-5.2-codex': { model: 'gpt-5.2-codex', reasoningEffort: 'high' },
'gpt-5.1-codex-max': { model: 'gpt-5.1-codex-max', reasoningEffort: 'high' },
'gpt-5.1-codex-mini': { model: 'gpt-5.1-codex-mini' },
'gpt-5.4-mini': { model: 'gpt-5.4-mini', reasoningEffort: 'medium' },
'gpt-5.2': { model: 'gpt-5.2', reasoningEffort: 'medium' },
}
const alias = rawModel.toLowerCase()
if (alias in codexAliases) {
const resolved = codexAliases[alias]
displayModel = resolved.model
if (resolved.reasoningEffort) {
displayModel = `${displayModel} (${resolved.reasoningEffort})`
}
}
return { name, model: displayModel, baseUrl, isLocal }
}
// Default: Anthropic - check settings.model first, then env vars
const settings = getSettings_DEPRECATED() || {}
const modelSetting = settings.model || process.env.ANTHROPIC_MODEL || process.env.CLAUDE_MODEL || 'claude-sonnet-4-6'
const resolvedModel = parseUserSpecifiedModel(modelSetting)
return { name: 'Anthropic', model: resolvedModel, baseUrl: 'https://api.anthropic.com', isLocal: false }
// Default: Anthropic
const model = process.env.ANTHROPIC_MODEL || process.env.CLAUDE_MODEL || 'claude-sonnet-4-6'
return { name: 'Anthropic', model, baseUrl: 'https://api.anthropic.com', isLocal: false }
}
// ─── Box drawing ──────────────────────────────────────────────────────────────

View File

@@ -1,231 +0,0 @@
import { PassThrough } from 'node:stream'
import { expect, test } from 'bun:test'
import React from 'react'
import stripAnsi from 'strip-ansi'
import { createRoot } from '../ink.js'
import { AppStateProvider } from '../state/AppState.js'
import TextInput from './TextInput.js'
import VimTextInput from './VimTextInput.js'
const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l'
function extractLastFrame(output: string): string {
let lastFrame: string | null = null
let cursor = 0
while (cursor < output.length) {
const start = output.indexOf(SYNC_START, cursor)
if (start === -1) {
break
}
const contentStart = start + SYNC_START.length
const end = output.indexOf(SYNC_END, contentStart)
if (end === -1) {
break
}
const frame = output.slice(contentStart, end)
if (frame.trim().length > 0) {
lastFrame = frame
}
cursor = end + SYNC_END.length
}
return lastFrame ?? output
}
function createTestStreams(): {
stdout: PassThrough
stdin: PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
getOutput: () => string
} {
let output = ''
const stdout = new PassThrough()
const stdin = new PassThrough() as PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
stdin.isTTY = true
stdin.setRawMode = () => {}
stdin.ref = () => {}
stdin.unref = () => {}
;(stdout as unknown as { columns: number }).columns = 120
stdout.on('data', chunk => {
output += chunk.toString()
})
return {
stdout,
stdin,
getOutput: () => output,
}
}
function DelayedControlledTextInput(): React.ReactNode {
const [value, setValue] = React.useState('')
const [cursorOffset, setCursorOffset] = React.useState(0)
const valueTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const offsetTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
React.useEffect(() => {
return () => {
if (valueTimerRef.current) {
clearTimeout(valueTimerRef.current)
}
if (offsetTimerRef.current) {
clearTimeout(offsetTimerRef.current)
}
}
}, [])
return (
<AppStateProvider>
<TextInput
value={value}
onChange={nextValue => {
if (valueTimerRef.current) {
clearTimeout(valueTimerRef.current)
}
valueTimerRef.current = setTimeout(() => {
setValue(nextValue)
}, 200)
}}
onSubmit={() => {}}
placeholder="Type here..."
columns={60}
cursorOffset={cursorOffset}
onChangeCursorOffset={nextOffset => {
if (offsetTimerRef.current) {
clearTimeout(offsetTimerRef.current)
}
offsetTimerRef.current = setTimeout(() => {
setCursorOffset(nextOffset)
}, 200)
}}
focus
showCursor
multiline
/>
</AppStateProvider>
)
}
function DelayedControlledVimTextInput(): React.ReactNode {
const [value, setValue] = React.useState('')
const [cursorOffset, setCursorOffset] = React.useState(0)
const valueTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const offsetTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
React.useEffect(() => {
return () => {
if (valueTimerRef.current) {
clearTimeout(valueTimerRef.current)
}
if (offsetTimerRef.current) {
clearTimeout(offsetTimerRef.current)
}
}
}, [])
return (
<AppStateProvider>
<VimTextInput
value={value}
onChange={nextValue => {
if (valueTimerRef.current) {
clearTimeout(valueTimerRef.current)
}
valueTimerRef.current = setTimeout(() => {
setValue(nextValue)
}, 200)
}}
onSubmit={() => {}}
placeholder="Type here..."
columns={60}
cursorOffset={cursorOffset}
onChangeCursorOffset={nextOffset => {
if (offsetTimerRef.current) {
clearTimeout(offsetTimerRef.current)
}
offsetTimerRef.current = setTimeout(() => {
setCursorOffset(nextOffset)
}, 200)
}}
initialMode="INSERT"
focus
showCursor
multiline
/>
</AppStateProvider>
)
}
test('TextInput renders typed characters before delayed parent value commits', async () => {
const { stdout, stdin, getOutput } = createTestStreams()
const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(<DelayedControlledTextInput />)
await Bun.sleep(50)
stdin.write('a')
await Bun.sleep(25)
stdin.write('b')
await Bun.sleep(25)
const output = stripAnsi(extractLastFrame(getOutput()))
root.unmount()
stdin.end()
stdout.end()
await Bun.sleep(25)
expect(output).toContain('ab')
expect(output).not.toContain('Type here...')
})
test('VimTextInput preserves rapid typed characters before delayed parent value commits', async () => {
const { stdout, stdin, getOutput } = createTestStreams()
const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(<DelayedControlledVimTextInput />)
await Bun.sleep(50)
stdin.write('a')
await Bun.sleep(25)
stdin.write('s')
await Bun.sleep(25)
stdin.write('d')
await Bun.sleep(25)
stdin.write('f')
await Bun.sleep(25)
const output = stripAnsi(extractLastFrame(getOutput()))
root.unmount()
stdin.end()
stdout.end()
await Bun.sleep(25)
expect(output).toContain('asdf')
expect(output).not.toContain('Type here...')
})

View File

@@ -1,161 +1,113 @@
import { PassThrough } from 'node:stream'
import { describe, expect, it, mock } from 'bun:test'
import { afterEach, expect, mock, test } from 'bun:test'
import React from 'react'
import stripAnsi from 'strip-ansi'
// We can't fully render ThemePicker due to complex dependencies
// But we can test the theme options generation logic
describe('ThemePicker', () => {
describe('theme options', () => {
it('generates correct theme options without AUTO_THEME feature flag', () => {
// Since we can't easily mock bun:bundle, test the options structure
// The real test would require integration testing
const expectedOptions = [
{ label: "Dark mode", value: "dark" },
{ label: "Light mode", value: "light" },
{ label: "Dark mode (colorblind-friendly)", value: "dark-daltonized" },
{ label: "Light mode (colorblind-friendly)", value: "light-daltonized" },
{ label: "Dark mode (ANSI colors only)", value: "dark-ansi" },
{ label: "Light mode (ANSI colors only)", value: "light-ansi" },
]
expect(expectedOptions.length).toBe(6)
})
import { createRoot, Text, useTheme } from '../ink.js'
import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'
import { AppStateProvider } from '../state/AppState.js'
import { ThemeProvider } from './design-system/ThemeProvider.js'
mock.module('./StructuredDiff.js', () => ({
StructuredDiff: function StructuredDiffPreview(): React.ReactNode {
const [theme] = useTheme()
return <Text>{`Preview theme: ${theme}`}</Text>
},
}))
mock.module('./StructuredDiff/colorDiff.js', () => ({
getColorModuleUnavailableReason: () => 'env',
getSyntaxTheme: () => null,
}))
const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l'
function extractLastFrame(output: string): string {
let lastFrame: string | null = null
let cursor = 0
while (cursor < output.length) {
const start = output.indexOf(SYNC_START, cursor)
if (start === -1) {
break
}
const contentStart = start + SYNC_START.length
const end = output.indexOf(SYNC_END, contentStart)
if (end === -1) {
break
}
const frame = output.slice(contentStart, end)
if (frame.trim().length > 0) {
lastFrame = frame
}
cursor = end + SYNC_END.length
}
return lastFrame ?? output
}
function createTestStreams(): {
stdout: PassThrough
stdin: PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
getOutput: () => string
} {
let output = ''
const stdout = new PassThrough()
const stdin = new PassThrough() as PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
stdin.isTTY = true
stdin.setRawMode = () => {}
stdin.ref = () => {}
stdin.unref = () => {}
;(stdout as unknown as { columns: number }).columns = 120
stdout.on('data', chunk => {
output += chunk.toString()
it('includes auto theme when AUTO_THEME feature is enabled', () => {
// Test the structure when auto is present
const optionsWithAuto = [
{ label: "Auto (match terminal)", value: "auto" },
{ label: "Dark mode", value: "dark" },
]
expect(optionsWithAuto[0].value).toBe('auto')
})
})
return {
stdout,
stdin,
getOutput: () => output,
}
}
async function waitForCondition(
predicate: () => boolean,
timeoutMs = 2000,
): Promise<void> {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) {
return
}
await Bun.sleep(10)
}
throw new Error('Timed out waiting for ThemePicker test condition')
}
async function waitForFrame(
getOutput: () => string,
predicate: (frame: string) => boolean,
): Promise<string> {
let frame = ''
await waitForCondition(() => {
frame = stripAnsi(extractLastFrame(getOutput()))
return predicate(frame)
describe('handleRowFocus callback', () => {
it('setPreviewTheme is called with theme setting', () => {
const setPreviewTheme = mock()
const handleRowFocus = (setting: string) => setPreviewTheme(setting)
handleRowFocus('dark')
expect(setPreviewTheme).toHaveBeenCalledWith('dark')
})
})
return frame
}
describe('handleSelect callback', () => {
it('calls savePreview and onThemeSelect', () => {
const savePreview = mock()
const onThemeSelect = mock()
const handleSelect = (setting: string) => {
savePreview()
onThemeSelect(setting)
}
handleSelect('light')
expect(savePreview).toHaveBeenCalled()
expect(onThemeSelect).toHaveBeenCalledWith('light')
})
})
afterEach(() => {
mock.restore()
})
test('updates the preview when keyboard focus moves to another theme', async () => {
const { ThemePicker } = await import('./ThemePicker.js')
const { stdout, stdin, getOutput } = createTestStreams()
const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(
<AppStateProvider>
<KeybindingSetup>
<ThemeProvider initialState="dark">
<ThemePicker onThemeSelect={() => {}} />
</ThemeProvider>
</KeybindingSetup>
</AppStateProvider>,
)
try {
const initialFrame = await waitForFrame(
getOutput,
frame => frame.includes('Preview theme: dark'),
)
expect(initialFrame).toContain('Preview theme: dark')
stdin.write('j')
const updatedFrame = await waitForFrame(
getOutput,
frame => frame.includes('Preview theme: light'),
)
expect(updatedFrame).toContain('Preview theme: light')
} finally {
root.unmount()
stdin.end()
stdout.end()
await Bun.sleep(0)
}
describe('handleCancel callback', () => {
it('calls cancelPreview and gracefulShutdown when not skipExitHandling', () => {
const cancelPreview = mock()
const gracefulShutdown = mock()
const handleCancel = (skipExitHandling: boolean, onCancelProp?: () => void) => {
cancelPreview()
if (skipExitHandling) {
onCancelProp?.()
} else {
gracefulShutdown(0)
}
}
handleCancel(false)
expect(cancelPreview).toHaveBeenCalled()
expect(gracefulShutdown).toHaveBeenCalledWith(0)
})
it('calls onCancelProp when skipExitHandling is true', () => {
const cancelPreview = mock()
const onCancelProp = mock()
const handleCancel = (skipExitHandling: boolean, onCancelProp?: () => void) => {
cancelPreview()
if (skipExitHandling) {
onCancelProp?.()
}
}
handleCancel(true, onCancelProp)
expect(cancelPreview).toHaveBeenCalled()
expect(onCancelProp).toHaveBeenCalled()
})
})
describe('syntax hint logic', () => {
it('shows disabled hint when syntax highlighting is disabled', () => {
const syntaxHighlightingDisabled = true
const syntaxToggleShortcut = 'Ctrl+T'
const hint = syntaxHighlightingDisabled
? `Syntax highlighting disabled (${syntaxToggleShortcut} to enable)`
: `Syntax highlighting enabled (${syntaxToggleShortcut} to disable)`
expect(hint).toContain('disabled')
})
it('shows enabled hint when syntax highlighting is active', () => {
const syntaxHighlightingDisabled = false
const syntaxToggleShortcut = 'Ctrl+T'
const hint = !syntaxHighlightingDisabled
? `Syntax highlighting enabled (${syntaxToggleShortcut} to disable)`
: `Syntax highlighting disabled (${syntaxToggleShortcut} to enable)`
expect(hint).toContain('enabled')
})
})
})

View File

@@ -68,11 +68,11 @@ When a user describes what they want an agent to do, you will:
assistant: "Now let me use the test-runner agent to run the tests"
</example>
- <example>
Context: User is creating an agent for Claude Code product questions.
user: "How do I configure Claude Code hooks?"
assistant: "I'm going to use the ${AGENT_TOOL_NAME} tool to launch the claude-code-guide agent to answer the question"
Context: User is creating an agent to respond to the word "hello" with a friendly jok.
user: "Hello"
assistant: "I'm going to use the ${AGENT_TOOL_NAME} tool to launch the greeting-responder agent to respond with a friendly joke"
<commentary>
Since the user is asking how to use Claude Code, use the claude-code-guide agent.
Since the user is greeting, use the greeting-responder agent to respond with a friendly joke.
</commentary>
</example>
- If the user mentioned or implied that the agent should be used proactively, you should include examples of this.

View File

@@ -2,7 +2,7 @@ import { c as _c } from "react-compiler-runtime";
import { feature } from 'bun:bundle';
import chalk from 'chalk';
import { mkdir } from 'fs/promises';
import { basename, join } from 'path';
import { join } from 'path';
import * as React from 'react';
import { use, useEffect, useState } from 'react';
import { getOriginalCwd } from '../../bootstrap/state.js';
@@ -24,7 +24,6 @@ import { projectIsInGitRepo } from '../../utils/memory/versions.js';
import { updateSettingsForSource } from '../../utils/settings/settings.js';
import { Select } from '../CustomSelect/index.js';
import { ListItem } from '../design-system/ListItem.js';
import { getProjectMemoryPathForSelector } from './memoryFileSelectorPaths.js';
/* eslint-disable @typescript-eslint/no-require-imports */
const teamMemPaths = feature('TEAMMEM') ? require('../../memdir/teamMemPaths.js') as typeof import('../../memdir/teamMemPaths.js') : null;
@@ -49,10 +48,8 @@ export function MemoryFileSelector(t0) {
onCancel
} = t0;
const existingMemoryFiles = use(getMemoryFiles());
const originalCwd = getOriginalCwd();
const userMemoryPath = join(getClaudeConfigHomeDir(), "CLAUDE.md");
const projectMemoryPath = getProjectMemoryPathForSelector(existingMemoryFiles, originalCwd);
const projectMemoryFileName = basename(projectMemoryPath);
const projectMemoryPath = join(getOriginalCwd(), "CLAUDE.md");
const hasUserMemory = existingMemoryFiles.some(f => f.path === userMemoryPath);
const hasProjectMemory = existingMemoryFiles.some(f_0 => f_0.path === projectMemoryPath);
const allMemoryFiles = [...existingMemoryFiles.filter(_temp).map(_temp2), ...(hasUserMemory ? [] : [{
@@ -88,12 +85,12 @@ export function MemoryFileSelector(t0) {
}
}
let description;
const isGit = projectIsInGitRepo(originalCwd);
const isGit = projectIsInGitRepo(getOriginalCwd());
if (file.type === "User" && !file.isNested) {
description = "Saved in ~/.claude/CLAUDE.md";
} else {
if (file.type === "Project" && !file.isNested && file.path === projectMemoryPath) {
description = `${isGit ? "Checked in at" : "Saved in"} ./${projectMemoryFileName}`;
description = `${isGit ? "Checked in at" : "Saved in"} ./CLAUDE.md`;
} else {
if (file.parent) {
description = "@-imported";

View File

@@ -1,69 +0,0 @@
import { describe, expect, test } from 'bun:test'
import { join } from 'node:path'
import type { MemoryFileInfo } from '../../utils/claudemd.js'
import { getProjectMemoryPathForSelector } from './memoryFileSelectorPaths.js'
function projectFile(path: string): MemoryFileInfo {
return {
path,
type: 'Project',
content: '',
}
}
describe('getProjectMemoryPathForSelector', () => {
test('uses the loaded repo-level AGENTS.md from a nested cwd', () => {
const repoDir = '/repo'
const nestedDir = join(repoDir, 'packages', 'app')
expect(
getProjectMemoryPathForSelector(
[projectFile(join(repoDir, 'AGENTS.md'))],
nestedDir,
),
).toBe(join(repoDir, 'AGENTS.md'))
})
test('uses the loaded repo-level CLAUDE.md fallback from a nested cwd', () => {
const repoDir = '/repo'
const nestedDir = join(repoDir, 'packages', 'app')
expect(
getProjectMemoryPathForSelector(
[projectFile(join(repoDir, 'CLAUDE.md'))],
nestedDir,
),
).toBe(join(repoDir, 'CLAUDE.md'))
})
test('prefers the closest loaded ancestor instruction file', () => {
const repoDir = '/repo'
const nestedProjectDir = join(repoDir, 'packages', 'app')
expect(
getProjectMemoryPathForSelector(
[
projectFile(join(repoDir, 'AGENTS.md')),
projectFile(join(nestedProjectDir, 'CLAUDE.md')),
],
join(nestedProjectDir, 'src'),
),
).toBe(join(nestedProjectDir, 'CLAUDE.md'))
})
test('defaults to a new AGENTS.md in the current cwd when no project file is loaded', () => {
expect(getProjectMemoryPathForSelector([], '/repo/packages/app')).toBe(
'/repo/packages/app/AGENTS.md',
)
})
test('ignores loaded project instruction files outside the current cwd ancestry', () => {
expect(
getProjectMemoryPathForSelector(
[projectFile('/other-worktree/AGENTS.md')],
'/repo/packages/app',
),
).toBe('/repo/packages/app/AGENTS.md')
})
})

View File

@@ -1,34 +0,0 @@
import { basename, join } from 'path'
import type { MemoryFileInfo } from '../../utils/claudemd.js'
import {
findProjectInstructionFilePathInAncestors,
isProjectInstructionFileName,
PRIMARY_PROJECT_INSTRUCTION_FILE,
} from '../../utils/projectInstructions.js'
function isLoadedProjectInstructionFile(file: MemoryFileInfo): boolean {
return (
file.type === 'Project' &&
file.parent === undefined &&
isProjectInstructionFileName(basename(file.path))
)
}
export function getProjectMemoryPathForSelector(
existingMemoryFiles: MemoryFileInfo[],
cwd: string,
): string {
const loadedProjectInstructionPaths = new Set(
existingMemoryFiles
.filter(isLoadedProjectInstructionFile)
.map(file => file.path),
)
return (
findProjectInstructionFilePathInAncestors(
cwd,
path => loadedProjectInstructionPaths.has(path),
) ?? join(cwd, PRIMARY_PROJECT_INSTRUCTION_FILE)
)
}

View File

@@ -1,173 +0,0 @@
import React from 'react'
import { getOriginalCwd } from '../../../bootstrap/state.js'
import { Box, Text } from '../../../ink.js'
import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js'
import { env } from '../../../utils/env.js'
import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js'
import { usePermissionRequestLogging } from '../hooks.js'
import { PermissionDialog } from '../PermissionDialog.js'
import {
PermissionPrompt,
type PermissionPromptOption,
} from '../PermissionPrompt.js'
import type { PermissionRequestProps } from '../PermissionRequest.js'
import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js'
import { logUnaryPermissionEvent } from '../utils.js'
type OptionValue = 'yes' | 'yes-dont-ask-again' | 'no'
export function MonitorPermissionRequest({
toolUseConfirm,
onDone,
onReject,
workerBadge,
}: PermissionRequestProps) {
const { command, description } = toolUseConfirm.input as {
command?: string
description?: string
}
usePermissionRequestLogging(toolUseConfirm, {
completion_type: 'tool_use_single',
language_name: 'none',
})
const handleSelect = (
value: OptionValue,
feedback?: string,
) => {
switch (value) {
case 'yes': {
logUnaryPermissionEvent({
completion_type: 'tool_use_single',
event: 'accept',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id,
platform: env.platform,
},
})
toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback)
onDone()
break
}
case 'yes-dont-ask-again': {
logUnaryPermissionEvent({
completion_type: 'tool_use_single',
event: 'accept',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id,
platform: env.platform,
},
})
// Save the rule under 'Bash' toolName because checkPermissions
// delegates to bashToolHasPermission which matches rules against
// BashTool. Using 'Monitor' here would create a rule that's never
// checked. Command-specific prefix (like BashTool's shellRuleMatching).
const cmdForRule = command?.trim() || ''
const prefix = cmdForRule.split(/\s+/).slice(0, 2).join(' ')
toolUseConfirm.onAllow(toolUseConfirm.input, prefix ? [
{
type: 'addRules',
rules: [{ toolName: 'Bash', ruleContent: `${prefix}:*` }],
behavior: 'allow',
destination: 'localSettings',
},
] : [])
onDone()
break
}
case 'no': {
logUnaryPermissionEvent({
completion_type: 'tool_use_single',
event: 'reject',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id,
platform: env.platform,
},
})
toolUseConfirm.onReject(feedback)
onReject()
onDone()
break
}
}
}
const handleCancel = () => {
logUnaryPermissionEvent({
completion_type: 'tool_use_single',
event: 'reject',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id,
platform: env.platform,
},
})
toolUseConfirm.onReject()
onReject()
onDone()
}
const showAlwaysAllow = shouldShowAlwaysAllowOptions()
const originalCwd = getOriginalCwd()
const options: PermissionPromptOption<OptionValue>[] = [
{
label: 'Yes',
value: 'yes',
feedbackConfig: { type: 'accept' },
},
]
if (showAlwaysAllow) {
options.push({
label: (
<Text>
Yes, and don&apos;t ask again for{' '}
<Text bold>Monitor</Text> commands in{' '}
<Text bold>{originalCwd}</Text>
</Text>
),
value: 'yes-dont-ask-again',
})
}
options.push({
label: 'No',
value: 'no',
feedbackConfig: { type: 'reject' },
})
const toolAnalyticsContext = {
toolName: sanitizeToolNameForAnalytics(toolUseConfirm.tool.name),
isMcp: toolUseConfirm.tool.isMcp ?? false,
}
return (
<PermissionDialog title="Monitor" workerBadge={workerBadge}>
<Box flexDirection="column" paddingX={2} paddingY={1}>
<Text>
Monitor({command ?? ''})
</Text>
{description ? (
<Text dimColor>{description}</Text>
) : null}
</Box>
<Box flexDirection="column">
<PermissionRuleExplanation
permissionResult={toolUseConfirm.permissionResult}
toolType="tool"
/>
<PermissionPrompt
options={options}
onSelect={handleSelect}
onCancel={handleCancel}
toolAnalyticsContext={toolAnalyticsContext}
/>
</Box>
</PermissionDialog>
)
}

View File

@@ -32,7 +32,7 @@ export function optionForPermissionSaveDestination(saveDestination: EditableSett
case 'userSettings':
return {
label: 'User settings',
description: `Saved in ~/.openclaude/settings.json`,
description: `Saved in at ~/.claude/settings.json`,
value: saveDestination
};
}

View File

@@ -1,220 +0,0 @@
import { PassThrough } from 'node:stream'
import { afterEach, expect, mock, test } from 'bun:test'
import React from 'react'
import { createRoot, Text } from '../ink.js'
const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l'
function createTestStreams(): {
stdout: PassThrough
stdin: PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
getOutput: () => string
} {
let output = ''
const stdout = new PassThrough()
const stdin = new PassThrough() as PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
stdin.isTTY = true
stdin.setRawMode = () => {}
stdin.ref = () => {}
stdin.unref = () => {}
;(stdout as unknown as { columns: number }).columns = 120
stdout.on('data', chunk => {
output += chunk.toString()
})
return {
stdout,
stdin,
getOutput: () => output,
}
}
async function waitForCondition(
predicate: () => boolean,
options?: { timeoutMs?: number; intervalMs?: number },
): Promise<void> {
const timeoutMs = options?.timeoutMs ?? 5000
const intervalMs = options?.intervalMs ?? 10
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) {
return
}
await Bun.sleep(intervalMs)
}
throw new Error('Timed out waiting for useCodexOAuthFlow test condition')
}
function extractLastFrame(output: string): string {
let lastFrame: string | null = null
let cursor = 0
while (cursor < output.length) {
const start = output.indexOf(SYNC_START, cursor)
if (start === -1) break
const contentStart = start + SYNC_START.length
const end = output.indexOf(SYNC_END, contentStart)
if (end === -1) break
const frame = output.slice(contentStart, end)
if (frame.trim().length > 0) {
lastFrame = frame
}
cursor = end + SYNC_END.length
}
return lastFrame ?? output
}
const TOKENS = {
accessToken: 'oauth-access-token',
refreshToken: 'oauth-refresh-token',
accountId: 'acct_oauth',
idToken: 'oauth-id-token',
apiKey: 'oauth-api-key',
}
afterEach(() => {
mock.restore()
})
test('does not persist credentials when downstream setup rejects', async () => {
const saveCodexCredentials = mock(() => ({ success: true }))
const cleanup = mock(() => {})
const onAuthenticated = mock(async () => {
throw new Error('profile save failed')
})
const deps = {
createOAuthService: () => ({
async startOAuthFlow(
onAuthorizationUrl: (authUrl: string) => void | Promise<void>,
) {
await onAuthorizationUrl('https://chatgpt.com/codex')
return TOKENS
},
cleanup,
}),
openBrowser: async () => true,
saveCodexCredentials,
isBareMode: () => false,
}
const { useCodexOAuthFlow } = await import(
`./useCodexOAuthFlow.js?real-reject-${Date.now()}-${Math.random()}`
)
function Harness(): React.ReactNode {
const handleAuthenticated = React.useCallback(onAuthenticated, [onAuthenticated])
const status = useCodexOAuthFlow({
onAuthenticated: handleAuthenticated,
deps,
})
return <Text>{status.state === 'error' ? status.message : status.state}</Text>
}
const streams = createTestStreams()
const root = await createRoot({
stdout: streams.stdout as unknown as NodeJS.WriteStream,
stdin: streams.stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(<Harness />)
try {
await waitForCondition(() => onAuthenticated.mock.calls.length === 1)
await Bun.sleep(0)
await Bun.sleep(0)
expect(onAuthenticated).toHaveBeenCalled()
expect(saveCodexCredentials).not.toHaveBeenCalled()
} finally {
root.unmount()
streams.stdin.end()
streams.stdout.end()
await Bun.sleep(0)
}
})
test('persists credentials with profile linkage after downstream setup succeeds', async () => {
const saveCodexCredentials = mock(() => ({ success: true }))
const onAuthenticated = mock(
async (
_tokens: typeof TOKENS,
persistCredentials: (options?: { profileId?: string }) => void,
) => {
persistCredentials({ profileId: 'profile_codex_oauth' })
},
)
const cleanup = mock(() => {})
const deps = {
createOAuthService: () => ({
async startOAuthFlow(
onAuthorizationUrl: (authUrl: string) => void | Promise<void>,
) {
await onAuthorizationUrl('https://chatgpt.com/codex')
return TOKENS
},
cleanup,
}),
openBrowser: async () => true,
saveCodexCredentials,
isBareMode: () => false,
}
const { useCodexOAuthFlow } = await import(
`./useCodexOAuthFlow.js?real-persist-${Date.now()}-${Math.random()}`
)
function Harness(): React.ReactNode {
const handleAuthenticated = React.useCallback(onAuthenticated, [onAuthenticated])
useCodexOAuthFlow({
onAuthenticated: handleAuthenticated,
deps,
})
return <Text>waiting</Text>
}
const streams = createTestStreams()
const root = await createRoot({
stdout: streams.stdout as unknown as NodeJS.WriteStream,
stdin: streams.stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(<Harness />)
try {
await waitForCondition(() => onAuthenticated.mock.calls.length === 1)
await waitForCondition(() => saveCodexCredentials.mock.calls.length === 1)
expect(onAuthenticated).toHaveBeenCalled()
expect(saveCodexCredentials).toHaveBeenCalledWith({
apiKey: TOKENS.apiKey,
accessToken: TOKENS.accessToken,
refreshToken: TOKENS.refreshToken,
idToken: TOKENS.idToken,
accountId: TOKENS.accountId,
profileId: 'profile_codex_oauth',
})
} finally {
root.unmount()
streams.stdin.end()
streams.stdout.end()
await Bun.sleep(0)
}
})

View File

@@ -1,134 +0,0 @@
import * as React from 'react'
import {
CodexOAuthService,
type CodexOAuthTokens,
} from '../services/api/codexOAuth.js'
import { openBrowser } from '../utils/browser.js'
import { saveCodexCredentials } from '../utils/codexCredentials.js'
import { isBareMode } from '../utils/envUtils.js'
export type CodexOAuthFlowStatus =
| { state: 'starting' }
| {
state: 'waiting'
authUrl: string
browserOpened: boolean | null
}
| {
state: 'error'
message: string
}
type PersistCodexOAuthCredentials = (options?: {
profileId?: string
}) => void
type CodexOAuthFlowDependencies = {
createOAuthService?: () => Pick<
CodexOAuthService,
'startOAuthFlow' | 'cleanup'
>
openBrowser?: typeof openBrowser
saveCodexCredentials?: typeof saveCodexCredentials
isBareMode?: typeof isBareMode
}
function createDefaultOAuthService(): Pick<
CodexOAuthService,
'startOAuthFlow' | 'cleanup'
> {
return new CodexOAuthService()
}
export function useCodexOAuthFlow(options: {
onAuthenticated: (
tokens: CodexOAuthTokens,
persistCredentials: PersistCodexOAuthCredentials,
) => void | Promise<void>
deps?: CodexOAuthFlowDependencies
}): CodexOAuthFlowStatus {
const { onAuthenticated } = options
const createOAuthService =
options.deps?.createOAuthService ?? createDefaultOAuthService
const openBrowserFn = options.deps?.openBrowser ?? openBrowser
const saveCredentials =
options.deps?.saveCodexCredentials ?? saveCodexCredentials
const isBareModeFn = options.deps?.isBareMode ?? isBareMode
const [status, setStatus] = React.useState<CodexOAuthFlowStatus>({
state: 'starting',
})
React.useEffect(() => {
if (isBareModeFn()) {
setStatus({
state: 'error',
message:
'Codex OAuth is unavailable in --bare because secure storage is disabled.',
})
return
}
let cancelled = false
const oauthService = createOAuthService()
void oauthService
.startOAuthFlow(async authUrl => {
if (cancelled) return
setStatus({
state: 'waiting',
authUrl,
browserOpened: null,
})
const browserOpened = await openBrowserFn(authUrl)
if (cancelled) return
setStatus({
state: 'waiting',
authUrl,
browserOpened,
})
})
.then(async tokens => {
if (cancelled) return
const persistCredentials: PersistCodexOAuthCredentials = options => {
const saved = saveCredentials({
apiKey: tokens.apiKey,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
idToken: tokens.idToken,
accountId: tokens.accountId,
profileId: options?.profileId,
})
if (!saved.success) {
throw new Error(
saved.warning ??
'Codex OAuth succeeded, but credentials could not be saved securely.',
)
}
}
await onAuthenticated(tokens, persistCredentials)
})
.catch(error => {
if (cancelled) return
setStatus({
state: 'error',
message: error instanceof Error ? error.message : String(error),
})
})
return () => {
cancelled = true
oauthService.cleanup()
}
}, [
createOAuthService,
isBareModeFn,
onAuthenticated,
openBrowserFn,
saveCredentials,
])
return status
}

View File

@@ -33,14 +33,14 @@ export const IMAGE_TARGET_RAW_SIZE = (API_IMAGE_MAX_BASE64_SIZE * 3) / 4 // 3.75
*
* Note: The API internally resizes images larger than 1568px (source:
* encoding/full_encoding.py), but this is handled server-side and doesn't
* cause errors. These client-side limits (1568px) are slightly larger to
* cause errors. These client-side limits (2000px) are slightly larger to
* preserve quality when beneficial.
*
* The API_IMAGE_MAX_BASE64_SIZE (5MB) is the actual hard limit that causes
* API errors if exceeded.
*/
export const IMAGE_MAX_WIDTH = 1568
export const IMAGE_MAX_HEIGHT = 1568
export const IMAGE_MAX_WIDTH = 2000
export const IMAGE_MAX_HEIGHT = 2000
// =============================================================================
// PDF LIMITS

View File

@@ -2,11 +2,8 @@ import { afterEach, expect, test } from 'bun:test'
import { getSystemPrompt, DEFAULT_AGENT_PROMPT } from './prompts.js'
import { CLI_SYSPROMPT_PREFIXES, getCLISyspromptPrefix } from './system.js'
import { CLAUDE_CODE_GUIDE_AGENT } from '../tools/AgentTool/built-in/claudeCodeGuideAgent.js'
import { GENERAL_PURPOSE_AGENT } from '../tools/AgentTool/built-in/generalPurposeAgent.js'
import { EXPLORE_AGENT } from '../tools/AgentTool/built-in/exploreAgent.js'
import { PLAN_AGENT } from '../tools/AgentTool/built-in/planAgent.js'
import { STATUSLINE_SETUP_AGENT } from '../tools/AgentTool/built-in/statuslineSetup.js'
const originalSimpleEnv = process.env.CLAUDE_CODE_SIMPLE
@@ -16,12 +13,10 @@ afterEach(() => {
test('CLI identity prefixes describe OpenClaude instead of Claude Code', () => {
expect(getCLISyspromptPrefix()).toContain('OpenClaude')
expect(getCLISyspromptPrefix()).not.toContain('Claude Code')
expect(getCLISyspromptPrefix()).not.toContain("Anthropic's official CLI for Claude")
for (const prefix of CLI_SYSPROMPT_PREFIXES) {
expect(prefix).toContain('OpenClaude')
expect(prefix).not.toContain('Claude Code')
expect(prefix).not.toContain("Anthropic's official CLI for Claude")
}
})
@@ -32,53 +27,22 @@ test('simple mode identity describes OpenClaude instead of Claude Code', async (
const prompt = await getSystemPrompt([], 'gpt-4o')
expect(prompt[0]).toContain('OpenClaude')
expect(prompt[0]).not.toContain('Claude Code')
expect(prompt[0]).not.toContain("Anthropic's official CLI for Claude")
})
test('built-in agent prompts describe OpenClaude instead of Claude Code', () => {
expect(DEFAULT_AGENT_PROMPT).toContain('OpenClaude')
expect(DEFAULT_AGENT_PROMPT).not.toContain('Claude Code')
expect(DEFAULT_AGENT_PROMPT).not.toContain("Anthropic's official CLI for Claude")
const generalPrompt = GENERAL_PURPOSE_AGENT.getSystemPrompt({
toolUseContext: { options: {} as never },
})
expect(generalPrompt).toContain('OpenClaude')
expect(generalPrompt).not.toContain('Claude Code')
expect(generalPrompt).not.toContain("Anthropic's official CLI for Claude")
const explorePrompt = EXPLORE_AGENT.getSystemPrompt({
toolUseContext: { options: {} as never },
})
expect(explorePrompt).toContain('OpenClaude')
expect(explorePrompt).not.toContain('Claude Code')
expect(explorePrompt).not.toContain("Anthropic's official CLI for Claude")
const planPrompt = PLAN_AGENT.getSystemPrompt({
toolUseContext: { options: {} as never },
})
expect(planPrompt).toContain('OpenClaude')
expect(planPrompt).not.toContain('Claude Code')
const statuslinePrompt = STATUSLINE_SETUP_AGENT.getSystemPrompt({
toolUseContext: { options: {} as never },
})
expect(statuslinePrompt).toContain('OpenClaude')
expect(statuslinePrompt).not.toContain('Claude Code')
const guidePrompt = CLAUDE_CODE_GUIDE_AGENT.getSystemPrompt({
toolUseContext: {
options: {
commands: [],
agentDefinitions: { activeAgents: [] },
mcpClients: [],
} as never,
},
})
expect(guidePrompt).toContain('OpenClaude')
expect(guidePrompt).toContain('You are the OpenClaude guide agent.')
expect(guidePrompt).toContain('**OpenClaude** (the CLI tool)')
expect(guidePrompt).not.toContain('You are the Claude guide agent.')
expect(guidePrompt).not.toContain('**Claude Code** (the CLI tool)')
})

View File

@@ -214,7 +214,7 @@ function getSimpleDoingTasksSection(): string {
]
const userHelpSubitems = [
`/help: Get help with using OpenClaude`,
`/help: Get help with using Claude Code`,
`To give feedback, users should ${MACRO.ISSUES_EXPLAINER}`,
]
@@ -242,7 +242,7 @@ function getSimpleDoingTasksSection(): string {
: []),
...(process.env.USER_TYPE === 'ant'
? [
`If the user reports a bug, slowness, or unexpected behavior with OpenClaude itself (as opposed to asking you to fix their own code), recommend the appropriate slash command: /issue for model-related problems (odd outputs, wrong tool choices, hallucinations, refusals), or /share to upload the full session transcript for product bugs, crashes, slowness, or general issues. Only recommend these when the user is describing a problem with OpenClaude.`,
`If the user reports a bug, slowness, or unexpected behavior with Claude Code itself (as opposed to asking you to fix their own code), recommend the appropriate slash command: /issue for model-related problems (odd outputs, wrong tool choices, hallucinations, refusals), or /share to upload the full session transcript for product bugs, crashes, slowness, or general issues. Only recommend these when the user is describing a problem with Claude Code.`,
]
: []),
`If the user asks for help or wants to give feedback inform them of the following:`,
@@ -449,7 +449,7 @@ export async function getSystemPrompt(
): Promise<string[]> {
if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
return [
`You are OpenClaude, an open-source coding agent and CLI.\n\nCWD: ${getCwd()}\nDate: ${getSessionStartDate()}`,
`You are OpenClaude, an open-source fork of Claude Code.\n\nCWD: ${getCwd()}\nDate: ${getSessionStartDate()}`,
]
}
@@ -696,10 +696,10 @@ export async function computeSimpleEnvInfo(
: `The most recent Claude model family is Claude 4.5/4.6. Model IDs — Opus 4.6: '${CLAUDE_4_5_OR_4_6_MODEL_IDS.opus}', Sonnet 4.6: '${CLAUDE_4_5_OR_4_6_MODEL_IDS.sonnet}', Haiku 4.5: '${CLAUDE_4_5_OR_4_6_MODEL_IDS.haiku}'. When building AI applications, default to the latest and most capable Claude models.`,
process.env.USER_TYPE === 'ant' && isUndercover()
? null
: `OpenClaude is available as a CLI in the terminal and can be used across local development environments and IDE workflows.`,
: `Claude Code is available as a CLI in the terminal, desktop app (Mac/Windows), web app (claude.ai/code), and IDE extensions (VS Code, JetBrains).`,
process.env.USER_TYPE === 'ant' && isUndercover()
? null
: `Fast mode for OpenClaude uses the same ${FRONTIER_MODEL_NAME} model with faster output. It does NOT switch to a different model. It can be toggled with /fast.`,
: `Fast mode for Claude Code uses the same ${FRONTIER_MODEL_NAME} model with faster output. It does NOT switch to a different model. It can be toggled with /fast.`,
].filter(item => item !== null)
return [
@@ -755,7 +755,7 @@ export function getUnameSR(): string {
return `${osType()} ${osRelease()}`
}
export const DEFAULT_AGENT_PROMPT = `You are an agent for OpenClaude, an open-source coding agent and CLI. Given the user's message, you should use the tools available to complete the task. Complete the task fully—don't gold-plate, but don't leave it half-done. When you complete the task, respond with a concise report covering what was done and any key findings — the caller will relay this to the user, so it only needs the essentials.`
export const DEFAULT_AGENT_PROMPT = `You are an agent for OpenClaude, an open-source fork of Claude Code. Given the user's message, you should use the tools available to complete the task. Complete the task fully—don't gold-plate, but don't leave it half-done. When you complete the task, respond with a concise report covering what was done and any key findings — the caller will relay this to the user, so it only needs the essentials.`
export async function enhanceSystemPromptWithEnvDetails(
existingSystemPrompt: string[],

View File

@@ -8,11 +8,11 @@ import { getAPIProvider } from '../utils/model/providers.js'
import { getWorkload } from '../utils/workloadContext.js'
const DEFAULT_PREFIX =
`You are OpenClaude, an open-source coding agent and CLI.`
`You are OpenClaude, an open-source fork of Claude Code.`
const AGENT_SDK_CLAUDE_CODE_PRESET_PREFIX =
`You are OpenClaude, an open-source coding agent and CLI running within the Claude Agent SDK.`
`You are OpenClaude, an open-source fork of Claude Code, running within the Claude Agent SDK.`
const AGENT_SDK_PREFIX =
`You are OpenClaude, built on the Claude Agent SDK.`
`You are a Claude agent running in OpenClaude, built on the Claude Agent SDK.`
const CLI_SYSPROMPT_PREFIX_VALUES = [
DEFAULT_PREFIX,

View File

@@ -37,6 +37,8 @@ export const ALL_AGENT_DISALLOWED_TOOLS = new Set([
TASK_OUTPUT_TOOL_NAME,
EXIT_PLAN_MODE_V2_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
// Allow Agent tool for agents when user is ant (enables nested agents)
...(process.env.USER_TYPE === 'ant' ? [] : [AGENT_TOOL_NAME]),
ASK_USER_QUESTION_TOOL_NAME,
TASK_STOP_TOOL_NAME,
// Prevent recursive workflow execution inside subagents.
@@ -80,9 +82,9 @@ export const IN_PROCESS_TEAMMATE_ALLOWED_TOOLS = new Set([
SEND_MESSAGE_TOOL_NAME,
// Teammate-created crons are tagged with the creating agentId and routed to
// that teammate's pendingUserMessages queue (see useScheduledTasks.ts).
CRON_CREATE_TOOL_NAME,
CRON_DELETE_TOOL_NAME,
CRON_LIST_TOOL_NAME,
...(feature('AGENT_TRIGGERS')
? [CRON_CREATE_TOOL_NAME, CRON_DELETE_TOOL_NAME, CRON_LIST_TOOL_NAME]
: []),
])
/*

View File

@@ -1,64 +0,0 @@
import { afterEach, describe, expect, test } from 'bun:test'
afterEach(() => {
delete process.env.REPO_MAP
})
describe('getRepoMapContext', () => {
test('returns null when REPO_MAP env flag is off (default)', async () => {
const { getRepoMapContext } = await import('./context.js')
const result = await getRepoMapContext()
expect(result).toBeNull()
})
test('buildRepoMap produces valid output for context injection', async () => {
process.env.REPO_MAP = '1'
const { mkdtempSync, writeFileSync, rmSync } = await import('fs')
const { tmpdir } = await import('os')
const { join } = await import('path')
const { buildRepoMap } = await import('./context/repoMap/index.js')
const tempDir = mkdtempSync(join(tmpdir(), 'repomap-ctx-'))
try {
writeFileSync(
join(tempDir, 'main.ts'),
'export function main(): void { console.log("hello") }\n',
)
writeFileSync(
join(tempDir, 'utils.ts'),
'import { main } from "./main"\nexport function helper(): void { main() }\n',
)
const result = await buildRepoMap({
root: tempDir,
maxTokens: 1024,
})
// Valid map that could be injected
expect(result.map.length).toBeGreaterThan(0)
expect(result.tokenCount).toBeGreaterThan(0)
expect(result.tokenCount).toBeLessThanOrEqual(1024)
expect(typeof result.cacheHit).toBe('boolean')
} finally {
rmSync(tempDir, { recursive: true, force: true })
const { invalidateCache } = await import('./context/repoMap/index.js')
invalidateCache(tempDir)
}
})
test('getSystemContext does not include repoMap key when flag is off', async () => {
const { getSystemContext } = await import('./context.js')
const result = await getSystemContext()
expect('repoMap' in result).toBe(false)
})
test('getSystemContext includes repoMap key when REPO_MAP env flag is on', async () => {
process.env.REPO_MAP = '1'
const { getSystemContext, getRepoMapContext } = await import('./context.js')
getRepoMapContext.cache.clear?.()
getSystemContext.cache.clear?.()
const result = await getSystemContext()
expect(typeof result.repoMap).toBe('string')
expect(result.repoMap!.length).toBeGreaterThan(0)
})
})

View File

@@ -31,7 +31,6 @@ export function setSystemPromptInjection(value: string | null): void {
// Clear context caches immediately when injection changes
getUserContext.cache.clear?.()
getSystemContext.cache.clear?.()
getRepoMapContext.cache.clear?.()
}
export const getGitStatus = memoize(async (): Promise<string | null> => {
@@ -111,35 +110,6 @@ export const getGitStatus = memoize(async (): Promise<string | null> => {
}
})
export const getRepoMapContext = memoize(
async (): Promise<string | null> => {
const runtimeEnabled = isEnvTruthy(process.env.REPO_MAP)
if (!runtimeEnabled) return null
if (isBareMode()) return null
if (isEnvTruthy(process.env.CLAUDE_CODE_REMOTE)) return null
try {
const startTime = Date.now()
logForDiagnosticsNoPII('info', 'repo_map_started')
const { buildRepoMap } = await import('./context/repoMap/index.js')
const result = await buildRepoMap({ maxTokens: 1024 })
logForDiagnosticsNoPII('info', 'repo_map_completed', {
duration_ms: Date.now() - startTime,
token_count: result.tokenCount,
file_count: result.fileCount,
cache_hit: result.cacheHit,
})
if (!result.map || result.map.length === 0) return null
return `This is a structural map of the repository, ranked by importance. Use it to understand the codebase architecture.\n\n${result.map}`
} catch (err) {
logForDiagnosticsNoPII('warn', 'repo_map_failed', {
error: String(err),
})
return null
}
},
)
/**
* This context is prepended to each conversation, and cached for the duration of the conversation.
*/
@@ -157,8 +127,6 @@ export const getSystemContext = memoize(
? null
: await getGitStatus()
const repoMap = await getRepoMapContext()
// Include system prompt injection if set (for cache breaking, internal-only)
const injection = feature('BREAK_CACHE_COMMAND')
? getSystemPromptInjection()
@@ -167,13 +135,11 @@ export const getSystemContext = memoize(
logForDiagnosticsNoPII('info', 'system_context_completed', {
duration_ms: Date.now() - startTime,
has_git_status: gitStatus !== null,
has_repo_map: repoMap !== null,
has_injection: injection !== null,
})
return {
...(gitStatus && { gitStatus }),
...(repoMap && { repoMap }),
...(feature('BREAK_CACHE_COMMAND') && injection
? {
cacheBreaker: `[CACHE_BREAKER: ${injection}]`,

View File

@@ -1,29 +0,0 @@
// fileA — imports from fileB and fileC
import { CacheLayer, buildCache } from './fileB'
import { createStore, type StoreConfig } from './fileC'
export class AppController {
private cache: CacheLayer
private config: StoreConfig
constructor(config: StoreConfig) {
this.cache = buildCache()
this.config = config
}
initialize(): void {
const store = createStore()
this.cache.cacheSet('primary', store)
}
getFromCache(key: string): unknown {
return this.cache.cacheGet(key)
}
}
export function startApp(config: StoreConfig): AppController {
const app = new AppController(config)
app.initialize()
return app
}

View File

@@ -1,23 +0,0 @@
// fileB — imports from fileC
import { DataStore, createStore } from './fileC'
export class CacheLayer {
private store: DataStore
constructor() {
this.store = createStore()
}
cacheGet(key: string): unknown | undefined {
return this.store.lookup(key)
}
cacheSet(key: string, value: unknown): void {
this.store.add(key, value)
}
}
export function buildCache(): CacheLayer {
return new CacheLayer()
}

View File

@@ -1,22 +0,0 @@
// fileC — the most imported module (imported by fileA and fileB)
export class DataStore {
private items: Map<string, unknown> = new Map()
add(key: string, value: unknown): void {
this.items.set(key, value)
}
lookup(key: string): unknown | undefined {
return this.items.get(key)
}
}
export function createStore(): DataStore {
return new DataStore()
}
export interface StoreConfig {
maxSize: number
ttl: number
}

View File

@@ -1,9 +0,0 @@
// fileD — imports from fileA
import { AppController, startApp } from './fileA'
export function runApp(): void {
const controller: AppController = startApp({ maxSize: 100, ttl: 3600 })
const result = controller.getFromCache('test')
console.log(result)
}

View File

@@ -1,25 +0,0 @@
// fileE — isolated, no imports from other fixture files
export interface Logger {
log(message: string): void
warn(message: string): void
error(message: string): void
}
export class ConsoleLogger implements Logger {
log(message: string): void {
console.log(`[LOG] ${message}`)
}
warn(message: string): void {
console.warn(`[WARN] ${message}`)
}
error(message: string): void {
console.error(`[ERROR] ${message}`)
}
}
export function createLogger(): Logger {
return new ConsoleLogger()
}

View File

@@ -1,139 +0,0 @@
import { createHash } from 'crypto'
import {
existsSync,
mkdirSync,
readFileSync,
statSync,
writeFileSync,
} from 'fs'
import { homedir } from 'os'
import { join } from 'path'
import type { CacheData, CacheEntry, CacheStats, Tag } from './types.js'
const CACHE_VERSION = 1
const CACHE_DIR = join(homedir(), '.openclaude', 'repomap-cache')
function getCacheFilePath(root: string): string {
const hash = createHash('sha1').update(root).digest('hex')
return join(CACHE_DIR, `${hash}.json`)
}
function ensureCacheDir(): void {
if (!existsSync(CACHE_DIR)) {
mkdirSync(CACHE_DIR, { recursive: true })
}
}
/** Load cache from disk. Returns empty cache if not found or invalid. */
export function loadCache(root: string): CacheData {
const path = getCacheFilePath(root)
try {
const raw = readFileSync(path, 'utf-8')
const data = JSON.parse(raw) as CacheData
if (data.version !== CACHE_VERSION) {
return { version: CACHE_VERSION, entries: {} }
}
return data
} catch {
return { version: CACHE_VERSION, entries: {} }
}
}
/** Save cache to disk. */
export function saveCache(root: string, cache: CacheData): void {
ensureCacheDir()
const path = getCacheFilePath(root)
writeFileSync(path, JSON.stringify(cache), 'utf-8')
}
/**
* Check if a file's cached entry is still valid based on mtime and size.
* Returns the cached tags if valid, null otherwise.
*/
export function getCachedTags(
cache: CacheData,
filePath: string,
root: string,
): Tag[] | null {
const entry = cache.entries[filePath]
if (!entry) return null
try {
const absolutePath = join(root, filePath)
const stat = statSync(absolutePath)
if (stat.mtimeMs === entry.mtimeMs && stat.size === entry.size) {
return entry.tags
}
} catch {
// File may have been deleted
}
return null
}
/** Update the cache entry for a file. */
export function setCachedTags(
cache: CacheData,
filePath: string,
root: string,
tags: Tag[],
): void {
try {
const absolutePath = join(root, filePath)
const stat = statSync(absolutePath)
cache.entries[filePath] = {
tags,
mtimeMs: stat.mtimeMs,
size: stat.size,
}
} catch {
// If we can't stat, don't cache
}
}
/**
* Compute a hash of the inputs that affect the rendered map.
* Used to cache the final rendered output.
*/
export function computeMapHash(
files: string[],
maxTokens: number,
focusFiles: string[],
): string {
const sorted = [...files].sort()
const input = JSON.stringify({ files: sorted, maxTokens, focusFiles: [...focusFiles].sort() })
return createHash('sha1').update(input).digest('hex')
}
/** Get cache statistics. */
export function getCacheStats(root: string): CacheStats {
const cacheFile = getCacheFilePath(root)
const exists = existsSync(cacheFile)
let entryCount = 0
if (exists) {
try {
const data = JSON.parse(readFileSync(cacheFile, 'utf-8')) as CacheData
entryCount = Object.keys(data.entries).length
} catch {
// corrupted cache
}
}
return {
cacheDir: CACHE_DIR,
cacheFile: exists ? cacheFile : null,
entryCount,
exists,
}
}
/** Delete the cache for a repo root. */
export function invalidateCache(root: string): void {
const path = getCacheFilePath(root)
try {
const { unlinkSync } = require('fs')
unlinkSync(path)
} catch {
// File may not exist
}
}

View File

@@ -1,109 +0,0 @@
import { execFile } from 'child_process'
import { readdirSync } from 'fs'
import { join, relative } from 'path'
import type { SupportedLanguage } from './types.js'
const SUPPORTED_EXTENSIONS: Record<string, SupportedLanguage> = {
'.ts': 'typescript',
'.tsx': 'typescript',
'.js': 'javascript',
'.jsx': 'javascript',
'.mjs': 'javascript',
'.cjs': 'javascript',
'.py': 'python',
}
const EXCLUDED_DIRS = new Set([
'node_modules',
'dist',
'.git',
'.hg',
'.svn',
'build',
'out',
'coverage',
'__pycache__',
'.next',
'.nuxt',
'vendor',
'.worktrees',
])
const EXCLUDED_FILES = new Set([
'bun.lock',
'bun.lockb',
'package-lock.json',
'yarn.lock',
'pnpm-lock.yaml',
])
export function getLanguageForFile(filePath: string): SupportedLanguage | null {
const ext = filePath.substring(filePath.lastIndexOf('.'))
return SUPPORTED_EXTENSIONS[ext] ?? null
}
export function isSupportedFile(filePath: string): boolean {
return getLanguageForFile(filePath) !== null
}
/** List files using git ls-files. Returns relative paths. */
function gitLsFiles(root: string): Promise<string[]> {
return new Promise((resolve, reject) => {
execFile(
'git',
['ls-files', '--cached', '--others', '--exclude-standard'],
{ cwd: root, maxBuffer: 10 * 1024 * 1024 },
(error, stdout) => {
if (error) {
reject(error)
return
}
const files = stdout
.split('\n')
.map(f => f.trim())
.filter(f => f.length > 0)
resolve(files)
},
)
})
}
/** Walk directory tree manually as fallback when git is unavailable. */
function walkDirectory(root: string, currentDir: string = root): string[] {
const results: string[] = []
let entries: ReturnType<typeof readdirSync>
try {
entries = readdirSync(currentDir, { withFileTypes: true })
} catch {
return results
}
for (const entry of entries) {
const name = entry.name
if (entry.isDirectory()) {
if (!EXCLUDED_DIRS.has(name) && !name.startsWith('.')) {
results.push(...walkDirectory(root, join(currentDir, name)))
}
} else if (entry.isFile()) {
if (!EXCLUDED_FILES.has(name)) {
results.push(relative(root, join(currentDir, name)))
}
}
}
return results
}
/**
* Enumerate all supported source files in the repo.
* Tries git ls-files first, falls back to manual walk.
*/
export async function getRepoFiles(root: string): Promise<string[]> {
let files: string[]
try {
files = await gitLsFiles(root)
} catch {
files = walkDirectory(root)
}
return files.filter(isSupportedFile)
}

View File

@@ -1,88 +0,0 @@
import Graph from 'graphology'
import type { FileTags } from './types.js'
// Common identifiers that should contribute less weight (high IDF penalty).
const COMMON_NAMES = new Set([
'map', 'get', 'set', 'value', 'key', 'data', 'result', 'error',
'name', 'type', 'id', 'index', 'item', 'items', 'list', 'options',
'config', 'args', 'params', 'props', 'state', 'event', 'callback',
'handler', 'fn', 'func', 'self', 'this', 'ctx', 'context', 'req',
'res', 'next', 'err', 'msg', 'obj', 'arr', 'str', 'num', 'val',
'init', 'start', 'stop', 'run', 'main', 'test', 'setup', 'teardown',
'constructor', 'toString', 'valueOf', 'length', 'size', 'count',
'push', 'pop', 'shift', 'filter', 'reduce', 'forEach', 'find',
'log', 'warn', 'info', 'debug', 'trace',
])
/**
* Build a directed graph from file tags.
*
* Nodes are file paths. An edge from A to B means file A references
* a symbol defined in file B. Edge weight = refCount * idf(symbolName).
*/
export function buildGraph(allFileTags: FileTags[]): Graph {
const graph = new Graph({ multi: false, type: 'directed' })
// Build a map from symbol name → files that define it
const defIndex = new Map<string, Set<string>>()
for (const ft of allFileTags) {
for (const tag of ft.tags) {
if (tag.kind === 'def') {
let files = defIndex.get(tag.name)
if (!files) {
files = new Set()
defIndex.set(tag.name, files)
}
files.add(ft.path)
}
}
}
// Compute IDF: log(totalFiles / filesDefiningSymbol)
// Common names get an extra penalty
const totalFiles = allFileTags.length
function idf(symbolName: string): number {
const defFiles = defIndex.get(symbolName)
const docFreq = defFiles ? defFiles.size : 1
const rawIdf = Math.log(totalFiles / docFreq)
return COMMON_NAMES.has(symbolName) ? rawIdf * 0.1 : rawIdf
}
// Add all files as nodes
for (const ft of allFileTags) {
if (!graph.hasNode(ft.path)) {
graph.addNode(ft.path)
}
}
// Build edges: for each ref in a file, find where it's defined
for (const ft of allFileTags) {
// Count refs per target file
const edgeWeights = new Map<string, number>()
for (const tag of ft.tags) {
if (tag.kind !== 'ref') continue
const defFiles = defIndex.get(tag.name)
if (!defFiles) continue
const weight = idf(tag.name)
for (const defFile of defFiles) {
if (defFile === ft.path) continue // skip self-references
const current = edgeWeights.get(defFile) ?? 0
edgeWeights.set(defFile, current + weight)
}
}
for (const [target, weight] of edgeWeights) {
if (graph.hasEdge(ft.path, target)) {
graph.setEdgeAttribute(ft.path, target, 'weight',
graph.getEdgeAttribute(ft.path, target, 'weight') + weight)
} else {
graph.addEdge(ft.path, target, { weight })
}
}
}
return graph
}

View File

@@ -1,144 +0,0 @@
import {
computeMapHash,
getCachedTags,
getCacheStats as getCacheStatsImpl,
invalidateCache as invalidateCacheImpl,
loadCache,
saveCache,
setCachedTags,
} from './cache.js'
import { getRepoFiles } from './gitFiles.js'
import { buildGraph } from './graph.js'
import { rankFiles } from './pagerank.js'
import { initParser } from './parser.js'
import { renderMap } from './renderer.js'
import { extractTags } from './symbolExtractor.js'
import type { FileTags, RepoMapOptions, RepoMapResult, CacheStats } from './types.js'
const DEFAULT_MAX_TOKENS = 2048
/**
* Build a structural summary of a code repository.
*
* Walks the repo, extracts symbols via tree-sitter, builds an IDF-weighted
* reference graph, ranks files with PageRank, and renders a token-budgeted
* structural summary.
*/
export async function buildRepoMap(options: RepoMapOptions = {}): Promise<RepoMapResult> {
const startTime = Date.now()
const root = options.root ?? process.cwd()
const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS
const focusFiles = options.focusFiles ?? []
// Initialize tree-sitter
await initParser()
// Get files
const files = options.files ?? await getRepoFiles(root)
const totalFileCount = files.length
// Check if we have a cached rendered map
const mapHash = computeMapHash(files, maxTokens, focusFiles)
const cache = loadCache(root)
// Check if rendered map is cached (stored as a special entry)
const renderedCacheKey = `__rendered__${mapHash}`
const renderedEntry = cache.entries[renderedCacheKey]
if (renderedEntry && renderedEntry.tags.length === 1) {
const cachedResult = renderedEntry.tags[0]!
// The cached "tag" stores the rendered map in the signature field
// and metadata in name/line fields
try {
const meta = JSON.parse(cachedResult.name)
return {
map: cachedResult.signature,
cacheHit: true,
buildTimeMs: Date.now() - startTime,
fileCount: meta.fileCount ?? 0,
totalFileCount,
tokenCount: meta.tokenCount ?? 0,
}
} catch {
// Invalid cached data, continue with full build
}
}
// Extract tags for all files (using per-file cache).
// Separate cached hits from files needing extraction.
const allFileTags: FileTags[] = []
const uncachedFiles: string[] = []
for (const file of files) {
const cachedTags = getCachedTags(cache, file, root)
if (cachedTags) {
allFileTags.push({ path: file, tags: cachedTags })
} else {
uncachedFiles.push(file)
}
}
// Process uncached files in parallel batches
const BATCH_SIZE = 50
for (let i = 0; i < uncachedFiles.length; i += BATCH_SIZE) {
const batch = uncachedFiles.slice(i, i + BATCH_SIZE)
const results = await Promise.all(
batch.map(file => extractTags(file, root).catch(() => null))
)
for (let j = 0; j < results.length; j++) {
const fileTags = results[j]
if (fileTags) {
allFileTags.push(fileTags)
setCachedTags(cache, fileTags.path, root, fileTags.tags)
}
}
}
// Build graph and rank
const graph = buildGraph(allFileTags)
const ranked = rankFiles(graph, focusFiles)
// Build a lookup map
const fileTagsMap = new Map<string, FileTags>()
for (const ft of allFileTags) {
fileTagsMap.set(ft.path, ft)
}
// Render
const { map, tokenCount, fileCount } = renderMap(ranked, fileTagsMap, maxTokens)
// Cache the rendered result
cache.entries[renderedCacheKey] = {
tags: [{
kind: 'def',
name: JSON.stringify({ fileCount, tokenCount }),
line: 0,
signature: map,
}],
mtimeMs: Date.now(),
size: 0,
}
saveCache(root, cache)
return {
map,
cacheHit: false,
buildTimeMs: Date.now() - startTime,
fileCount,
totalFileCount,
tokenCount,
}
}
/** Invalidate the disk cache for a given repo root. */
export function invalidateCache(root?: string): void {
invalidateCacheImpl(root ?? process.cwd())
}
/** Get cache statistics for a given repo root. */
export function getCacheStats(root?: string): CacheStats {
return getCacheStatsImpl(root ?? process.cwd())
}
// Re-export types for convenience
export type { RepoMapOptions, RepoMapResult, CacheStats } from './types.js'

View File

@@ -1,57 +0,0 @@
import type Graph from 'graphology'
import pagerank from 'graphology-pagerank'
export interface RankedFile {
path: string
score: number
}
/**
* Run PageRank on the file reference graph.
*
* focusFiles get a 100x boost in the personalization vector so they
* and their neighbors rank higher.
*
* Returns files sorted by score descending.
*/
export function rankFiles(
graph: Graph,
focusFiles: string[] = [],
): RankedFile[] {
if (graph.order === 0) return []
const hasPersonalization = focusFiles.length > 0
// graphology-pagerank accepts getEdgeWeight option
const scores: Record<string, number> = pagerank(graph, {
alpha: 0.85,
maxIterations: 100,
tolerance: 1e-6,
getEdgeWeight: 'weight',
})
// Apply focus boost post-hoc if focus files are specified
if (hasPersonalization) {
for (const file of focusFiles) {
if (scores[file] !== undefined) {
scores[file] *= 100
}
}
// Also boost direct neighbors of focus files
for (const file of focusFiles) {
if (!graph.hasNode(file)) continue
graph.forEachNeighbor(file, (neighbor) => {
if (scores[neighbor] !== undefined) {
scores[neighbor] *= 10
}
})
}
}
const ranked: RankedFile[] = Object.entries(scores)
.map(([path, score]) => ({ path, score }))
.sort((a, b) => b.score - a.score)
return ranked
}

View File

@@ -1,166 +0,0 @@
import { existsSync, readFileSync } from 'fs'
import { join, resolve } from 'path'
import { fileURLToPath } from 'url'
import type { SupportedLanguage } from './types.js'
// Resolve project root in both source and bundled modes.
// In source (bun test/dev): import.meta.url is src/context/repoMap/parser.ts → go up 4 levels
// In bundle (node dist/cli.mjs): import.meta.url is dist/cli.mjs → go up 2 levels
const __filename = fileURLToPath(import.meta.url)
const __projectRoot = join(
__filename,
process.env.NODE_ENV === 'test' ? '../../../../' : '../../',
)
// web-tree-sitter types
type TreeSitterParser = {
parse(input: string): { rootNode: unknown }
setLanguage(lang: unknown): void
delete(): void
}
type TreeSitterLanguage = {
query(source: string): unknown
}
// The actual module exports { Parser, Language } as named exports
let ParserClass: (new () => TreeSitterParser) & {
init(opts?: { locateFile?: (file: string) => string }): Promise<void>
} | null = null
let LanguageLoader: {
load(path: string | Uint8Array): Promise<TreeSitterLanguage>
} | null = null
let initialized = false
const languageCache = new Map<SupportedLanguage, TreeSitterLanguage>()
const queryCache = new Map<SupportedLanguage, string>()
/** Resolve the path to the tree-sitter WASM file. */
function getTreeSitterWasmPath(): string {
// Try require.resolve first (works in source mode with node_modules)
try {
const webTsDir = resolve(
require.resolve('web-tree-sitter/package.json'),
'..',
)
return join(webTsDir, 'tree-sitter.wasm')
} catch {
// Fallback: relative to project root
return join(__projectRoot, 'node_modules', 'web-tree-sitter', 'tree-sitter.wasm')
}
}
/** Resolve the path to a language WASM grammar file. */
function getLanguageWasmPath(language: SupportedLanguage): string {
const wasmName = language === 'typescript' ? 'tree-sitter-typescript' :
language === 'javascript' ? 'tree-sitter-javascript' :
`tree-sitter-${language}`
try {
const wasmDir = resolve(
require.resolve('tree-sitter-wasms/package.json'),
'..',
'out',
)
return join(wasmDir, `${wasmName}.wasm`)
} catch {
return join(__projectRoot, 'node_modules', 'tree-sitter-wasms', 'out', `${wasmName}.wasm`)
}
}
/** Resolve the path to a tag query .scm file for the given language. */
function getQueryPath(language: SupportedLanguage): string {
// Try source location first (works in both source and when queries are alongside the bundle)
const sourcePath = join(__projectRoot, 'src', 'context', 'repoMap', 'queries', `${language}-tags.scm`)
if (existsSync(sourcePath)) {
return sourcePath
}
// Fallback: relative to this file (source mode)
return join(fileURLToPath(import.meta.url), '..', 'queries', `${language}-tags.scm`)
}
/** Initialize the tree-sitter WASM module. */
export async function initParser(): Promise<void> {
if (initialized) return
try {
const mod = await import('web-tree-sitter')
ParserClass = mod.Parser as typeof ParserClass
LanguageLoader = mod.Language as typeof LanguageLoader
const wasmPath = getTreeSitterWasmPath()
await ParserClass!.init({
locateFile: () => wasmPath,
})
initialized = true
} catch (err) {
// eslint-disable-next-line no-console
console.error('[repoMap] Failed to initialize tree-sitter:', err)
throw err
}
}
/** Load a language grammar. Cached after first load. */
export async function loadLanguage(language: SupportedLanguage): Promise<TreeSitterLanguage | null> {
if (languageCache.has(language)) {
return languageCache.get(language)!
}
if (!initialized) {
await initParser()
}
try {
const wasmPath = getLanguageWasmPath(language)
const lang = await LanguageLoader!.load(wasmPath)
languageCache.set(language, lang)
return lang
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[repoMap] Failed to load ${language} grammar:`, err)
return null
}
}
/** Load the tag query for a language. Cached after first load. */
export function loadQuery(language: SupportedLanguage): string | null {
if (queryCache.has(language)) {
return queryCache.get(language)!
}
try {
const queryPath = getQueryPath(language)
const content = readFileSync(queryPath, 'utf-8')
queryCache.set(language, content)
return content
} catch {
return null
}
}
/** Create a new parser instance with the given language set. */
export async function createParser(language: SupportedLanguage): Promise<TreeSitterParser | null> {
if (!initialized) {
await initParser()
}
const lang = await loadLanguage(language)
if (!lang) return null
try {
const parser = new ParserClass!()
parser.setLanguage(lang)
return parser
} catch {
return null
}
}
/** Clear all caches (useful for testing). */
export function clearParserCaches(): void {
languageCache.clear()
queryCache.clear()
initialized = false
ParserClass = null
LanguageLoader = null
}

View File

@@ -1,92 +0,0 @@
; Source: https://github.com/Aider-AI/aider/blob/main/aider/queries/tree-sitter-languages/javascript-tags.scm
; License: MIT (Apache-2.0 dual) — see https://github.com/Aider-AI/aider/blob/main/LICENSE
; Copied for use in openclaude's repo-map feature.
(
(comment)* @doc
.
(method_definition
name: (property_identifier) @name.definition.method) @definition.method
(#not-eq? @name.definition.method "constructor")
(#strip! @doc "^[\\s\\*/]+|^[\\s\\*/]$")
(#select-adjacent! @doc @definition.method)
)
(
(comment)* @doc
.
[
(class
name: (_) @name.definition.class)
(class_declaration
name: (_) @name.definition.class)
] @definition.class
(#strip! @doc "^[\\s\\*/]+|^[\\s\\*/]$")
(#select-adjacent! @doc @definition.class)
)
(
(comment)* @doc
.
[
(function
name: (identifier) @name.definition.function)
(function_declaration
name: (identifier) @name.definition.function)
(generator_function
name: (identifier) @name.definition.function)
(generator_function_declaration
name: (identifier) @name.definition.function)
] @definition.function
(#strip! @doc "^[\\s\\*/]+|^[\\s\\*/]$")
(#select-adjacent! @doc @definition.function)
)
(
(comment)* @doc
.
(lexical_declaration
(variable_declarator
name: (identifier) @name.definition.function
value: [(arrow_function) (function)]) @definition.function)
(#strip! @doc "^[\\s\\*/]+|^[\\s\\*/]$")
(#select-adjacent! @doc @definition.function)
)
(
(comment)* @doc
.
(variable_declaration
(variable_declarator
name: (identifier) @name.definition.function
value: [(arrow_function) (function)]) @definition.function)
(#strip! @doc "^[\\s\\*/]+|^[\\s\\*/]$")
(#select-adjacent! @doc @definition.function)
)
(assignment_expression
left: [
(identifier) @name.definition.function
(member_expression
property: (property_identifier) @name.definition.function)
]
right: [(arrow_function) (function)]
) @definition.function
(pair
key: (property_identifier) @name.definition.function
value: [(arrow_function) (function)]) @definition.function
(
(call_expression
function: (identifier) @name.reference.call) @reference.call
(#not-match? @name.reference.call "^(require)$")
)
(call_expression
function: (member_expression
property: (property_identifier) @name.reference.call)
arguments: (_) @reference.call)
(new_expression
constructor: (_) @name.reference.class) @reference.class

View File

@@ -1,16 +0,0 @@
; Source: https://github.com/Aider-AI/aider/blob/main/aider/queries/tree-sitter-languages/python-tags.scm
; License: MIT (Apache-2.0 dual) — see https://github.com/Aider-AI/aider/blob/main/LICENSE
; Copied for use in openclaude's repo-map feature.
(class_definition
name: (identifier) @name.definition.class) @definition.class
(function_definition
name: (identifier) @name.definition.function) @definition.function
(call
function: [
(identifier) @name.reference.call
(attribute
attribute: (identifier) @name.reference.call)
]) @reference.call

View File

@@ -1,45 +0,0 @@
; Source: https://github.com/Aider-AI/aider/blob/main/aider/queries/tree-sitter-languages/typescript-tags.scm
; License: MIT (Apache-2.0 dual) — see https://github.com/Aider-AI/aider/blob/main/LICENSE
; Copied for use in openclaude's repo-map feature.
(function_signature
name: (identifier) @name.definition.function) @definition.function
(method_signature
name: (property_identifier) @name.definition.method) @definition.method
(abstract_method_signature
name: (property_identifier) @name.definition.method) @definition.method
(abstract_class_declaration
name: (type_identifier) @name.definition.class) @definition.class
(module
name: (identifier) @name.definition.module) @definition.module
(interface_declaration
name: (type_identifier) @name.definition.interface) @definition.interface
(type_annotation
(type_identifier) @name.reference.type) @reference.type
(new_expression
constructor: (identifier) @name.reference.class) @reference.class
(function_declaration
name: (identifier) @name.definition.function) @definition.function
(method_definition
name: (property_identifier) @name.definition.method) @definition.method
(class_declaration
name: (type_identifier) @name.definition.class) @definition.class
(interface_declaration
name: (type_identifier) @name.definition.class) @definition.class
(type_alias_declaration
name: (type_identifier) @name.definition.type) @definition.type
(enum_declaration
name: (identifier) @name.definition.enum) @definition.enum

View File

@@ -1,72 +0,0 @@
import type { FileTags, Tag } from './types.js'
import type { RankedFile } from './pagerank.js'
import { countTokens } from './tokenize.js'
/**
* Render a token-budgeted repo map from ranked files and their tags.
*
* Format per file:
* path/to/file.ts:
* ⋮
* signature line for def 1
* ⋮
* signature line for def 2
* ⋮
*
* Files that don't fit within the budget are dropped entirely.
*/
export function renderMap(
rankedFiles: RankedFile[],
fileTagsMap: Map<string, FileTags>,
maxTokens: number,
): { map: string; tokenCount: number; fileCount: number } {
const sections: string[] = []
let currentTokens = 0
let fileCount = 0
for (const { path } of rankedFiles) {
const ft = fileTagsMap.get(path)
if (!ft) continue
// Only include definitions in the rendered output
const defs = ft.tags
.filter(t => t.kind === 'def')
.sort((a, b) => a.line - b.line)
if (defs.length === 0) continue
const section = renderFileSection(path, defs)
const sectionTokens = countTokens(section)
// Would this section bust the budget?
if (currentTokens + sectionTokens > maxTokens) {
// Don't include partial files — drop entirely
break
}
sections.push(section)
currentTokens += sectionTokens
fileCount++
}
const map = sections.join('\n')
return { map, tokenCount: currentTokens, fileCount }
}
function renderFileSection(path: string, defs: Tag[]): string {
const lines: string[] = [`${path}:`]
let lastLine = 0
for (const def of defs) {
// Add elision marker if there's a gap
if (def.line > lastLine + 1) {
lines.push('⋮')
}
lines.push(` ${def.signature}`)
lastLine = def.line
}
// Trailing elision marker
lines.push('⋮')
return lines.join('\n')
}

View File

@@ -1,275 +0,0 @@
import { afterEach, beforeAll, describe, expect, test } from 'bun:test'
import { cpSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { invalidateCache, buildRepoMap } from './index.js'
import { extractTags } from './symbolExtractor.js'
import { buildGraph } from './graph.js'
import { initParser } from './parser.js'
import { countTokens } from './tokenize.js'
const FIXTURE_ROOT = join(import.meta.dir, '__fixtures__', 'mini-repo')
const FIXTURE_FILES = ['fileA.ts', 'fileB.ts', 'fileC.ts', 'fileD.ts', 'fileE.ts']
beforeAll(async () => {
await initParser()
})
// Clean up cache between tests to avoid cross-test interference
afterEach(() => {
invalidateCache(FIXTURE_ROOT)
})
describe('symbol extraction', () => {
test('extracts function and class defs from a TypeScript file', async () => {
const result = await extractTags('fileC.ts', FIXTURE_ROOT)
expect(result).not.toBeNull()
const defs = result!.tags.filter(t => t.kind === 'def')
const defNames = defs.map(t => t.name)
expect(defNames).toContain('DataStore')
expect(defNames).toContain('createStore')
expect(defNames).toContain('StoreConfig')
// All defs should have kind='def'
for (const d of defs) {
expect(d.kind).toBe('def')
}
})
test('extracts references to imported symbols', async () => {
const result = await extractTags('fileA.ts', FIXTURE_ROOT)
expect(result).not.toBeNull()
const refs = result!.tags.filter(t => t.kind === 'ref')
const refNames = refs.map(t => t.name)
// fileA imports CacheLayer from fileB and StoreConfig from fileC
expect(refNames).toContain('CacheLayer')
expect(refNames).toContain('StoreConfig')
})
})
describe('graph', () => {
test('builds edges between files that reference each other\'s symbols', async () => {
const allTags = []
for (const f of FIXTURE_FILES) {
const tags = await extractTags(f, FIXTURE_ROOT)
if (tags) allTags.push(tags)
}
const graph = buildGraph(allTags)
// fileA imports from fileB (references CacheLayer defined in fileB)
expect(graph.hasEdge('fileA.ts', 'fileB.ts')).toBe(true)
// fileA imports from fileC (references StoreConfig, DataStore defined in fileC)
expect(graph.hasEdge('fileA.ts', 'fileC.ts')).toBe(true)
// fileB imports from fileC (references DataStore defined in fileC)
expect(graph.hasEdge('fileB.ts', 'fileC.ts')).toBe(true)
// fileD imports from fileA
expect(graph.hasEdge('fileD.ts', 'fileA.ts')).toBe(true)
// fileE is isolated — no edges to/from it
expect(graph.degree('fileE.ts')).toBe(0)
})
})
describe('pagerank', () => {
test('ranks the most-imported file highest', async () => {
const result = await buildRepoMap({
root: FIXTURE_ROOT,
maxTokens: 2048,
files: FIXTURE_FILES,
})
// The map starts with the highest-ranked file
const firstFile = result.map.split('\n')[0]
expect(firstFile).toBe('fileC.ts:')
// fileE should be ranked lowest (or near last)
const lines = result.map.split('\n')
const filePositions = FIXTURE_FILES.map(f => {
const idx = lines.findIndex(l => l === `${f}:`)
return { file: f, position: idx }
}).filter(x => x.position >= 0)
.sort((a, b) => a.position - b.position)
// fileC should be first
expect(filePositions[0]!.file).toBe('fileC.ts')
// fileE should be last (or among the last)
const lastFile = filePositions[filePositions.length - 1]!.file
expect(['fileD.ts', 'fileE.ts']).toContain(lastFile)
})
})
describe('renderer', () => {
test('respects the token budget within 5%', async () => {
const maxTokens = 500
const result = await buildRepoMap({
root: FIXTURE_ROOT,
maxTokens,
files: FIXTURE_FILES,
})
const actualTokens = countTokens(result.map)
expect(actualTokens).toBeLessThanOrEqual(maxTokens * 1.05)
expect(result.tokenCount).toBeLessThanOrEqual(maxTokens * 1.05)
})
test('drops files that don\'t fit rather than listing their names', async () => {
// Very tight budget — should only fit 1-2 files
const result = await buildRepoMap({
root: FIXTURE_ROOT,
maxTokens: 100,
files: FIXTURE_FILES,
})
// Count how many files appear as headers in the output
const fileHeaders = result.map.split('\n').filter(l => l.endsWith(':') && !l.startsWith(' '))
// Every file header in the output should have its signatures listed
for (const header of fileHeaders) {
// The file must have at least one signature line after it
const headerIdx = result.map.indexOf(header)
const afterHeader = result.map.slice(headerIdx + header.length)
// Should have content (signatures), not just the filename
expect(afterHeader.trim().length).toBeGreaterThan(0)
}
// Should have fewer files than total
expect(fileHeaders.length).toBeLessThan(FIXTURE_FILES.length)
})
})
describe('cache', () => {
test('second build of unchanged fixture uses the cache', async () => {
// First build (cold)
const result1 = await buildRepoMap({
root: FIXTURE_ROOT,
maxTokens: 2048,
files: FIXTURE_FILES,
})
expect(result1.cacheHit).toBe(false)
// Second build (warm)
const result2 = await buildRepoMap({
root: FIXTURE_ROOT,
maxTokens: 2048,
files: FIXTURE_FILES,
})
expect(result2.cacheHit).toBe(true)
expect(result2.buildTimeMs).toBeLessThan(result1.buildTimeMs)
// Output should be identical
expect(result2.map).toBe(result1.map)
})
test('modifying a file invalidates only that file', async () => {
// Create a temp copy of the fixture
const tempDir = mkdtempSync(join(tmpdir(), 'repomap-test-'))
try {
for (const f of FIXTURE_FILES) {
cpSync(join(FIXTURE_ROOT, f), join(tempDir, f))
}
// First build
const result1 = await buildRepoMap({
root: tempDir,
maxTokens: 2048,
files: FIXTURE_FILES,
})
expect(result1.cacheHit).toBe(false)
// Touch one file to change its mtime
const targetFile = join(tempDir, 'fileE.ts')
const now = new Date()
utimesSync(targetFile, now, now)
// Second build — rendered cache should be invalidated because file list hash
// includes the files and the rendered map hash changes with different mtimes
// for the per-file cache check
invalidateCache(tempDir)
const result2 = await buildRepoMap({
root: tempDir,
maxTokens: 2048,
files: FIXTURE_FILES,
})
// The per-file cache for fileE should miss (mtime changed),
// but other files should still hit the per-file cache
expect(result2.cacheHit).toBe(false)
// Output should still be valid
expect(result2.map.length).toBeGreaterThan(0)
expect(result2.fileCount).toBe(result1.fileCount)
} finally {
rmSync(tempDir, { recursive: true, force: true })
invalidateCache(tempDir)
}
})
})
describe('gitFiles', () => {
test('falls back gracefully when not in a git repo', async () => {
// Create a temp directory with source files but NO .git
const tempDir = mkdtempSync(join(tmpdir(), 'repomap-nogit-'))
try {
writeFileSync(
join(tempDir, 'hello.ts'),
'export function hello(): string { return "world" }\n',
)
writeFileSync(
join(tempDir, 'utils.ts'),
'export function add(a: number, b: number): number { return a + b }\n',
)
const result = await buildRepoMap({
root: tempDir,
maxTokens: 1024,
})
// Should succeed without throwing
expect(result.map.length).toBeGreaterThan(0)
expect(result.totalFileCount).toBeGreaterThan(0)
} finally {
rmSync(tempDir, { recursive: true, force: true })
invalidateCache(tempDir)
}
})
})
describe('error handling', () => {
test('no crash on malformed source file', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'repomap-malformed-'))
try {
// Valid file
writeFileSync(
join(tempDir, 'good.ts'),
'export function good(): number { return 1 }\n',
)
// Malformed file — severe syntax errors
writeFileSync(
join(tempDir, 'bad.ts'),
'}{}{}{export classclass [[[ function ,,, @@@ ###\n',
)
const result = await buildRepoMap({
root: tempDir,
maxTokens: 1024,
files: ['good.ts', 'bad.ts'],
})
// Should complete successfully
expect(result.map.length).toBeGreaterThan(0)
// The good file should be in the output
expect(result.map).toContain('good.ts')
} finally {
rmSync(tempDir, { recursive: true, force: true })
invalidateCache(tempDir)
}
})
})

View File

@@ -1,108 +0,0 @@
import { readFileSync } from 'fs'
import { join } from 'path'
import { getLanguageForFile } from './gitFiles.js'
import { createParser, loadLanguage, loadQuery } from './parser.js'
import type { FileTags, Tag } from './types.js'
/**
* Extract definition and reference tags from a single source file.
* Returns null if the file can't be parsed (unsupported language, parse error, etc).
*/
export async function extractTags(
filePath: string,
root: string,
): Promise<FileTags | null> {
const language = getLanguageForFile(filePath)
if (!language) return null
const absolutePath = join(root, filePath)
let source: string
try {
source = readFileSync(absolutePath, 'utf-8')
} catch {
return null
}
const lines = source.split('\n')
const parser = await createParser(language)
if (!parser) return null
const querySource = loadQuery(language)
if (!querySource) {
parser.delete()
return null
}
try {
const tree = parser.parse(source) as {
rootNode: unknown
}
const lang = await loadLanguage(language)
if (!lang) {
parser.delete()
return null
}
// Use the non-deprecated Query constructor
const { Query } = await import('web-tree-sitter')
const query = new Query(lang, querySource) as {
matches(rootNode: unknown): Array<{
pattern: number
captures: Array<{
name: string
node: {
text: string
startPosition: { row: number; column: number }
endPosition: { row: number; column: number }
}
}>
}>
}
const matches = query.matches(tree.rootNode)
const tags: Tag[] = []
const seen = new Set<string>() // dedup by kind+name+line
for (const match of matches) {
let name: string | null = null
let kind: 'def' | 'ref' | null = null
let subKind: string | undefined
let lineRow = 0
for (const capture of match.captures) {
const captureName = capture.name
// Name captures: name.definition.X or name.reference.X
if (captureName.startsWith('name.definition.')) {
name = capture.node.text
kind = 'def'
subKind = captureName.slice('name.definition.'.length)
lineRow = capture.node.startPosition.row
} else if (captureName.startsWith('name.reference.')) {
name = capture.node.text
kind = 'ref'
subKind = captureName.slice('name.reference.'.length)
lineRow = capture.node.startPosition.row
}
}
if (name && kind) {
const key = `${kind}:${name}:${lineRow}`
if (!seen.has(key)) {
seen.add(key)
const line = lineRow + 1 // convert 0-based to 1-based
const signature = lines[lineRow]?.trimEnd() ?? ''
tags.push({ kind, name, line, signature, subKind })
}
}
}
parser.delete()
return { path: filePath, tags }
} catch {
parser.delete()
return null
}
}

Some files were not shown because too many files have changed in this diff Show More