Compare commits
184 Commits
v0.1.8
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e43ba9da69 | ||
|
|
6ea3eb6483 | ||
|
|
f699c1f2fc | ||
|
|
52b4c5c2ff | ||
|
|
c6c5f0608c | ||
|
|
46a9d3eec4 | ||
|
|
2586a9cddb | ||
|
|
d45628c413 | ||
|
|
6dedffe5ff | ||
|
|
a3e728a114 | ||
|
|
818689b2ee | ||
|
|
d9ae56bc58 | ||
|
|
af9a3caa4d | ||
|
|
a0d657ee18 | ||
|
|
29f7579377 | ||
|
|
9e23c2bec4 | ||
|
|
9070220292 | ||
|
|
26413f6d30 | ||
|
|
44f9cac70d | ||
|
|
ff2a380723 | ||
|
|
c4cb98a4f0 | ||
|
|
b5f7047358 | ||
|
|
64b1014b9a | ||
|
|
5a21d05741 | ||
|
|
038f715b7a | ||
|
|
b694ccfff1 | ||
|
|
dcbe29558a | ||
|
|
a4c6757023 | ||
|
|
6e58b81937 | ||
|
|
e346b8d5ec | ||
|
|
b750e9e97d | ||
|
|
28de94df5d | ||
|
|
23e8cfbd5b | ||
|
|
531e3f1059 | ||
|
|
3c4d8435c4 | ||
|
|
67de6bd2cf | ||
|
|
4d559c9135 | ||
|
|
b7b83eff13 | ||
|
|
44a2c30d5f | ||
|
|
5b9cd21e37 | ||
|
|
e92e5274b2 | ||
|
|
86bce4ae74 | ||
|
|
c13842e91c | ||
|
|
458120889f | ||
|
|
ee19159c17 | ||
|
|
13de4e85df | ||
|
|
a5bfcbbadf | ||
|
|
268c0398e4 | ||
|
|
761924daa7 | ||
|
|
e908864da7 | ||
|
|
b95d2221df | ||
|
|
2b15e16421 | ||
|
|
6a62e3ff76 | ||
|
|
06e7684eb5 | ||
|
|
ae3b723f3b | ||
|
|
a6a3de5ac1 | ||
|
|
64582c119d | ||
|
|
85eab2751e | ||
|
|
4d4fb2880e | ||
|
|
fdef4a1b4c | ||
|
|
4cb963e660 | ||
|
|
b09972f223 | ||
|
|
336ddcc50d | ||
|
|
c0b8a59a23 | ||
|
|
aab489055c | ||
|
|
7002cb302b | ||
|
|
739b8d1f40 | ||
|
|
f166ec1a4e | ||
|
|
13e9f22a83 | ||
|
|
f828171ef1 | ||
|
|
e6e8d9a248 | ||
|
|
2c98be7002 | ||
|
|
b786b765f0 | ||
|
|
55c5f262a9 | ||
|
|
002a8f1f6d | ||
|
|
3d1979ff06 | ||
|
|
b0d9fe7112 | ||
|
|
651123db1f | ||
|
|
34246635fb | ||
|
|
43ac6dba75 | ||
|
|
80a00acc2c | ||
|
|
eed77e6579 | ||
|
|
b280c740a6 | ||
|
|
2ff5710329 | ||
|
|
d6f5130c20 | ||
|
|
d32a2a1329 | ||
|
|
fbcd928f7f | ||
|
|
77083d769b | ||
|
|
b66633ea4d | ||
|
|
51191d6132 | ||
|
|
6b2121da12 | ||
|
|
c207cdbdcc | ||
|
|
a00b7928de | ||
|
|
12dd3755c6 | ||
|
|
114f772a4a | ||
|
|
7187fc007a | ||
|
|
0ed50ccfe7 | ||
|
|
131b31bf0e | ||
|
|
c1beea9867 | ||
|
|
658d076909 | ||
|
|
a07e5ef990 | ||
|
|
25ce2ca7bf | ||
|
|
1741f32cb7 | ||
|
|
fc7dc9ca0d | ||
|
|
252808bbd0 | ||
|
|
0e48884f56 | ||
|
|
b818dd5958 | ||
|
|
24d485f42f | ||
|
|
99a17144ee | ||
|
|
df2b9f2b7b | ||
|
|
adbe391e63 | ||
|
|
03e0b06e07 | ||
|
|
31be66d764 | ||
|
|
7c8bdcc3e2 | ||
|
|
64298a663f | ||
|
|
30c866d31a | ||
|
|
f6a4455ecf | ||
|
|
aeaa658f77 | ||
|
|
d2a057c6f1 | ||
|
|
08cc6f3287 | ||
|
|
84fcc7f7e0 | ||
|
|
ad11414def | ||
|
|
9419e8a4a2 | ||
|
|
41a86d05fa | ||
|
|
fa4b6a96c0 | ||
|
|
d03d77b110 | ||
|
|
15de1d6190 | ||
|
|
812facf024 | ||
|
|
2e39d2607a | ||
|
|
a3633ac094 | ||
|
|
3cefe2297d | ||
|
|
40ac164501 | ||
|
|
b3f3dc4e66 | ||
|
|
2e0e14d713 | ||
|
|
a02c44143b | ||
|
|
7817fe88bd | ||
|
|
4c50977f3c | ||
|
|
b126e38b1a | ||
|
|
6e94dd9136 | ||
|
|
91e4cfb15b | ||
|
|
f4ac709fa6 | ||
|
|
8aaa4f22ac | ||
|
|
a7f5982f64 | ||
|
|
cb8f8b7ac2 | ||
|
|
07621a6f8d | ||
|
|
692471850f | ||
|
|
68c296833d | ||
|
|
9ccaa7a675 | ||
|
|
598651f423 | ||
|
|
c385047abb | ||
|
|
42b121bd0d | ||
|
|
32fbd0c7b4 | ||
|
|
e30ad17ae0 | ||
|
|
c328fdf9e2 | ||
|
|
4ad6bc50c1 | ||
|
|
284d9bda36 | ||
|
|
537c469c3a | ||
|
|
ccaa193eec | ||
|
|
2caf2fd982 | ||
|
|
ad724dc3a4 | ||
|
|
648ae8053b | ||
|
|
3188f6ac66 | ||
|
|
69ea1f1e4a | ||
|
|
f9ce81bfb3 | ||
|
|
4975cfc2e0 | ||
|
|
600c01faf7 | ||
|
|
b07bafa5bd | ||
|
|
85aa8b0985 | ||
|
|
e365cb4010 | ||
|
|
52d33a87a0 | ||
|
|
b4bd95b477 | ||
|
|
1e057025d6 | ||
|
|
aff2bd87e4 | ||
|
|
72e6a945fe | ||
|
|
39f3b2babd | ||
|
|
ff7d49990d | ||
|
|
8ece290087 | ||
|
|
6c61790063 | ||
|
|
26eef92fe7 | ||
|
|
112df59117 | ||
|
|
8724d59d48 | ||
|
|
af08b4f762 | ||
|
|
5012c160c9 | ||
|
|
c1934974aa |
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
coverage
|
||||
reports
|
||||
vscode-extension
|
||||
python
|
||||
docs
|
||||
*.md
|
||||
!README.md
|
||||
.github
|
||||
.tsbuildinfo
|
||||
186
.env.example
186
.env.example
@@ -145,9 +145,44 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
|
||||
# CLAUDE_CODE_USE_OPENAI=1
|
||||
# OPENAI_API_KEY=sk-your-key-here
|
||||
# OPENAI_MODEL=gpt-4o
|
||||
# For DeepSeek, set:
|
||||
# OPENAI_BASE_URL=https://api.deepseek.com/v1
|
||||
# OPENAI_MODEL=deepseek-v4-flash
|
||||
# Optional: OPENAI_MODEL=deepseek-v4-pro
|
||||
# Legacy aliases also work: deepseek-chat and deepseek-reasoner
|
||||
# For Z.AI GLM Coding Plan, set:
|
||||
# OPENAI_BASE_URL=https://api.z.ai/api/coding/paas/v4
|
||||
# OPENAI_MODEL=GLM-5.1
|
||||
# Optional: OPENAI_MODEL=GLM-5-Turbo, GLM-4.7, or GLM-4.5-Air
|
||||
|
||||
# Use a custom OpenAI-compatible endpoint (optional — defaults to api.openai.com)
|
||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
# Choose the OpenAI-compatible API surface (optional — defaults to chat_completions)
|
||||
# Supported: chat_completions, responses
|
||||
# OPENAI_API_FORMAT=chat_completions
|
||||
# Choose a custom auth header for OpenAI-compatible providers (optional).
|
||||
# Authorization defaults to Bearer; custom headers default to the raw API key.
|
||||
# Set OPENAI_AUTH_HEADER_VALUE when the header value differs from OPENAI_API_KEY.
|
||||
# OPENAI_AUTH_HEADER=api-key
|
||||
# OPENAI_AUTH_SCHEME=raw
|
||||
# OPENAI_AUTH_HEADER_VALUE=your-header-value-here
|
||||
|
||||
# Fallback context window size (tokens) when the model is not found in the
|
||||
# built-in table (default: 128000). Increase this for models with larger
|
||||
# context windows (e.g. 200000 for Claude-sized contexts).
|
||||
# CLAUDE_CODE_OPENAI_FALLBACK_CONTEXT_WINDOW=128000
|
||||
|
||||
# Per-model context window overrides as a JSON object.
|
||||
# Takes precedence over the built-in table, so you can register new or
|
||||
# custom models without patching source.
|
||||
# Example: CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS={"my-corp/llm-v3":262144,"gpt-4o-mini":128000}
|
||||
# CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS=
|
||||
|
||||
# Per-model maximum output token overrides as a JSON object.
|
||||
# Use this alongside CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS when your model
|
||||
# supports a different output limit than what the built-in table specifies.
|
||||
# Example: CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS={"my-corp/llm-v3":8192}
|
||||
# CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS=
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -225,6 +260,30 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
|
||||
# GOOGLE_CLOUD_PROJECT=your-gcp-project-id
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Option 9: NVIDIA NIM
|
||||
# -----------------------------------------------------------------------------
|
||||
# NVIDIA NIM provides hosted inference endpoints for NVIDIA models.
|
||||
# Get your API key from https://build.nvidia.com/
|
||||
#
|
||||
# CLAUDE_CODE_USE_OPENAI=1
|
||||
# NVIDIA_API_KEY=nvapi-your-key-here
|
||||
# OPENAI_BASE_URL=https://integrate.api.nvidia.com/v1
|
||||
# OPENAI_MODEL=nvidia/llama-3.1-nemotron-70b-instruct
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Option 10: MiniMax
|
||||
# -----------------------------------------------------------------------------
|
||||
# MiniMax API provides text generation models.
|
||||
# Get your API key from https://platform.minimax.io/
|
||||
#
|
||||
# CLAUDE_CODE_USE_OPENAI=1
|
||||
# MINIMAX_API_KEY=your-minimax-key-here
|
||||
# OPENAI_BASE_URL=https://api.minimax.io/v1
|
||||
# OPENAI_MODEL=MiniMax-M2.5
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OPTIONAL TUNING
|
||||
# =============================================================================
|
||||
@@ -243,8 +302,135 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
|
||||
# Disable "Co-authored-by" line in git commits made by OpenClaude
|
||||
# OPENCLAUDE_DISABLE_CO_AUTHORED_BY=1
|
||||
|
||||
# Disable strict tool schema normalization for non-Gemini providers
|
||||
# Useful when MCP tools with complex optional params (e.g. list[dict])
|
||||
# trigger "Extra required key ... supplied" errors from OpenAI-compatible endpoints
|
||||
# OPENCLAUDE_DISABLE_STRICT_TOOLS=1
|
||||
|
||||
# Disable hidden <system-reminder> messages injected into tool output
|
||||
# Suppresses the file-read cyber-risk reminder and the todo/task tool nudges
|
||||
# Useful for users who want full transparency over what the model sees
|
||||
# OPENCLAUDE_DISABLE_TOOL_REMINDERS=1
|
||||
|
||||
# Log structured per-request token usage (including cache metrics) to stderr.
|
||||
# Useful for auditing cache hit rate / debugging cost spikes outside the REPL.
|
||||
# Any truthy value enables it ("verbose", "1", "true").
|
||||
#
|
||||
# Complements (does NOT replace) CLAUDE_CODE_ENABLE_TOKEN_USAGE_ATTACHMENT —
|
||||
# they serve different audiences:
|
||||
# - OPENCLAUDE_LOG_TOKEN_USAGE is user-facing: one JSON line per API
|
||||
# request on stderr, intended for humans inspecting cost/caching.
|
||||
# - CLAUDE_CODE_ENABLE_TOKEN_USAGE_ATTACHMENT is model-facing: injects
|
||||
# a context-usage attachment INTO the prompt so the model can reason
|
||||
# about its own remaining context. Does not touch stderr.
|
||||
# Turn on whichever audience you're debugging; both can run together.
|
||||
# OPENCLAUDE_LOG_TOKEN_USAGE=verbose
|
||||
|
||||
# Custom timeout for API requests in milliseconds (default: varies)
|
||||
# API_TIMEOUT_MS=60000
|
||||
|
||||
# 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)
|
||||
|
||||
# ── Config directory override ───────────────────────────────────────
|
||||
#
|
||||
# By default openclaude stores per-user state under ~/.openclaude
|
||||
# (and falls back to ~/.claude for installs that pre-date the rename).
|
||||
# Set this to point openclaude at a different directory — useful for
|
||||
# isolating profiles or sharing config across machines.
|
||||
#
|
||||
# OPENCLAUDE_CONFIG_DIR=/path/to/dir — preferred name
|
||||
# CLAUDE_CONFIG_DIR=/path/to/dir — legacy alias (still works)
|
||||
#
|
||||
# When both are set with different values, OPENCLAUDE_CONFIG_DIR wins
|
||||
# and a warning is logged once per process.
|
||||
|
||||
13
.github/workflows/pr-checks.yml
vendored
13
.github/workflows/pr-checks.yml
vendored
@@ -29,6 +29,13 @@ 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
|
||||
|
||||
@@ -38,6 +45,12 @@ 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
|
||||
|
||||
144
.github/workflows/release.yml
vendored
Normal file
144
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,144 @@
|
||||
name: Auto Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: auto-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
if: ${{ github.repository == 'Gitlawb/openclaude' }}
|
||||
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
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -6,4 +6,9 @@ dist/
|
||||
!.env.example
|
||||
.openclaude-profile.json
|
||||
reports/
|
||||
GEMINI.md
|
||||
CLAUDE.md
|
||||
package-lock.json
|
||||
/.claude
|
||||
coverage/
|
||||
agent.log
|
||||
|
||||
3
.release-please-manifest.json
Normal file
3
.release-please-manifest.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "0.7.0"
|
||||
}
|
||||
210
CHANGELOG.md
Normal file
210
CHANGELOG.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Changelog
|
||||
|
||||
## [0.7.0](https://github.com/Gitlawb/openclaude/compare/v0.6.0...v0.7.0) (2026-04-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add model-specific tokenizers and compression ratio detection ([#799](https://github.com/Gitlawb/openclaude/issues/799)) ([e92e527](https://github.com/Gitlawb/openclaude/commit/e92e5274b223d935d380b1fbd234cb631ab03211))
|
||||
* add OPENCLAUDE_DISABLE_TOOL_REMINDERS env var to suppress hidden tool-output reminders ([#837](https://github.com/Gitlawb/openclaude/issues/837)) ([28de94d](https://github.com/Gitlawb/openclaude/commit/28de94df5dcd7718cb334e2e793e9472f5b291c5)), closes [#809](https://github.com/Gitlawb/openclaude/issues/809)
|
||||
* add streaming optimizer and structured request logging ([#703](https://github.com/Gitlawb/openclaude/issues/703)) ([5b9cd21](https://github.com/Gitlawb/openclaude/commit/5b9cd21e373823a77fd552d6e02f5d4b68ae06b1))
|
||||
* add xAI as official provider ([#865](https://github.com/Gitlawb/openclaude/issues/865)) ([2586a9c](https://github.com/Gitlawb/openclaude/commit/2586a9cddbd2512826bca81cb5deb3ec97f00f0f))
|
||||
* **api:** expose cache metrics in REPL + normalize across providers ([#813](https://github.com/Gitlawb/openclaude/issues/813)) ([9e23c2b](https://github.com/Gitlawb/openclaude/commit/9e23c2bec43697187762601db5b1585c9b0fb1a3))
|
||||
* implement Hook Chains runtime integration for self-healing agent mesh MVP ([#711](https://github.com/Gitlawb/openclaude/issues/711)) ([44a2c30](https://github.com/Gitlawb/openclaude/commit/44a2c30d5f9b98027e454466c680360f6b4625fc))
|
||||
* **memory:** implement persistent project-level Knowledge Graph and RAG ([#899](https://github.com/Gitlawb/openclaude/issues/899)) ([29f7579](https://github.com/Gitlawb/openclaude/commit/29f757937732be0f8cca2bc0627a27eeafc2a992))
|
||||
* **minimax:** add /usage support and fix MiniMax quota parsing ([#869](https://github.com/Gitlawb/openclaude/issues/869)) ([26413f6](https://github.com/Gitlawb/openclaude/commit/26413f6d307928a4f14c9c61c9860a28f8d81358))
|
||||
* **model:** add GPT-5.5 support for Codex provider ([#880](https://github.com/Gitlawb/openclaude/issues/880)) ([038f715](https://github.com/Gitlawb/openclaude/commit/038f715b7ab9714340bda421b73a86d8590cf531))
|
||||
* **tools:** resilient web search and fetch across all providers ([#836](https://github.com/Gitlawb/openclaude/issues/836)) ([531e3f1](https://github.com/Gitlawb/openclaude/commit/531e3f10592a73d81f26675c2479d46a3d5b55f5))
|
||||
* **zai:** add Z.AI GLM Coding Plan provider preset ([#896](https://github.com/Gitlawb/openclaude/issues/896)) ([a0d657e](https://github.com/Gitlawb/openclaude/commit/a0d657ee188f52f8a4ceaad1658c81343a32fdad))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **agent:** provider-aware fallback for haiku/sonnet aliases ([#908](https://github.com/Gitlawb/openclaude/issues/908)) ([a3e728a](https://github.com/Gitlawb/openclaude/commit/a3e728a114f6379b80daefc8abcac17a752c5f96))
|
||||
* bugs ([#885](https://github.com/Gitlawb/openclaude/issues/885)) ([c6c5f06](https://github.com/Gitlawb/openclaude/commit/c6c5f0608cf6509b412b121954547d72b3f3a411))
|
||||
* make OpenAI fallback context window configurable + support external model lookup ([#861](https://github.com/Gitlawb/openclaude/issues/861)) ([b750e9e](https://github.com/Gitlawb/openclaude/commit/b750e9e97d15926d094d435772b2d6d12e5e545c))
|
||||
* **mcp:** disable MCP_SKILLS feature flag — source not mirrored ([#872](https://github.com/Gitlawb/openclaude/issues/872)) ([dcbe295](https://github.com/Gitlawb/openclaude/commit/dcbe29558ab9c74d335b138488005a6509aa906a))
|
||||
* normalize /provider multi-model selection and semicolon parsing ([#841](https://github.com/Gitlawb/openclaude/issues/841)) ([c4cb98a](https://github.com/Gitlawb/openclaude/commit/c4cb98a4f092062da02a4728cf59fed0fc3a6d3f))
|
||||
* **openai-shim:** echo reasoning_content on assistant tool-call messages for Moonshot ([#828](https://github.com/Gitlawb/openclaude/issues/828)) ([67de6bd](https://github.com/Gitlawb/openclaude/commit/67de6bd2cffc3381f0f28fd3ffce043970611667))
|
||||
* **query:** restore system prompt structure and add missing config import ([#907](https://github.com/Gitlawb/openclaude/issues/907)) ([818689b](https://github.com/Gitlawb/openclaude/commit/818689b2ee71cb6966cb4dc5a5ebd90fd22b0fcb))
|
||||
* **shell:** recover when CWD path was replaced by a non-directory ([#871](https://github.com/Gitlawb/openclaude/issues/871)) ([a4c6757](https://github.com/Gitlawb/openclaude/commit/a4c67570238794317d049a225396672b465fdbfc))
|
||||
* **startup:** show --model flag override on startup screen ([#898](https://github.com/Gitlawb/openclaude/issues/898)) ([d45628c](https://github.com/Gitlawb/openclaude/commit/d45628c41300b83b466e6a97983099615a50e7d7))
|
||||
* **startup:** url authoritative over model name in banner provider detect ([#864](https://github.com/Gitlawb/openclaude/issues/864)) ([e346b8d](https://github.com/Gitlawb/openclaude/commit/e346b8d5ec2d58a4e8db337918d52d844ee52766)), closes [#855](https://github.com/Gitlawb/openclaude/issues/855)
|
||||
* surface actionable error when DuckDuckGo web search is rate-limited ([#834](https://github.com/Gitlawb/openclaude/issues/834)) ([3c4d843](https://github.com/Gitlawb/openclaude/commit/3c4d8435c42e1ee04f9defd31c4c589017f524c5))
|
||||
* **test:** add missing teammate exports to hookChains integration mock ([#840](https://github.com/Gitlawb/openclaude/issues/840)) ([23e8cfb](https://github.com/Gitlawb/openclaude/commit/23e8cfbd5b22179684276bef4131e26b830ce69c)), closes [#839](https://github.com/Gitlawb/openclaude/issues/839)
|
||||
* **update:** show real package version and give actionable guidance ([#870](https://github.com/Gitlawb/openclaude/issues/870)) ([6e58b81](https://github.com/Gitlawb/openclaude/commit/6e58b819370128b923dda4fcc774bb556f4b951a))
|
||||
|
||||
## [0.6.0](https://github.com/Gitlawb/openclaude/compare/v0.5.2...v0.6.0) (2026-04-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add model caching and benchmarking utilities ([#671](https://github.com/Gitlawb/openclaude/issues/671)) ([2b15e16](https://github.com/Gitlawb/openclaude/commit/2b15e16421f793f954a92c53933a07094544b29d))
|
||||
* add thinking token extraction ([#798](https://github.com/Gitlawb/openclaude/issues/798)) ([268c039](https://github.com/Gitlawb/openclaude/commit/268c0398e4bf1ab898069c61500a2b3c226a0322))
|
||||
* **api:** compress old tool_result content for small-context providers ([#801](https://github.com/Gitlawb/openclaude/issues/801)) ([a6a3de5](https://github.com/Gitlawb/openclaude/commit/a6a3de5ac155fe9d00befbfcab98d439314effd8))
|
||||
* **api:** improve local provider reliability with readiness and self-healing ([#738](https://github.com/Gitlawb/openclaude/issues/738)) ([4cb963e](https://github.com/Gitlawb/openclaude/commit/4cb963e660dbd6ee438c04042700db05a9d32c59))
|
||||
* **api:** smart model routing primitive (cheap-for-simple, strong-for-hard) ([#785](https://github.com/Gitlawb/openclaude/issues/785)) ([e908864](https://github.com/Gitlawb/openclaude/commit/e908864da7e7c987a98053ac5d18d702e192db2b))
|
||||
* enable 15 additional feature flags in open build ([#667](https://github.com/Gitlawb/openclaude/issues/667)) ([6a62e3f](https://github.com/Gitlawb/openclaude/commit/6a62e3ff76ba9ba446b8e20cf2bb139ee76a9387))
|
||||
* native Anthropic API mode for Claude models on GitHub Copilot ([#579](https://github.com/Gitlawb/openclaude/issues/579)) ([fdef4a1](https://github.com/Gitlawb/openclaude/commit/fdef4a1b4ce218ded4937ca83b30acce7c726472))
|
||||
* **provider:** expose Atomic Chat in /provider picker with autodetect ([#810](https://github.com/Gitlawb/openclaude/issues/810)) ([ee19159](https://github.com/Gitlawb/openclaude/commit/ee19159c17b3de3b4a8b4a4541a6569f4261d54e))
|
||||
* **provider:** zero-config autodetection primitive ([#784](https://github.com/Gitlawb/openclaude/issues/784)) ([a5bfcbb](https://github.com/Gitlawb/openclaude/commit/a5bfcbbadf8e9a1fd42f3e103d295524b8da64b0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** ensure strict role sequence and filter empty assistant messages after interruption ([#745](https://github.com/Gitlawb/openclaude/issues/745) regression) ([#794](https://github.com/Gitlawb/openclaude/issues/794)) ([06e7684](https://github.com/Gitlawb/openclaude/commit/06e7684eb56df8e694ac784575e163641931c44c))
|
||||
* Collapse all-text arrays to string for DeepSeek compatibility ([#806](https://github.com/Gitlawb/openclaude/issues/806)) ([761924d](https://github.com/Gitlawb/openclaude/commit/761924daa7e225fe8acf41651408c7cae639a511))
|
||||
* **model:** codex/nvidia-nim/minimax now read OPENAI_MODEL env ([#815](https://github.com/Gitlawb/openclaude/issues/815)) ([4581208](https://github.com/Gitlawb/openclaude/commit/458120889f6ce54cc9f0b287461d5e38eae48a20))
|
||||
* **provider:** saved profile ignored when stale CLAUDE_CODE_USE_* in shell ([#807](https://github.com/Gitlawb/openclaude/issues/807)) ([13de4e8](https://github.com/Gitlawb/openclaude/commit/13de4e85df7f5fadc8cd15a76076374dc112360b))
|
||||
* rename .claude.json to .openclaude.json with legacy fallback ([#582](https://github.com/Gitlawb/openclaude/issues/582)) ([4d4fb28](https://github.com/Gitlawb/openclaude/commit/4d4fb2880e4d0e3a62d8715e1ec13d932e736279))
|
||||
* replace discontinued gemini-2.5-pro-preview-03-25 with stable gemini-2.5-pro ([#802](https://github.com/Gitlawb/openclaude/issues/802)) ([64582c1](https://github.com/Gitlawb/openclaude/commit/64582c119d5d0278195271379da4a68d59a89c1f)), closes [#398](https://github.com/Gitlawb/openclaude/issues/398)
|
||||
* **security:** harden project settings trust boundary + MCP sanitization ([#789](https://github.com/Gitlawb/openclaude/issues/789)) ([ae3b723](https://github.com/Gitlawb/openclaude/commit/ae3b723f3b297b49925cada4728f3174aee8bf12))
|
||||
* **test:** autoCompact floor assertion is flag-sensitive ([#816](https://github.com/Gitlawb/openclaude/issues/816)) ([c13842e](https://github.com/Gitlawb/openclaude/commit/c13842e91c7227246520955de6ae0636b30def9a))
|
||||
* **ui:** prevent provider manager lag by deferring sync I/O ([#803](https://github.com/Gitlawb/openclaude/issues/803)) ([85eab27](https://github.com/Gitlawb/openclaude/commit/85eab2751e7d351bb0ed6a3fe0e15461d241c9cb))
|
||||
|
||||
## [0.5.2](https://github.com/Gitlawb/openclaude/compare/v0.5.1...v0.5.2) (2026-04-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** replace phrase-based reasoning sanitizer with tag-based filter ([#779](https://github.com/Gitlawb/openclaude/issues/779)) ([336ddcc](https://github.com/Gitlawb/openclaude/commit/336ddcc50d59d79ebff50993f2673652aecb0d7d))
|
||||
|
||||
## [0.5.1](https://github.com/Gitlawb/openclaude/compare/v0.5.0...v0.5.1) (2026-04-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enforce Bash path constraints after sandbox allow ([#777](https://github.com/Gitlawb/openclaude/issues/777)) ([7002cb3](https://github.com/Gitlawb/openclaude/commit/7002cb302b78ea2a19da3f26226de24e2903fa1d))
|
||||
* enforce MCP OAuth callback state before errors ([#775](https://github.com/Gitlawb/openclaude/issues/775)) ([739b8d1](https://github.com/Gitlawb/openclaude/commit/739b8d1f40fde0e401a5cbd2b9a55d88bd5124ad))
|
||||
* require trusted approval for sandbox override ([#778](https://github.com/Gitlawb/openclaude/issues/778)) ([aab4890](https://github.com/Gitlawb/openclaude/commit/aab489055c53dd64369414116fe93226d2656273))
|
||||
|
||||
## [0.5.0](https://github.com/Gitlawb/openclaude/compare/v0.4.0...v0.5.0) (2026-04-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add OPENCLAUDE_DISABLE_STRICT_TOOLS env var to opt out of strict MCP tool schema normalization ([#770](https://github.com/Gitlawb/openclaude/issues/770)) ([e6e8d9a](https://github.com/Gitlawb/openclaude/commit/e6e8d9a24897e4c9ef08b72df20fabbf8ef27f38))
|
||||
* mask provider api key input ([#772](https://github.com/Gitlawb/openclaude/issues/772)) ([13e9f22](https://github.com/Gitlawb/openclaude/commit/13e9f22a83a2b0f85f557b1e12c9442ba61241e4))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow provider recovery during startup ([#765](https://github.com/Gitlawb/openclaude/issues/765)) ([f828171](https://github.com/Gitlawb/openclaude/commit/f828171ef1ab94e2acf73a28a292799e4e26cc0d))
|
||||
* **api:** drop orphan tool results to satisfy strict role sequence ([#745](https://github.com/Gitlawb/openclaude/issues/745)) ([b786b76](https://github.com/Gitlawb/openclaude/commit/b786b765f01f392652eaf28ed3579a96b7260a53))
|
||||
* **help:** prevent /help tab crash from undefined descriptions ([#732](https://github.com/Gitlawb/openclaude/issues/732)) ([3d1979f](https://github.com/Gitlawb/openclaude/commit/3d1979ff066db32415e0c8321af916d81f5f2621))
|
||||
* **mcp:** sync required array with properties in tool schemas ([#754](https://github.com/Gitlawb/openclaude/issues/754)) ([002a8f1](https://github.com/Gitlawb/openclaude/commit/002a8f1f6de2fcfc917165d828501d3047bad61f))
|
||||
* remove cached mcpClient in diagnostic tracking to prevent stale references ([#727](https://github.com/Gitlawb/openclaude/issues/727)) ([2c98be7](https://github.com/Gitlawb/openclaude/commit/2c98be700274a4241963b5f43530bf3bd8f8963f))
|
||||
* use raw context window for auto-compact percentage display ([#748](https://github.com/Gitlawb/openclaude/issues/748)) ([55c5f26](https://github.com/Gitlawb/openclaude/commit/55c5f262a9a5a8be0aa9ae8dc6c7dafc465eb2c6))
|
||||
|
||||
## [0.4.0](https://github.com/Gitlawb/openclaude/compare/v0.3.0...v0.4.0) (2026-04-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add Alibaba Coding Plan (DashScope) provider support ([#509](https://github.com/Gitlawb/openclaude/issues/509)) ([43ac6db](https://github.com/Gitlawb/openclaude/commit/43ac6dba75537282da1e2ad8f855082bc4e25f1e))
|
||||
* add NVIDIA NIM and MiniMax provider support ([#552](https://github.com/Gitlawb/openclaude/issues/552)) ([51191d6](https://github.com/Gitlawb/openclaude/commit/51191d61326e1f8319d70b3a3c0d9229e185a564))
|
||||
* add ripgrep to Dockerfile for faster file searching ([#688](https://github.com/Gitlawb/openclaude/issues/688)) ([12dd375](https://github.com/Gitlawb/openclaude/commit/12dd3755c619cc27af3b151ae8fdb9d425a7b9a2))
|
||||
* **api:** classify openai-compatible provider failures ([#708](https://github.com/Gitlawb/openclaude/issues/708)) ([80a00ac](https://github.com/Gitlawb/openclaude/commit/80a00acc2c6dc4657a78de7366f7a9ebc920bfbb))
|
||||
* **vscode:** add full chat interface to OpenClaude extension ([#608](https://github.com/Gitlawb/openclaude/issues/608)) ([fbcd928](https://github.com/Gitlawb/openclaude/commit/fbcd928f7f8511da795aea3ad318bddf0ab9a1a7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* focus "Done" option after completing provider manager actions ([#718](https://github.com/Gitlawb/openclaude/issues/718)) ([d6f5130](https://github.com/Gitlawb/openclaude/commit/d6f5130c204d8ffe582212466768706cd7fd6774))
|
||||
* **models:** prevent /models crash from non-string saved model values ([#691](https://github.com/Gitlawb/openclaude/issues/691)) ([6b2121d](https://github.com/Gitlawb/openclaude/commit/6b2121da12189fa7ce1f33394d18abd24cf8a01b))
|
||||
* prevent crash in commands tab when description is undefined ([#730](https://github.com/Gitlawb/openclaude/issues/730)) ([eed77e6](https://github.com/Gitlawb/openclaude/commit/eed77e6579866a98384dcc948a0ad6406614ede3))
|
||||
* strip comments before scanning for missing imports ([#676](https://github.com/Gitlawb/openclaude/issues/676)) ([a00b792](https://github.com/Gitlawb/openclaude/commit/a00b7928de9662ffb7ef6abd8cd040afe6f4f122))
|
||||
* **ui:** show correct endpoint URL in intro screen for custom Anthropic endpoints ([#735](https://github.com/Gitlawb/openclaude/issues/735)) ([3424663](https://github.com/Gitlawb/openclaude/commit/34246635fb9a09499047a52e7f96ca9b36c8a85a))
|
||||
|
||||
## [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))
|
||||
46
Dockerfile
Normal file
46
Dockerfile
Normal file
@@ -0,0 +1,46 @@
|
||||
# ---- 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 and ripgrep — many CLI tool operations depend on them
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ripgrep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Run as non-root user
|
||||
USER node
|
||||
|
||||
ENTRYPOINT ["node", "/app/dist/cli.mjs"]
|
||||
@@ -132,7 +132,7 @@ Cause:
|
||||
Fix:
|
||||
|
||||
```powershell
|
||||
cd C:\Users\Lucas Pedry\Documents\openclaude\openclaude
|
||||
cd <PATH>
|
||||
bun run dev:profile
|
||||
```
|
||||
|
||||
@@ -189,7 +189,7 @@ Or pick a local Ollama profile automatically by goal:
|
||||
bun run profile:init -- --provider ollama --goal balanced
|
||||
```
|
||||
|
||||
## 6.5 Placeholder key (`SUA_CHAVE`) error
|
||||
## 6.5 Placeholder key (`YOUR_KEY`) error
|
||||
|
||||
Cause:
|
||||
|
||||
|
||||
94
README.md
94
README.md
@@ -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, 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 OAuth, Codex, Ollama, Atomic Chat, and other supported backends while keeping one terminal-first workflow: prompts, tools, agents, MCP, slash commands, and streaming output.
|
||||
|
||||
[](https://github.com/Gitlawb/openclaude/actions/workflows/pr-checks.yml)
|
||||
[](https://github.com/Gitlawb/openclaude/tags)
|
||||
@@ -10,13 +10,38 @@ Use OpenAI-compatible APIs, Gemini, GitHub Models, Codex, Ollama, Atomic Chat, a
|
||||
[](SECURITY.md)
|
||||
[](LICENSE)
|
||||
|
||||
[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)
|
||||
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) | [Sponsors](#sponsors) | [Community](#community)
|
||||
|
||||
## Sponsors
|
||||
|
||||
<p align="center">
|
||||
<a href="https://gitlawb.com">
|
||||
<img src="https://gitlawb.com/logo.png" alt="GitLawb logo" width="96">
|
||||
</a>
|
||||
|
||||
<a href="https://bankr.bot">
|
||||
<img src="https://bankr.bot/favicon.svg" alt="Bankr.bot logo" width="96">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://gitlawb.com"><strong>GitLawb</strong></a>
|
||||
|
||||
<a href="https://bankr.bot"><strong>Bankr.bot</strong></a>
|
||||
</p>
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/?repos=gitlawb%2Fopenclaude&type=date&legend=top-left)
|
||||
|
||||
## 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, Ollama, Atomic Chat, and other supported providers
|
||||
- Run with OpenAI-compatible services, Gemini, GitHub Models, Codex OAuth, 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
|
||||
|
||||
@@ -85,6 +110,16 @@ $env:OPENAI_MODEL="qwen2.5-coder:7b"
|
||||
openclaude
|
||||
```
|
||||
|
||||
### Using Ollama's launch command
|
||||
|
||||
If you have [Ollama](https://ollama.com) installed, you can skip the env var setup entirely:
|
||||
|
||||
```bash
|
||||
ollama launch openclaude --model qwen2.5-coder:7b
|
||||
```
|
||||
|
||||
This automatically sets `ANTHROPIC_BASE_URL`, model routing, and auth so all API traffic goes through your local Ollama instance. Works with any model you have pulled — local or cloud.
|
||||
|
||||
## Setup Guides
|
||||
|
||||
Beginner-friendly guides:
|
||||
@@ -105,9 +140,10 @@ 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 | `/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 |
|
||||
| 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 |
|
||||
| Ollama | `/provider`, env vars, or `ollama launch` | Local inference with no API key |
|
||||
| Atomic Chat | `/provider`, env vars, or `bun run dev:atomic-chat` | Local Model Provider; auto-detects loaded models |
|
||||
| Bedrock / Vertex / Foundry | env vars | Additional provider integrations for supported environments |
|
||||
|
||||
## What Works
|
||||
@@ -134,12 +170,12 @@ For best results, use models with strong tool/function calling support.
|
||||
|
||||
OpenClaude can route different agents to different models through settings-based routing. This is useful for cost optimization or splitting work by model strength.
|
||||
|
||||
Add to `~/.claude/settings.json`:
|
||||
Add to `~/.openclaude.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agentModels": {
|
||||
"deepseek-chat": {
|
||||
"deepseek-v4-flash": {
|
||||
"base_url": "https://api.deepseek.com/v1",
|
||||
"api_key": "sk-your-key"
|
||||
},
|
||||
@@ -149,10 +185,10 @@ Add to `~/.claude/settings.json`:
|
||||
}
|
||||
},
|
||||
"agentRouting": {
|
||||
"Explore": "deepseek-chat",
|
||||
"Explore": "deepseek-v4-flash",
|
||||
"Plan": "gpt-4o",
|
||||
"general-purpose": "gpt-4o",
|
||||
"frontend-dev": "deepseek-chat",
|
||||
"frontend-dev": "deepseek-v4-flash",
|
||||
"default": "gpt-4o"
|
||||
}
|
||||
}
|
||||
@@ -185,6 +221,41 @@ With Firecrawl enabled:
|
||||
|
||||
Free tier at [firecrawl.dev](https://firecrawl.dev) includes 500 credits. The key is optional.
|
||||
|
||||
---
|
||||
|
||||
## Headless gRPC Server
|
||||
|
||||
OpenClaude can be run as a headless gRPC service, allowing you to integrate its agentic capabilities (tools, bash, file editing) into other applications, CI/CD pipelines, or custom user interfaces. The server uses bidirectional streaming to send real-time text chunks, tool calls, and request permissions for sensitive commands.
|
||||
|
||||
### 1. Start the gRPC Server
|
||||
|
||||
Start the core engine as a gRPC service on `localhost:50051`:
|
||||
|
||||
```bash
|
||||
npm run dev:grpc
|
||||
```
|
||||
|
||||
#### Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
|-----------|-------------|------------------------------------------------|
|
||||
| `GRPC_PORT` | `50051` | Port the gRPC server listens on |
|
||||
| `GRPC_HOST` | `localhost` | Bind address. Use `0.0.0.0` to expose on all interfaces (not recommended without authentication) |
|
||||
|
||||
### 2. Run the Test CLI Client
|
||||
|
||||
We provide a lightweight CLI client that communicates exclusively over gRPC. It acts just like the main interactive CLI, rendering colors, streaming tokens, and prompting you for tool permissions (y/n) via the gRPC `action_required` event.
|
||||
|
||||
In a separate terminal, run:
|
||||
|
||||
```bash
|
||||
npm run dev:grpc:cli
|
||||
```
|
||||
|
||||
*Note: The gRPC definitions are located in `src/proto/openclaude.proto`. You can use this file to generate clients in Python, Go, Rust, or any other language.*
|
||||
|
||||
---
|
||||
|
||||
## Source Build And Local Development
|
||||
|
||||
```bash
|
||||
@@ -278,7 +349,8 @@ For larger changes, open an issue first so the scope is clear before implementat
|
||||
- `bun run build`
|
||||
- `bun run test:coverage`
|
||||
- `bun run smoke`
|
||||
- focused `bun test ...` runs for touched areas
|
||||
- focused `bun test ...` runs for files and flows you changed
|
||||
|
||||
|
||||
## Disclaimer
|
||||
|
||||
|
||||
125
bun.lock
125
bun.lock
@@ -13,6 +13,8 @@
|
||||
"@anthropic-ai/vertex-sdk": "0.14.4",
|
||||
"@commander-js/extra-typings": "12.1.0",
|
||||
"@growthbook/growthbook": "1.6.5",
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@mendable/firecrawl-js": "4.18.1",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opentelemetry/api": "1.9.1",
|
||||
@@ -28,7 +30,7 @@
|
||||
"@opentelemetry/semantic-conventions": "1.40.0",
|
||||
"ajv": "8.18.0",
|
||||
"auto-bind": "5.0.1",
|
||||
"axios": "1.14.0",
|
||||
"axios": "1.15.0",
|
||||
"bidi-js": "1.0.3",
|
||||
"chalk": "5.6.2",
|
||||
"chokidar": "4.0.3",
|
||||
@@ -84,6 +86,7 @@
|
||||
"@types/bun": "1.3.11",
|
||||
"@types/node": "25.5.0",
|
||||
"@types/react": "19.2.14",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
},
|
||||
},
|
||||
@@ -184,6 +187,58 @@
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
|
||||
|
||||
"@growthbook/growthbook": ["@growthbook/growthbook@1.6.5", "", { "dependencies": { "dom-mutator": "^0.6.0" } }, "sha512-mUaMsgeUTpRIUOTn33EUXHRK6j7pxBjwqH4WpQyq+pukjd1AIzWlEa6w7i6bInJUcweGgP2beXZmaP6b6UPn7A=="],
|
||||
|
||||
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="],
|
||||
@@ -424,7 +479,7 @@
|
||||
|
||||
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
|
||||
|
||||
"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=="],
|
||||
"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=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
@@ -456,7 +511,7 @@
|
||||
|
||||
"cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="],
|
||||
|
||||
"cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="],
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="],
|
||||
|
||||
@@ -524,6 +579,8 @@
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
@@ -570,6 +627,8 @@
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"fuse.js": ["fuse.js@7.1.0", "", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="],
|
||||
@@ -588,6 +647,8 @@
|
||||
|
||||
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="],
|
||||
|
||||
"google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="],
|
||||
|
||||
"google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="],
|
||||
@@ -764,6 +825,8 @@
|
||||
|
||||
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
@@ -834,6 +897,8 @@
|
||||
|
||||
"tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
|
||||
|
||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||
|
||||
"turndown": ["turndown@7.2.2", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ=="],
|
||||
|
||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
@@ -884,9 +949,9 @@
|
||||
|
||||
"yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
|
||||
|
||||
"yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="],
|
||||
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
|
||||
@@ -1086,7 +1151,7 @@
|
||||
|
||||
"@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@grpc/proto-loader/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
"@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=="],
|
||||
|
||||
@@ -1306,12 +1371,16 @@
|
||||
|
||||
"cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"cli-highlight/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="],
|
||||
|
||||
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"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=="],
|
||||
@@ -1360,12 +1429,6 @@
|
||||
|
||||
"@aws-sdk/nested-clients/@smithy/util-base64/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
|
||||
|
||||
"@grpc/proto-loader/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"@grpc/proto-loader/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"@grpc/proto-loader/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A=="],
|
||||
@@ -1432,6 +1495,12 @@
|
||||
|
||||
"cli-highlight/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"cli-highlight/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="],
|
||||
|
||||
"cli-highlight/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"cli-highlight/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
|
||||
|
||||
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
@@ -1472,16 +1541,6 @@
|
||||
|
||||
"@aws-sdk/nested-clients/@smithy/util-base64/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="],
|
||||
|
||||
"@grpc/proto-loader/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"@grpc/proto-loader/yargs/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=="],
|
||||
|
||||
"@grpc/proto-loader/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"@grpc/proto-loader/yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"@grpc/proto-loader/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
|
||||
|
||||
"@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
|
||||
@@ -1502,6 +1561,16 @@
|
||||
|
||||
"@smithy/smithy-client/@smithy/util-stream/@smithy/node-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@2.2.0", "", { "dependencies": { "@smithy/types": "^2.12.0", "@smithy/util-uri-escape": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-L1kSeviUWL+emq3CUVSgdogoM/D9QMFaqxL/dd0X7PCNWmPXqt+ExtrBjqT0V7HLN03Vs9SuiLrG3zy3JGnE5A=="],
|
||||
|
||||
"cli-highlight/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"cli-highlight/yargs/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=="],
|
||||
|
||||
"cli-highlight/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"cli-highlight/yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"cli-highlight/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"qrcode/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
@@ -1514,16 +1583,16 @@
|
||||
|
||||
"yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"@grpc/proto-loader/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"@grpc/proto-loader/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"@grpc/proto-loader/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"@smithy/smithy-client/@smithy/util-stream/@smithy/fetch-http-handler/@smithy/querystring-builder/@smithy/util-uri-escape": ["@smithy/util-uri-escape@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-jtmJMyt1xMD/d8OtbVJ2gFZOSKc+ueYJZPW20ULW1GOp/q/YIM0wNh+u8ZFao9UaIGz4WoPW8hC64qlWLIfoDA=="],
|
||||
|
||||
"@smithy/smithy-client/@smithy/util-stream/@smithy/node-http-handler/@smithy/querystring-builder/@smithy/util-uri-escape": ["@smithy/util-uri-escape@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-jtmJMyt1xMD/d8OtbVJ2gFZOSKc+ueYJZPW20ULW1GOp/q/YIM0wNh+u8ZFao9UaIGz4WoPW8hC64qlWLIfoDA=="],
|
||||
|
||||
"cli-highlight/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"cli-highlight/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"cli-highlight/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"qrcode/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"qrcode/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
@@ -48,6 +48,8 @@ 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
|
||||
@@ -66,9 +68,11 @@ openclaude
|
||||
export CLAUDE_CODE_USE_OPENAI=1
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export OPENAI_BASE_URL=https://api.deepseek.com/v1
|
||||
export OPENAI_MODEL=deepseek-chat
|
||||
export OPENAI_MODEL=deepseek-v4-flash
|
||||
```
|
||||
|
||||
Use `deepseek-v4-pro` when you want the stronger model. `deepseek-chat` and `deepseek-reasoner` remain available as DeepSeek's legacy API aliases.
|
||||
|
||||
### Google Gemini via OpenRouter
|
||||
|
||||
```bash
|
||||
@@ -82,6 +86,16 @@ OpenRouter model availability changes over time. If a model stops working, try a
|
||||
|
||||
### Ollama
|
||||
|
||||
Using `ollama launch` (recommended if you have Ollama installed):
|
||||
|
||||
```bash
|
||||
ollama launch openclaude --model llama3.3:70b
|
||||
```
|
||||
|
||||
This handles all environment setup automatically — no env vars needed. Works with any local or cloud model available in your Ollama instance.
|
||||
|
||||
Using environment variables manually:
|
||||
|
||||
```bash
|
||||
ollama pull llama3.3:70b
|
||||
|
||||
@@ -137,10 +151,9 @@ export OPENAI_MODEL=llama-3.3-70b-versatile
|
||||
### Mistral
|
||||
|
||||
```bash
|
||||
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
|
||||
export CLAUDE_CODE_USE_MISTRAL=1
|
||||
export MISTRAL_API_KEY=...
|
||||
export MISTRAL_MODEL=mistral-large-latest
|
||||
```
|
||||
|
||||
### Azure OpenAI
|
||||
@@ -158,12 +171,13 @@ export OPENAI_MODEL=gpt-4o
|
||||
|----------|----------|-------------|
|
||||
| `CLAUDE_CODE_USE_OPENAI` | Yes | Set to `1` to enable the OpenAI provider |
|
||||
| `OPENAI_API_KEY` | Yes* | Your API key (`*` not needed for local models like Ollama or Atomic Chat) |
|
||||
| `OPENAI_MODEL` | Yes | Model name such as `gpt-4o`, `deepseek-chat`, or `llama3.3:70b` |
|
||||
| `OPENAI_MODEL` | Yes | Model name such as `gpt-4o`, `deepseek-v4-flash`, or `llama3.3:70b` |
|
||||
| `OPENAI_BASE_URL` | No | API endpoint, defaulting to `https://api.openai.com/v1` |
|
||||
| `CODEX_API_KEY` | Codex only | Codex or ChatGPT access token override |
|
||||
| `CODEX_AUTH_JSON_PATH` | Codex only | Path to a Codex CLI `auth.json` file |
|
||||
| `CODEX_HOME` | Codex only | Alternative Codex home directory |
|
||||
| `OPENCLAUDE_DISABLE_CO_AUTHORED_BY` | No | Suppress the default `Co-Authored-By` trailer in generated git commits |
|
||||
| `OPENCLAUDE_LOG_TOKEN_USAGE` | No | When truthy (e.g. `verbose`), emits one JSON line on stderr per API request with input/output/cache tokens and the resolved provider. **User-facing debug output** — complements the REPL display controlled by `/config showCacheStats`. Distinct from `CLAUDE_CODE_ENABLE_TOKEN_USAGE_ATTACHMENT`, which is **model-facing** (injects context usage info into the prompt itself). Both can run together. |
|
||||
|
||||
You can also use `ANTHROPIC_MODEL` to override the model name. `OPENAI_MODEL` takes priority.
|
||||
|
||||
|
||||
333
docs/hook-chains.md
Normal file
333
docs/hook-chains.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# Hook Chains (Self-Healing Agent Mesh MVP)
|
||||
|
||||
Hook Chains provide an event-driven recovery layer for important workflow failures.
|
||||
When a matching hook event occurs, OpenClaude evaluates declarative rules and can dispatch remediation actions such as:
|
||||
|
||||
- `spawn_fallback_agent`
|
||||
- `notify_team`
|
||||
- `warm_remote_capacity`
|
||||
|
||||
## Disabled-By-Default Rollout
|
||||
|
||||
> **Rollout recommendation:** keep Hook Chains disabled until you validate rules in your environment.
|
||||
>
|
||||
> - Set top-level config to `"enabled": false` initially.
|
||||
> - Enable per environment when ready.
|
||||
> - Dispatch is gated by `feature('HOOK_CHAINS')`.
|
||||
> - Env gate defaults to off unless `CLAUDE_CODE_ENABLE_HOOK_CHAINS=1` is set.
|
||||
|
||||
This keeps existing workflows unchanged while you tune guard windows and action behavior.
|
||||
|
||||
## Feature Overview
|
||||
|
||||
Hook Chains are loaded from a deterministic config file and evaluated on dispatched hook events.
|
||||
|
||||
MVP runtime trigger wiring:
|
||||
|
||||
- `PostToolUseFailure` hooks dispatch Hook Chains with outcome `failed`.
|
||||
- `TaskCompleted` hooks dispatch Hook Chains with outcome:
|
||||
- `success` when completion hooks did not block.
|
||||
- `failed` when completion hooks returned blocking errors or prevented continuation.
|
||||
|
||||
Default config path:
|
||||
|
||||
- `.openclaude/hook-chains.json`
|
||||
|
||||
Override path:
|
||||
|
||||
- `CLAUDE_CODE_HOOK_CHAINS_CONFIG_PATH=/abs/or/relative/path/to/hook-chains.json`
|
||||
|
||||
Global gate:
|
||||
|
||||
- `feature('HOOK_CHAINS')` must be enabled in the build
|
||||
- `CLAUDE_CODE_ENABLE_HOOK_CHAINS=0|1` (defaults to disabled when unset)
|
||||
|
||||
## Safety Guarantees
|
||||
|
||||
The runtime is intentionally conservative:
|
||||
|
||||
- **Depth guard:** chain dispatch is blocked when `chainDepth >= maxChainDepth`.
|
||||
- **Rule cooldown:** each rule can only re-fire after cooldown expires.
|
||||
- **Dedup window:** identical event/action combinations are suppressed for a window.
|
||||
- **Abort-safe behavior:** if the current signal is aborted, actions skip safely.
|
||||
- **Policy-aware remote warm:** `warm_remote_capacity` skips when remote sessions are policy denied.
|
||||
- **Bridge inactive no-op:** `warm_remote_capacity` safely skips when no active bridge handle exists.
|
||||
- **Missing team context safety:** `notify_team` skips with structured reason if no team context/team file is available.
|
||||
- **Fallback launcher safety:** `spawn_fallback_agent` fails with a structured reason when launch permissions/context are unavailable.
|
||||
|
||||
## Configuration Schema Reference
|
||||
|
||||
Top-level object:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"enabled": true,
|
||||
"maxChainDepth": 2,
|
||||
"defaultCooldownMs": 30000,
|
||||
"defaultDedupWindowMs": 30000,
|
||||
"rules": []
|
||||
}
|
||||
```
|
||||
|
||||
### Top-Level Fields
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---:|---|
|
||||
| `version` | `1` | No | Defaults to `1`. |
|
||||
| `enabled` | `boolean` | No | Global feature switch for this config file. |
|
||||
| `maxChainDepth` | `integer` | No | Global depth guard (default `2`, max `10`). |
|
||||
| `defaultCooldownMs` | `integer` | No | Default rule cooldown in ms (default `30000`). |
|
||||
| `defaultDedupWindowMs` | `integer` | No | Default action dedup window in ms (default `30000`). |
|
||||
| `rules` | `HookChainRule[]` | No | Defaults to `[]`. May be omitted or empty; when no rules are present, dispatch is a no-op and returns `enabled: false`. |
|
||||
|
||||
> **Note:** An empty ruleset is valid and can be used to keep Hook Chains configured but effectively disabled until rules are added.
|
||||
### Rule Object (`HookChainRule`)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "task-failure-recovery",
|
||||
"enabled": true,
|
||||
"trigger": {
|
||||
"event": "TaskCompleted",
|
||||
"outcome": "failed"
|
||||
},
|
||||
"condition": {
|
||||
"toolNames": ["Edit"],
|
||||
"taskStatuses": ["failed"],
|
||||
"errorIncludes": ["timeout", "permission denied"],
|
||||
"eventFieldEquals": {
|
||||
"meta.source": "scheduler"
|
||||
}
|
||||
},
|
||||
"cooldownMs": 60000,
|
||||
"dedupWindowMs": 30000,
|
||||
"maxDepth": 2,
|
||||
"actions": []
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---:|---|
|
||||
| `id` | `string` | Yes | Stable identifier used in telemetry/guards. |
|
||||
| `enabled` | `boolean` | No | Per-rule switch. |
|
||||
| `trigger.event` | `HookEvent` | Yes | Event name to match. |
|
||||
| `trigger.outcome` | `"success"|"failed"|"timeout"|"unknown"` | No | Single outcome matcher. |
|
||||
| `trigger.outcomes` | `Outcome[]` | No | Multi-outcome matcher. Use either `outcome` or `outcomes`. |
|
||||
| `condition` | `object` | No | Optional extra matching constraints. |
|
||||
| `cooldownMs` | `integer` | No | Overrides global cooldown for this rule. |
|
||||
| `dedupWindowMs` | `integer` | No | Overrides global dedup for this rule. |
|
||||
| `maxDepth` | `integer` | No | Per-rule depth cap. |
|
||||
| `actions` | `HookChainAction[]` | Yes | One or more actions to execute in order. |
|
||||
|
||||
### Condition Fields
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `toolNames` | `string[]` | Matches `tool_name` / `toolName` in event payload. |
|
||||
| `taskStatuses` | `string[]` | Matches `task_status` / `taskStatus` / `status`. |
|
||||
| `errorIncludes` | `string[]` | Case-insensitive substring match against `error` / `reason` / `message`. |
|
||||
| `eventFieldEquals` | `Record<string, string\|number\|boolean>` | Dot-path equality against payload (example: `"meta.source": "scheduler"`). |
|
||||
|
||||
### Actions
|
||||
|
||||
#### `spawn_fallback_agent`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "spawn_fallback_agent",
|
||||
"id": "fallback-1",
|
||||
"enabled": true,
|
||||
"dedupWindowMs": 30000,
|
||||
"description": "Fallback recovery for failed task",
|
||||
"promptTemplate": "Recover task ${TASK_SUBJECT}. Event=${EVENT_NAME}, outcome=${OUTCOME}, error=${ERROR}. Payload=${PAYLOAD_JSON}",
|
||||
"agentType": "general-purpose",
|
||||
"model": "sonnet"
|
||||
}
|
||||
```
|
||||
|
||||
#### `notify_team`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "notify_team",
|
||||
"id": "notify-ops",
|
||||
"enabled": true,
|
||||
"dedupWindowMs": 30000,
|
||||
"teamName": "mesh-team",
|
||||
"recipients": ["*"],
|
||||
"summary": "Hook chain ${RULE_ID} fired",
|
||||
"messageTemplate": "Event=${EVENT_NAME} outcome=${OUTCOME}\nTask=${TASK_ID}\nError=${ERROR}\nPayload=${PAYLOAD_JSON}"
|
||||
}
|
||||
```
|
||||
|
||||
#### `warm_remote_capacity`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "warm_remote_capacity",
|
||||
"id": "warm-bridge",
|
||||
"enabled": true,
|
||||
"dedupWindowMs": 60000,
|
||||
"createDefaultEnvironmentIfMissing": false
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example Configs
|
||||
|
||||
### 1) Retry via Fallback Agent
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"enabled": true,
|
||||
"maxChainDepth": 2,
|
||||
"defaultCooldownMs": 30000,
|
||||
"defaultDedupWindowMs": 30000,
|
||||
"rules": [
|
||||
{
|
||||
"id": "retry-task-via-fallback",
|
||||
"trigger": {
|
||||
"event": "TaskCompleted",
|
||||
"outcome": "failed"
|
||||
},
|
||||
"cooldownMs": 60000,
|
||||
"actions": [
|
||||
{
|
||||
"type": "spawn_fallback_agent",
|
||||
"id": "spawn-retry-agent",
|
||||
"description": "Retry failed task with fallback agent",
|
||||
"promptTemplate": "A task failed. Recover it safely.\nTask=${TASK_SUBJECT}\nDescription=${TASK_DESCRIPTION}\nError=${ERROR}\nPayload=${PAYLOAD_JSON}",
|
||||
"agentType": "general-purpose",
|
||||
"model": "sonnet"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2) Notify Only
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"enabled": true,
|
||||
"maxChainDepth": 2,
|
||||
"defaultCooldownMs": 30000,
|
||||
"defaultDedupWindowMs": 30000,
|
||||
"rules": [
|
||||
{
|
||||
"id": "notify-on-tool-failure",
|
||||
"trigger": {
|
||||
"event": "PostToolUseFailure",
|
||||
"outcome": "failed"
|
||||
},
|
||||
"condition": {
|
||||
"toolNames": ["Edit", "Write", "Bash"]
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "notify_team",
|
||||
"id": "notify-team-failure",
|
||||
"recipients": ["*"],
|
||||
"summary": "Tool failure detected",
|
||||
"messageTemplate": "Tool failure detected.\nEvent=${EVENT_NAME} outcome=${OUTCOME}\nError=${ERROR}\nPayload=${PAYLOAD_JSON}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3) Combined Fallback + Notify + Bridge Warm
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"enabled": true,
|
||||
"maxChainDepth": 2,
|
||||
"defaultCooldownMs": 45000,
|
||||
"defaultDedupWindowMs": 30000,
|
||||
"rules": [
|
||||
{
|
||||
"id": "full-recovery-chain",
|
||||
"trigger": {
|
||||
"event": "TaskCompleted",
|
||||
"outcomes": ["failed", "timeout"]
|
||||
},
|
||||
"condition": {
|
||||
"errorIncludes": ["timeout", "capacity", "connection"]
|
||||
},
|
||||
"cooldownMs": 90000,
|
||||
"actions": [
|
||||
{
|
||||
"type": "spawn_fallback_agent",
|
||||
"id": "fallback-agent",
|
||||
"description": "Recover failed task execution",
|
||||
"promptTemplate": "Recover failed task and produce a concise fix summary.\nTask=${TASK_SUBJECT}\nError=${ERROR}\nPayload=${PAYLOAD_JSON}"
|
||||
},
|
||||
{
|
||||
"type": "notify_team",
|
||||
"id": "notify-team",
|
||||
"recipients": ["*"],
|
||||
"summary": "Recovery chain triggered",
|
||||
"messageTemplate": "Recovery chain ${RULE_ID} fired.\nOutcome=${OUTCOME}\nTask=${TASK_SUBJECT}\nError=${ERROR}"
|
||||
},
|
||||
{
|
||||
"type": "warm_remote_capacity",
|
||||
"id": "warm-capacity",
|
||||
"createDefaultEnvironmentIfMissing": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Template Variables
|
||||
|
||||
The following placeholders are supported by `promptTemplate`, `summary`, and `messageTemplate`:
|
||||
|
||||
- `${EVENT_NAME}`
|
||||
- `${OUTCOME}`
|
||||
- `${RULE_ID}`
|
||||
- `${TASK_SUBJECT}`
|
||||
- `${TASK_DESCRIPTION}`
|
||||
- `${TASK_ID}`
|
||||
- `${ERROR}`
|
||||
- `${PAYLOAD_JSON}`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Rule never triggers
|
||||
|
||||
- Verify `trigger.event` and `trigger.outcome`/`trigger.outcomes` exactly match dispatched event data.
|
||||
- Check `condition` filters (especially `toolNames` and `eventFieldEquals` dot-path keys).
|
||||
- Confirm the config file is valid JSON and schema-valid.
|
||||
|
||||
### Actions show as skipped
|
||||
|
||||
Common skip reasons:
|
||||
|
||||
- `action disabled`
|
||||
- `rule cooldown active ...`
|
||||
- `dedup window active ...`
|
||||
- `max chain depth reached ...`
|
||||
- `No team context is available ...`
|
||||
- `Team file not found ...`
|
||||
- `Remote sessions are blocked by policy`
|
||||
- `Bridge is not active; warm_remote_capacity is a safe no-op`
|
||||
- `No fallback agent launcher is registered in runtime context`
|
||||
|
||||
### Config changes not reflected
|
||||
|
||||
- Loader uses memoization by file mtime/size.
|
||||
- Ensure your editor writes the file fully and updates mtime.
|
||||
- If needed, force reload from the caller side with `forceReloadConfig: true`.
|
||||
|
||||
### Existing workflows changed unexpectedly
|
||||
|
||||
- Set `"enabled": false` at top-level.
|
||||
- Or globally disable with `CLAUDE_CODE_ENABLE_HOOK_CHAINS=0`.
|
||||
- Re-enable gradually after validating one rule at a time.
|
||||
144
docs/litellm-setup.md
Normal file
144
docs/litellm-setup.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# LiteLLM Setup
|
||||
|
||||
OpenClaude can connect to LiteLLM through LiteLLM's OpenAI-compatible proxy.
|
||||
|
||||
## Overview
|
||||
|
||||
LiteLLM is an open-source LLM gateway that provides a unified API to 100+ model providers. By running the LiteLLM Proxy, you can route OpenClaude requests through LiteLLM to access any of its supported providers — all while using OpenClaude's existing OpenAI-compatible provider path.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- LiteLLM installed (`pip install litellm[proxy]`)
|
||||
- A `litellm_config.yaml` or equivalent LiteLLM configuration
|
||||
- LiteLLM Proxy running on a local or remote port
|
||||
|
||||
## 1. Start the LiteLLM Proxy
|
||||
|
||||
### Basic installation
|
||||
|
||||
```bash
|
||||
pip install litellm[proxy]
|
||||
```
|
||||
|
||||
### Configure LiteLLM
|
||||
|
||||
Create a `litellm_config.yaml` with your desired model aliases:
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
- model_name: gpt-4o
|
||||
litellm_params:
|
||||
model: openai/gpt-4o
|
||||
api_key: os.environ/OPENAI_API_KEY
|
||||
|
||||
- model_name: claude-sonnet-4
|
||||
litellm_params:
|
||||
model: anthropic/claude-sonnet-4-5-20250929
|
||||
api_key: os.environ/ANTHROPIC_API_KEY
|
||||
|
||||
- model_name: gemini-2.5-flash
|
||||
litellm_params:
|
||||
model: gemini/gemini-2.5-flash
|
||||
api_key: os.environ/GEMINI_API_KEY
|
||||
|
||||
- model_name: llama-3.3-70b
|
||||
litellm_params:
|
||||
model: together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo
|
||||
api_key: os.environ/TOGETHER_API_KEY
|
||||
```
|
||||
|
||||
### Run the proxy
|
||||
|
||||
```bash
|
||||
litellm --config litellm_config.yaml --port 4000
|
||||
```
|
||||
|
||||
The proxy will start at `http://localhost:4000` by default.
|
||||
|
||||
## 2. Point OpenClaude to LiteLLM
|
||||
|
||||
### Option A: Environment Variables
|
||||
|
||||
```bash
|
||||
export CLAUDE_CODE_USE_OPENAI=1
|
||||
export OPENAI_BASE_URL=http://localhost:4000
|
||||
export OPENAI_API_KEY=<your-master-key-or-placeholder>
|
||||
export OPENAI_MODEL=<your-litellm-model-alias>
|
||||
openclaude
|
||||
```
|
||||
|
||||
Replace `<your-litellm-model-alias>` with a model name from your `litellm_config.yaml` (e.g., `gpt-4o`, `claude-sonnet-4`, `gemini-2.5-flash`).
|
||||
|
||||
### Option B: Using /provider
|
||||
|
||||
1. Run `openclaude`
|
||||
2. Type `/provider` to open the provider setup flow
|
||||
3. Choose the **OpenAI-compatible** option
|
||||
4. When prompted for the API key, enter the key required by your LiteLLM proxy
|
||||
If your local LiteLLM setup does not enforce auth, you may still need to enter a placeholder value
|
||||
- 5. When prompted for the base URL, enter `http://localhost:4000`
|
||||
6. 6. When prompted for the model, enter the LiteLLM model name or alias you configured
|
||||
7. 7. Save the provider configuration
|
||||
|
||||
## 3. Example LiteLLM Configs
|
||||
|
||||
### Multi-provider routing with spend tracking
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
- model_name: gpt-4o
|
||||
litellm_params:
|
||||
model: openai/gpt-4o
|
||||
api_key: os.environ/OPENAI_API_KEY
|
||||
|
||||
- model_name: claude-sonnet-4
|
||||
litellm_params:
|
||||
model: anthropic/claude-sonnet-4-5-20250929
|
||||
api_key: os.environ/ANTHROPIC_API_KEY
|
||||
|
||||
- model_name: deepseek-chat
|
||||
litellm_params:
|
||||
model: deepseek/deepseek-chat
|
||||
api_key: os.environ/DEEPSEEK_API_KEY
|
||||
|
||||
litellm_settings:
|
||||
set_verbose: false
|
||||
num_retries: 3
|
||||
```
|
||||
|
||||
### With a master key for auth
|
||||
|
||||
```bash
|
||||
# Start proxy with a master key
|
||||
litellm --config litellm_config.yaml --port 4000 --master_key sk-my-master-key
|
||||
|
||||
# Connect OpenClaude
|
||||
export CLAUDE_CODE_USE_OPENAI=1
|
||||
export OPENAI_BASE_URL=http://localhost:4000
|
||||
export OPENAI_API_KEY=sk-my-master-key
|
||||
export OPENAI_MODEL=gpt-4o
|
||||
openclaude
|
||||
```
|
||||
|
||||
## 4. Notes
|
||||
|
||||
- `OPENAI_MODEL` must match the **LiteLLM model alias** defined in your config, not the upstream raw provider model name.
|
||||
- If your proxy requires authentication, use the proxy key (or `master_key`) in `OPENAI_API_KEY`.
|
||||
- LiteLLM's OpenAI-compatible endpoint accepts the same request format as OpenAI, so OpenClaude works without any code changes.
|
||||
- You can switch between any provider configured in LiteLLM by simply changing the `OPENAI_MODEL` value — no need to reconfigure OpenClaude.
|
||||
|
||||
## 5. Troubleshooting
|
||||
|
||||
| Issue | Likely Cause | Fix |
|
||||
|-------|--------------|-----|
|
||||
| 404 or Model Not Found | Model alias doesn't exist in LiteLLM config | Verify the `model_name` in `litellm_config.yaml` matches `OPENAI_MODEL` |
|
||||
| Connection Refused | LiteLLM proxy isn't running | Start the proxy with `litellm --config litellm_config.yaml --port 4000` |
|
||||
| Auth Failed | Missing or wrong `master_key` | Set the correct key in `OPENAI_API_KEY` |
|
||||
| Upstream provider error | The backend provider key is missing or invalid | Ensure the upstream API key (e.g., `OPENAI_API_KEY`) is set in your LiteLLM proxy process environment |
|
||||
| Tools fail but chat works | The selected model has weak function/tool calling support | Switch to a model with strong tool support (e.g., GPT-4o, Claude Sonnet) |
|
||||
|
||||
## 6. Resources
|
||||
|
||||
- [LiteLLM Proxy Docs](https://docs.litellm.ai/docs/proxy/quick_start)
|
||||
- [LiteLLM Provider List](https://docs.litellm.ai/docs/providers)
|
||||
- [LiteLLM OpenAI-Compatible Endpoints](https://docs.litellm.ai/docs/proxy/openai_compatible_proxy)
|
||||
@@ -41,11 +41,13 @@ openclaude
|
||||
export CLAUDE_CODE_USE_OPENAI=1
|
||||
export OPENAI_API_KEY=sk-your-key-here
|
||||
export OPENAI_BASE_URL=https://api.deepseek.com/v1
|
||||
export OPENAI_MODEL=deepseek-chat
|
||||
export OPENAI_MODEL=deepseek-v4-flash
|
||||
|
||||
openclaude
|
||||
```
|
||||
|
||||
Use `deepseek-v4-pro` when you want the stronger model. `deepseek-chat` and `deepseek-reasoner` still work as DeepSeek's legacy API aliases.
|
||||
|
||||
### Option C: Ollama
|
||||
|
||||
Install Ollama first from:
|
||||
|
||||
@@ -41,11 +41,13 @@ openclaude
|
||||
$env:CLAUDE_CODE_USE_OPENAI="1"
|
||||
$env:OPENAI_API_KEY="sk-your-key-here"
|
||||
$env:OPENAI_BASE_URL="https://api.deepseek.com/v1"
|
||||
$env:OPENAI_MODEL="deepseek-chat"
|
||||
$env:OPENAI_MODEL="deepseek-v4-flash"
|
||||
|
||||
openclaude
|
||||
```
|
||||
|
||||
Use `deepseek-v4-pro` when you want the stronger model. `deepseek-chat` and `deepseek-reasoner` still work as DeepSeek's legacy API aliases.
|
||||
|
||||
### Option C: Ollama
|
||||
|
||||
Install Ollama first from:
|
||||
|
||||
13
package.json
13
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@gitlawb/openclaude",
|
||||
"version": "0.1.8",
|
||||
"description": "Claude Code opened to any LLM — OpenAI, Gemini, DeepSeek, Ollama, and 200+ models",
|
||||
"version": "0.7.0",
|
||||
"description": "OpenClaude opens coding-agent workflows to any LLM — OpenAI, Gemini, DeepSeek, Ollama, and 200+ models",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"openclaude": "./bin/openclaude"
|
||||
@@ -30,6 +30,8 @@
|
||||
"profile:code": "bun run profile:init -- --provider ollama --model qwen2.5-coder:7b",
|
||||
"dev:fast": "bun run profile:fast && bun run dev:ollama:fast",
|
||||
"dev:code": "bun run profile:code && bun run dev:profile",
|
||||
"dev:grpc": "bun run scripts/start-grpc.ts",
|
||||
"dev:grpc:cli": "bun run scripts/grpc-cli.ts",
|
||||
"start": "node dist/cli.mjs",
|
||||
"test": "bun test",
|
||||
"test:coverage": "bun test --coverage --coverage-reporter=lcov --coverage-dir=coverage --max-concurrency=1 && bun run scripts/render-coverage-heatmap.ts",
|
||||
@@ -57,6 +59,8 @@
|
||||
"@anthropic-ai/vertex-sdk": "0.14.4",
|
||||
"@commander-js/extra-typings": "12.1.0",
|
||||
"@growthbook/growthbook": "1.6.5",
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@mendable/firecrawl-js": "4.18.1",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opentelemetry/api": "1.9.1",
|
||||
@@ -72,7 +76,7 @@
|
||||
"@opentelemetry/semantic-conventions": "1.40.0",
|
||||
"ajv": "8.18.0",
|
||||
"auto-bind": "5.0.1",
|
||||
"axios": "1.14.0",
|
||||
"axios": "1.15.0",
|
||||
"bidi-js": "1.0.3",
|
||||
"chalk": "5.6.2",
|
||||
"chokidar": "4.0.3",
|
||||
@@ -128,6 +132,7 @@
|
||||
"@types/bun": "1.3.11",
|
||||
"@types/node": "25.5.0",
|
||||
"@types/react": "19.2.14",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -135,7 +140,7 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://gitlawb.com/z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr/openclaude"
|
||||
"url": "https://github.com/Gitlawb/openclaude.git"
|
||||
},
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
|
||||
3
python/requirements.txt
Normal file
3
python/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
pytest==7.4.4
|
||||
pytest-asyncio==0.23.3
|
||||
httpx==0.25.2
|
||||
@@ -112,6 +112,14 @@ 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",
|
||||
|
||||
11
release-please-config.json
Normal file
11
release-please-config.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
}
|
||||
}
|
||||
197
scripts/build.ts
197
scripts/build.ts
@@ -8,7 +8,8 @@
|
||||
* - src/ path aliases
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs'
|
||||
import { readFileSync, readdirSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { noTelemetryPlugin } from './no-telemetry-plugin'
|
||||
|
||||
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
||||
@@ -18,31 +19,106 @@ const version = pkg.version
|
||||
// Most Anthropic-internal features stay off; open-build features can be
|
||||
// selectively enabled here when their full source exists in the mirror.
|
||||
const featureFlags: Record<string, boolean> = {
|
||||
VOICE_MODE: false,
|
||||
PROACTIVE: false,
|
||||
KAIROS: false,
|
||||
BRIDGE_MODE: false,
|
||||
DAEMON: false,
|
||||
AGENT_TRIGGERS: false,
|
||||
MONITOR_TOOL: false,
|
||||
ABLATION_BASELINE: false,
|
||||
DUMP_SYSTEM_PROMPT: false,
|
||||
CACHED_MICROCOMPACT: false,
|
||||
COORDINATOR_MODE: false,
|
||||
CONTEXT_COLLAPSE: false,
|
||||
COMMIT_ATTRIBUTION: false,
|
||||
TEAMMEM: false,
|
||||
UDS_INBOX: false,
|
||||
BG_SESSIONS: false,
|
||||
AWAY_SUMMARY: false,
|
||||
TRANSCRIPT_CLASSIFIER: false,
|
||||
WEB_BROWSER_TOOL: false,
|
||||
MESSAGE_ACTIONS: false,
|
||||
BUDDY: true,
|
||||
CHICAGO_MCP: false,
|
||||
COWORKER_TYPE_TELEMETRY: false,
|
||||
// ── Disabled: require Anthropic infrastructure or missing source ─────
|
||||
VOICE_MODE: false, // Push-to-talk STT via claude.ai OAuth endpoint
|
||||
PROACTIVE: false, // Autonomous agent mode (missing proactive/ module)
|
||||
KAIROS: false, // Persistent assistant/session mode (cloud backend)
|
||||
BRIDGE_MODE: false, // Remote desktop bridge via CCR infrastructure
|
||||
DAEMON: false, // Background daemon process (stubbed in open build)
|
||||
AGENT_TRIGGERS: false, // Scheduled remote agent triggers
|
||||
ABLATION_BASELINE: false, // A/B testing harness for eval experiments
|
||||
CONTEXT_COLLAPSE: false, // Context collapsing optimization (stubbed)
|
||||
COMMIT_ATTRIBUTION: false, // Co-Authored-By metadata in git commits
|
||||
UDS_INBOX: false, // Unix Domain Socket inter-session messaging
|
||||
BG_SESSIONS: false, // Background sessions via tmux (stubbed)
|
||||
WEB_BROWSER_TOOL: false, // Built-in browser automation (source not mirrored)
|
||||
CHICAGO_MCP: false, // Computer-use MCP (native Swift modules stubbed)
|
||||
COWORKER_TYPE_TELEMETRY: false, // Telemetry for agent/coworker type classification
|
||||
MCP_SKILLS: false, // Dynamic MCP skill discovery (src/skills/mcpSkills.ts not mirrored; enabling this causes "fetchMcpSkillsForClient is not a function" when MCP servers with resources connect — see #856)
|
||||
|
||||
// ── Enabled: upstream defaults ──────────────────────────────────────
|
||||
COORDINATOR_MODE: true, // Multi-agent coordinator with worker delegation
|
||||
BUILTIN_EXPLORE_PLAN_AGENTS: true, // Built-in Explore/Plan specialized subagents
|
||||
BUDDY: true, // Buddy mode for paired programming
|
||||
MONITOR_TOOL: true, // MCP server monitoring/streaming tool
|
||||
TEAMMEM: true, // Team memory management
|
||||
MESSAGE_ACTIONS: true, // Message action buttons in the UI
|
||||
|
||||
// ── Enabled: new activations ────────────────────────────────────────
|
||||
DUMP_SYSTEM_PROMPT: true, // --dump-system-prompt CLI flag for debugging
|
||||
CACHED_MICROCOMPACT: true, // Cache-aware tool result truncation optimization
|
||||
AWAY_SUMMARY: true, // "While you were away" recap after 5min blur
|
||||
TRANSCRIPT_CLASSIFIER: true, // Auto-approval classifier for safe tool uses
|
||||
ULTRATHINK: true, // Deep thinking mode — type "ultrathink" to boost reasoning
|
||||
TOKEN_BUDGET: true, // Token budget tracking with usage warnings
|
||||
HISTORY_PICKER: true, // Enhanced interactive prompt history picker
|
||||
QUICK_SEARCH: true, // Ctrl+G quick search across prompts
|
||||
SHOT_STATS: true, // Shot distribution stats in session summary
|
||||
EXTRACT_MEMORIES: true, // Auto-extract durable memories from conversations
|
||||
FORK_SUBAGENT: true, // Implicit context-forking when omitting subagent_type
|
||||
VERIFICATION_AGENT: true, // Built-in read-only agent for test/verification
|
||||
PROMPT_CACHE_BREAK_DETECTION: true, // Detect & log unexpected prompt cache invalidations
|
||||
HOOK_PROMPTS: true, // Allow tools to request interactive user prompts
|
||||
}
|
||||
|
||||
// ── 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',
|
||||
@@ -103,18 +179,11 @@ export async function handleBgFlag() { throw new Error("Background sessions are
|
||||
],
|
||||
] as const)
|
||||
|
||||
// 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',
|
||||
}),
|
||||
)
|
||||
// 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.
|
||||
|
||||
build.onResolve(
|
||||
{ filter: /^\.\.\/(daemon\/workerRegistry|daemon\/main|cli\/bg|cli\/handlers\/templateJobs|environment-runner\/main|self-hosted-runner\/main)\.js$/ },
|
||||
@@ -274,16 +343,7 @@ export const SeverityNumber = {};
|
||||
|
||||
// Scan source to find imports that can't resolve
|
||||
function scanForMissingImports() {
|
||||
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] || ''
|
||||
function checkAndRegister(specifier: string, fileDir: string, namedPart: string) {
|
||||
const names = namedPart.split(',')
|
||||
.map((s: string) => s.trim().replace(/^type\s+/, ''))
|
||||
.filter((s: string) => s && !s.startsWith('type '))
|
||||
@@ -303,8 +363,7 @@ export const SeverityNumber = {};
|
||||
}
|
||||
// Check relative .js imports
|
||||
else if (specifier.endsWith('.js') && (specifier.startsWith('./') || specifier.startsWith('../'))) {
|
||||
const dir2 = pathMod.dirname(full)
|
||||
const resolved = pathMod.resolve(dir2, specifier)
|
||||
const resolved = pathMod.resolve(fileDir, specifier)
|
||||
const tsVariant = resolved.replace(/\.js$/, '.ts')
|
||||
const tsxVariant = resolved.replace(/\.js$/, '.tsx')
|
||||
if (!fs.existsSync(resolved) && !fs.existsSync(tsVariant) && !fs.existsSync(tsxVariant)) {
|
||||
@@ -317,6 +376,38 @@ 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 rawCode: string = fs.readFileSync(full, 'utf-8')
|
||||
const fileDir = pathMod.dirname(full)
|
||||
|
||||
// Strip comments before scanning for imports/requires.
|
||||
// The regex scanner matches require()/import() patterns
|
||||
// inside JSDoc comments, causing false-positive missing
|
||||
// module detection that breaks the build with noop stubs.
|
||||
const code = rawCode
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
|
||||
.replace(/\/\/.*$/gm, '') // line comments
|
||||
|
||||
// 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, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,7 +480,13 @@ if (!result.success) {
|
||||
for (const log of result.logs) {
|
||||
console.error(log)
|
||||
}
|
||||
process.exit(1)
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
console.log(`✓ Built openclaude v${version} → dist/cli.mjs`)
|
||||
}
|
||||
|
||||
console.log(`✓ Built openclaude v${version} → dist/cli.mjs`)
|
||||
} finally {
|
||||
// Always restore source files, even if Bun.build() throws
|
||||
restoreModifiedFiles()
|
||||
console.log(` 🔄 feature-flags: pre-processed ${numModified} files (restored)`)
|
||||
}
|
||||
|
||||
47
scripts/feature-flags-source-guard.test.ts
Normal file
47
scripts/feature-flags-source-guard.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { expect, test } from 'bun:test'
|
||||
|
||||
// Regression guard for #856. Several build feature flags require source files
|
||||
// that are not mirrored into the open build. When such a flag is set to `true`
|
||||
// without the source present, the bundler falls back to a missing-module stub
|
||||
// that only exports `default`, which causes runtime errors like
|
||||
// `fetchMcpSkillsForClient is not a function` when downstream code reaches
|
||||
// through the `require()` to a named export.
|
||||
//
|
||||
// This test fails fast at test-time if someone re-enables one of these flags
|
||||
// without first mirroring the corresponding source file.
|
||||
|
||||
const BUILD_SCRIPT = join(import.meta.dir, 'build.ts')
|
||||
const REPO_ROOT = join(import.meta.dir, '..')
|
||||
|
||||
type FlagGuard = {
|
||||
flag: string
|
||||
source: string // path relative to repo root
|
||||
}
|
||||
|
||||
const FLAG_REQUIRES_SOURCE: FlagGuard[] = [
|
||||
{ flag: 'MCP_SKILLS', source: 'src/skills/mcpSkills.ts' },
|
||||
]
|
||||
|
||||
test('build feature flags are not enabled without their source files', () => {
|
||||
const buildScript = readFileSync(BUILD_SCRIPT, 'utf-8')
|
||||
|
||||
for (const { flag, source } of FLAG_REQUIRES_SOURCE) {
|
||||
const enabledRe = new RegExp(`^\\s*${flag}\\s*:\\s*true\\b`, 'm')
|
||||
const isEnabled = enabledRe.test(buildScript)
|
||||
const sourceExists = existsSync(join(REPO_ROOT, source))
|
||||
|
||||
if (isEnabled && !sourceExists) {
|
||||
throw new Error(
|
||||
`Feature flag ${flag} is enabled in scripts/build.ts, but its required source file "${source}" does not exist. ` +
|
||||
`Enabling this flag without the source will cause runtime errors (missing named exports from the missing-module stub). ` +
|
||||
`Either mirror the source file or set ${flag}: false.`,
|
||||
)
|
||||
}
|
||||
|
||||
// When the source IS present, the flag can be either true or false; either
|
||||
// is fine. We only care about the "enabled but missing" combination.
|
||||
expect(true).toBe(true)
|
||||
}
|
||||
})
|
||||
121
scripts/grpc-cli.ts
Normal file
121
scripts/grpc-cli.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import * as grpc from '@grpc/grpc-js'
|
||||
import * as protoLoader from '@grpc/proto-loader'
|
||||
import path from 'path'
|
||||
import * as readline from 'readline'
|
||||
|
||||
const PROTO_PATH = path.resolve(import.meta.dirname, '../src/proto/openclaude.proto')
|
||||
|
||||
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
|
||||
keepCase: true,
|
||||
longs: String,
|
||||
enums: String,
|
||||
defaults: true,
|
||||
oneofs: true,
|
||||
})
|
||||
|
||||
const protoDescriptor = grpc.loadPackageDefinition(packageDefinition) as any
|
||||
const openclaudeProto = protoDescriptor.openclaude.v1
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
})
|
||||
|
||||
function askQuestion(query: string): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
rl.question(query, resolve)
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const host = process.env.GRPC_HOST || 'localhost'
|
||||
const port = process.env.GRPC_PORT || '50051'
|
||||
const client = new openclaudeProto.AgentService(
|
||||
`${host}:${port}`,
|
||||
grpc.credentials.createInsecure()
|
||||
)
|
||||
|
||||
let call: grpc.ClientDuplexStream<any, any> | null = null
|
||||
|
||||
const startStream = () => {
|
||||
call = client.Chat()
|
||||
let textStreamed = false
|
||||
|
||||
call.on('data', async (serverMessage: any) => {
|
||||
if (serverMessage.text_chunk) {
|
||||
process.stdout.write(serverMessage.text_chunk.text)
|
||||
textStreamed = true
|
||||
} else if (serverMessage.tool_start) {
|
||||
console.log(`\n\x1b[36m[Tool Call]\x1b[0m \x1b[1m${serverMessage.tool_start.tool_name}\x1b[0m`)
|
||||
console.log(`\x1b[90m${serverMessage.tool_start.arguments_json}\x1b[0m\n`)
|
||||
} else if (serverMessage.tool_result) {
|
||||
console.log(`\n\x1b[32m[Tool Result]\x1b[0m \x1b[1m${serverMessage.tool_result.tool_name}\x1b[0m`)
|
||||
const out = serverMessage.tool_result.output
|
||||
if (out.length > 500) {
|
||||
console.log(`\x1b[90m${out.substring(0, 500)}...\n(Output truncated, total length: ${out.length})\x1b[0m`)
|
||||
} else {
|
||||
console.log(`\x1b[90m${out}\x1b[0m`)
|
||||
}
|
||||
} else if (serverMessage.action_required) {
|
||||
const action = serverMessage.action_required
|
||||
console.log(`\n\x1b[33m[Action Required]\x1b[0m`)
|
||||
const reply = await askQuestion(`\x1b[1m${action.question}\x1b[0m (y/n) > `)
|
||||
|
||||
call?.write({
|
||||
input: {
|
||||
prompt_id: action.prompt_id,
|
||||
reply: reply.trim()
|
||||
}
|
||||
})
|
||||
} else if (serverMessage.done) {
|
||||
if (!textStreamed && serverMessage.done.full_text) {
|
||||
process.stdout.write(serverMessage.done.full_text)
|
||||
}
|
||||
textStreamed = false
|
||||
console.log('\n\x1b[32m[Generation Complete]\x1b[0m')
|
||||
promptUser()
|
||||
} else if (serverMessage.error) {
|
||||
console.error(`\n\x1b[31m[Server Error]\x1b[0m ${serverMessage.error.message}`)
|
||||
promptUser()
|
||||
}
|
||||
})
|
||||
|
||||
call.on('end', () => {
|
||||
console.log('\n\x1b[90m[Stream closed by server]\x1b[0m')
|
||||
// Don't prompt user here, let 'done' or 'error' handlers do it
|
||||
})
|
||||
|
||||
call.on('error', (err: Error) => {
|
||||
console.error('\n\x1b[31m[Stream Error]\x1b[0m', err.message)
|
||||
promptUser()
|
||||
})
|
||||
}
|
||||
|
||||
const promptUser = async () => {
|
||||
const message = await askQuestion('\n\x1b[35m> \x1b[0m')
|
||||
|
||||
if (message.trim().toLowerCase() === '/exit' || message.trim().toLowerCase() === '/quit') {
|
||||
console.log('Bye!')
|
||||
rl.close()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!call || call.destroyed) {
|
||||
startStream()
|
||||
}
|
||||
|
||||
call!.write({
|
||||
request: {
|
||||
session_id: 'cli-session-1',
|
||||
message: message,
|
||||
working_directory: process.cwd()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
console.log('\x1b[32mOpenClaude gRPC CLI\x1b[0m')
|
||||
console.log('\x1b[90mType /exit to quit.\x1b[0m')
|
||||
promptUser()
|
||||
}
|
||||
|
||||
main()
|
||||
163
scripts/no-telemetry-growthbook-stub.test.ts
Normal file
163
scripts/no-telemetry-growthbook-stub.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
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({})
|
||||
})
|
||||
|
||||
// ── Open-build defaults (_openBuildDefaults) ────────────────────
|
||||
|
||||
test('returns open-build default when flags file is absent', () => {
|
||||
// tengu_passport_quail is in _openBuildDefaults as true; without a
|
||||
// flags file the stub should return the open-build override, not
|
||||
// the call-site defaultValue.
|
||||
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_passport_quail', false)).toBe(true)
|
||||
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_coral_fern', false)).toBe(true)
|
||||
})
|
||||
|
||||
test('flags file overrides open-build defaults', () => {
|
||||
// User-provided feature-flags.json takes priority over _openBuildDefaults.
|
||||
writeFileSync(flagsFile, JSON.stringify({ tengu_passport_quail: false }))
|
||||
|
||||
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_passport_quail', true)).toBe(false)
|
||||
})
|
||||
|
||||
// ── 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)
|
||||
})
|
||||
})
|
||||
@@ -34,28 +34,201 @@ export function _resetForTesting() {}
|
||||
`,
|
||||
|
||||
'services/analytics/growthbook': `
|
||||
import _fs from 'node:fs';
|
||||
import _path from 'node:path';
|
||||
import _os from 'node:os';
|
||||
|
||||
let _flags = undefined;
|
||||
|
||||
// ── Open-build GrowthBook overrides ───────────────────────────────────
|
||||
// Override upstream defaultValue for runtime gates tied to build-time
|
||||
// features. Only keys that DIFFER from upstream belong here — the
|
||||
// catalog below is pure documentation and does NOT affect resolution.
|
||||
//
|
||||
// Priority: ~/.claude/feature-flags.json > _openBuildDefaults > defaultValue
|
||||
//
|
||||
// To override at runtime, create ~/.claude/feature-flags.json:
|
||||
// { "tengu_some_flag": true }
|
||||
const _openBuildDefaults = {
|
||||
'tengu_sedge_lantern': true, // AWAY_SUMMARY — "while you were away" recap (upstream: false)
|
||||
'tengu_hive_evidence': true, // VERIFICATION_AGENT — read-only test/verification agent (upstream: false)
|
||||
'tengu_passport_quail': true, // EXTRACT_MEMORIES — enable memory extraction (upstream: false)
|
||||
'tengu_coral_fern': true, // EXTRACT_MEMORIES — enable memory search in past context (upstream: false)
|
||||
};
|
||||
|
||||
/* ── Known runtime feature keys (reference only) ───────────────────────
|
||||
* This catalog does NOT participate in flag resolution. It documents
|
||||
* the known GrowthBook keys and their upstream default values, scraped
|
||||
* from src/ call sites. It is NOT exhaustive — new keys may be added
|
||||
* upstream between catalog updates.
|
||||
*
|
||||
* Some keys have different defaults at different call sites — this is
|
||||
* intentional upstream (the server unifies the value at runtime).
|
||||
*
|
||||
* To activate any of these, add them to ~/.claude/feature-flags.json
|
||||
* or to _openBuildDefaults above.
|
||||
*
|
||||
* ── Reasoning & thinking ──────────────────────────────────────────────
|
||||
* tengu_turtle_carbon = true ULTRATHINK deep thinking runtime gate
|
||||
* tengu_thinkback = gate /thinkback replay command
|
||||
*
|
||||
* ── Agents & orchestration ────────────────────────────────────────────
|
||||
* tengu_amber_flint = true Agent swarms coordination
|
||||
* tengu_amber_stoat = true Built-in agent availability (Explore, Plan, etc.)
|
||||
* tengu_agent_list_attach = true Attach file context to agent list
|
||||
* tengu_auto_background_agents = false Auto-spawn background agents
|
||||
* tengu_slim_subagent_claudemd = true Lighter ClaudeMD for subagents
|
||||
* tengu_hive_evidence = false Verification agent / evidence tracking (4 call sites)
|
||||
* tengu_ultraplan_model = model cfg ULTRAPLAN model selection (dynamic config)
|
||||
*
|
||||
* ── Memory & context ──────────────────────────────────────────────────
|
||||
* tengu_passport_quail = false EXTRACT_MEMORIES main gate (isExtractModeActive)
|
||||
* tengu_coral_fern = false EXTRACT_MEMORIES search in past context
|
||||
* tengu_slate_thimble = false Memory dir paths (non-interactive sessions)
|
||||
* tengu_herring_clock = true/false Team memory paths (varies by call site)
|
||||
* tengu_bramble_lintel = null Extract memories throttle (null → every turn)
|
||||
* tengu_sedge_lantern = false AWAY_SUMMARY "while you were away" recap
|
||||
* tengu_session_memory = false Session memory service
|
||||
* tengu_sm_config = {} Session memory config (dynamic)
|
||||
* tengu_sm_compact_config = {} Session memory compaction config (dynamic)
|
||||
* tengu_cobalt_raccoon = false Reactive compaction (suppress auto-compact)
|
||||
* tengu_pebble_leaf_prune = false Session storage pruning
|
||||
*
|
||||
* ── Kairos & cron ─────────────────────────────────────────────────────
|
||||
* tengu_kairos_brief = false Brief layout mode (KAIROS)
|
||||
* tengu_kairos_brief_config = {} Brief config (dynamic)
|
||||
* tengu_kairos_cron = true Cron scheduler enable
|
||||
* tengu_kairos_cron_durable = true Durable (disk-persistent) cron tasks
|
||||
* tengu_kairos_cron_config = {} Cron jitter config (dynamic)
|
||||
*
|
||||
* ── Bridge & remote (require Anthropic infra) ─────────────────────────
|
||||
* tengu_ccr_bridge = false CCR bridge connection
|
||||
* tengu_ccr_bridge_multi_session = gate Multi-session spawn mode
|
||||
* tengu_ccr_mirror = false CCR session mirroring
|
||||
* tengu_ccr_bundle_seed_enabled = gate Git bundle seeding for CCR
|
||||
* tengu_ccr_bundle_max_bytes = null Bundle size limit (null → default)
|
||||
* tengu_bridge_repl_v2 = false Environment-less REPL bridge v2
|
||||
* tengu_bridge_repl_v2_cse_shim_enabled = true CSE→Session tag retag shim
|
||||
* tengu_bridge_min_version = {min:'0'} Min CLI version for bridge (dynamic)
|
||||
* tengu_bridge_initial_history_cap = 200 Initial history cap for bridge
|
||||
* tengu_bridge_system_init = false Bridge system initialization
|
||||
* tengu_cobalt_harbor = false Auto-connect CCR at startup
|
||||
* tengu_cobalt_lantern = false Remote setup preconditions
|
||||
* tengu_remote_backend = false Remote TUI backend
|
||||
* tengu_surreal_dali = false Remote agent tasks / triggers
|
||||
*
|
||||
* ── Prompt & API ──────────────────────────────────────────────────────
|
||||
* tengu_attribution_header = true Attribution header in API requests
|
||||
* tengu_basalt_3kr = true MCP instructions delta
|
||||
* tengu_slate_prism = true/false Message formatting (varies by call site)
|
||||
* tengu_amber_prism = false Message content formatting
|
||||
* tengu_amber_json_tools = false JSON format for tool schemas
|
||||
* tengu_fgts = false API feature gates
|
||||
* tengu_otk_slot_v1 = false One-time key slots for API auth
|
||||
* tengu_cicada_nap_ms = 0 Background GrowthBook refresh throttle (ms)
|
||||
* tengu_miraculo_the_bard = false Service initialization gate
|
||||
* tengu_immediate_model_command = false Immediate /model command execution
|
||||
* tengu_chomp_inflection = false Prompt suggestions after responses
|
||||
* tengu_tool_pear = gate API betas for tool use
|
||||
* tengu-off-switch = {act:false} Service kill switch (dynamic; uses dash)
|
||||
*
|
||||
* ── Permissions & security ────────────────────────────────────────────
|
||||
* tengu_birch_trellis = true Bash auto-mode permissions config
|
||||
* tengu_auto_mode_config = {} Auto-mode configuration (dynamic, many call sites)
|
||||
* tengu_iron_gate_closed = true Permission iron gate (with refresh)
|
||||
* tengu_destructive_command_warning = false Warning for destructive bash commands
|
||||
* tengu_disable_bypass_permissions_mode = security Security killswitch (always false in open build)
|
||||
*
|
||||
* ── UI & UX ───────────────────────────────────────────────────────────
|
||||
* tengu_willow_mode = 'off' REPL rendering mode
|
||||
* tengu_terminal_panel = false Terminal panel keybinding
|
||||
* tengu_terminal_sidebar = false Terminal sidebar in REPL/config
|
||||
* tengu_marble_sandcastle = false Fast mode gate
|
||||
* tengu_jade_anvil_4 = false Rate limit options UI ordering
|
||||
* tengu_collage_kaleidoscope = true Native clipboard image paste (macOS)
|
||||
* tengu_lapis_finch = false Plugin/hint recommendation
|
||||
* tengu_lodestone_enabled = false Deep links claude-cli:// protocol
|
||||
* tengu_copper_panda = false Skill improvement suggestions
|
||||
* tengu_desktop_upsell = {} Desktop app upsell config (dynamic)
|
||||
* tengu-top-of-feed-tip = {} Emergency tip of feed (dynamic; uses dash)
|
||||
*
|
||||
* ── File operations ───────────────────────────────────────────────────
|
||||
* tengu_quartz_lantern = false File read/write dedup optimization
|
||||
* tengu_moth_copse = false Attachments handling (variant A)
|
||||
* tengu_marble_fox = false Attachments handling (variant B)
|
||||
* tengu_scratch = gate Scratchpad filesystem access / coordinator
|
||||
*
|
||||
* ── MCP & plugins ─────────────────────────────────────────────────────
|
||||
* tengu_harbor = false MCP channel allowlist verification
|
||||
* tengu_harbor_permissions = false MCP channel permissions enforcement
|
||||
* tengu_copper_bridge = false Chrome MCP bridge
|
||||
* tengu_chrome_auto_enable = false Auto-enable Chrome MCP on startup
|
||||
* tengu_glacier_2xr = false Enhanced tool search / ToolSearchTool
|
||||
* tengu_malort_pedway = {} Computer-use (Chicago) config (dynamic)
|
||||
*
|
||||
* ── VSCode / IDE ──────────────────────────────────────────────────────
|
||||
* tengu_quiet_fern = false VSCode browser support
|
||||
* tengu_vscode_cc_auth = false VSCode in-band OAuth via claude_authenticate
|
||||
* tengu_vscode_review_upsell = gate VSCode review upsell
|
||||
* tengu_vscode_onboarding = gate VSCode onboarding experience
|
||||
*
|
||||
* ── Voice ─────────────────────────────────────────────────────────────
|
||||
* tengu_amber_quartz_disabled = false VOICE_MODE kill-switch (false = voice allowed)
|
||||
*
|
||||
* ── Auto-updater (stubbed in open build) ──────────────────────────────
|
||||
* tengu_version_config = {min:'0'} Min version enforcement (dynamic)
|
||||
* tengu_max_version_config = {} Max version / deprecation config (dynamic)
|
||||
*
|
||||
* ── Telemetry & tracing ───────────────────────────────────────────────
|
||||
* tengu_trace_lantern = false Beta session tracing
|
||||
* tengu_chair_sermon = gate Analytics / message formatting gate
|
||||
* tengu_strap_foyer = false Settings sync to cloud
|
||||
*/
|
||||
|
||||
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];
|
||||
if (Object.hasOwn(_openBuildDefaults, key)) return _openBuildDefaults[key];
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
export function onGrowthBookRefresh() { return noop; }
|
||||
export function hasGrowthBookEnvOverride() { return false; }
|
||||
export function getAllGrowthBookFeatures() { return {}; }
|
||||
export function getAllGrowthBookFeatures() { _loadFlags(); return _flags || {}; }
|
||||
export function getGrowthBookConfigOverrides() { return {}; }
|
||||
export function setGrowthBookConfigOverride() {}
|
||||
export function clearGrowthBookConfigOverrides() {}
|
||||
export function getApiBaseUrlHost() { return undefined; }
|
||||
export const initializeGrowthBook = async () => null;
|
||||
export async function getFeatureValue_DEPRECATED(feature, defaultValue) { return defaultValue; }
|
||||
export function getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue) { return defaultValue; }
|
||||
export function getFeatureValue_CACHED_WITH_REFRESH(feature, defaultValue) { return defaultValue; }
|
||||
export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE() { return false; }
|
||||
export async function checkSecurityRestrictionGate() { return false; }
|
||||
export async function checkGate_CACHED_OR_BLOCKING() { return false; }
|
||||
export 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 function refreshGrowthBookAfterAuthChange() {}
|
||||
export function resetGrowthBook() {}
|
||||
export async function refreshGrowthBookFeatures() {}
|
||||
export function resetGrowthBook() { _flags = undefined; }
|
||||
export async function refreshGrowthBookFeatures() { _flags = undefined; }
|
||||
export function setupPeriodicGrowthBookRefresh() {}
|
||||
export function stopPeriodicGrowthBookRefresh() {}
|
||||
export async function getDynamicConfig_BLOCKS_ON_INIT(configName, defaultValue) { return defaultValue; }
|
||||
export function getDynamicConfig_CACHED_MAY_BE_STALE(configName, defaultValue) { return defaultValue; }
|
||||
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); }
|
||||
`,
|
||||
|
||||
'services/analytics/sink': `
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
buildAtomicChatProfileEnv,
|
||||
buildCodexProfileEnv,
|
||||
buildGeminiProfileEnv,
|
||||
buildMistralProfileEnv,
|
||||
buildOllamaProfileEnv,
|
||||
buildOpenAIProfileEnv,
|
||||
createProfileFile,
|
||||
@@ -37,7 +38,7 @@ function parseArg(name: string): string | null {
|
||||
|
||||
function parseProviderArg(): ProviderProfile | 'auto' {
|
||||
const p = parseArg('--provider')?.toLowerCase()
|
||||
if (p === 'openai' || p === 'ollama' || p === 'codex' || p === 'gemini' || p === 'atomic-chat') return p
|
||||
if (p === 'openai' || p === 'ollama' || p === 'codex' || p === 'gemini' || p === 'mistral' || p === 'atomic-chat') return p
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
@@ -90,6 +91,21 @@ 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)
|
||||
@@ -169,7 +185,7 @@ async function main(): Promise<void> {
|
||||
|
||||
console.log(`Saved profile: ${selected}`)
|
||||
console.log(`Goal: ${goal}`)
|
||||
console.log(`Model: ${profile.env.GEMINI_MODEL || profile.env.OPENAI_MODEL || getGoalDefaultOpenAIModel(goal)}`)
|
||||
console.log(`Model: ${profile.env.GEMINI_MODEL || profile.env.MISTRAL_MODEL || profile.env.OPENAI_MODEL || getGoalDefaultOpenAIModel(goal)}`)
|
||||
console.log(`Path: ${outputPath}`)
|
||||
console.log('Next: bun run dev:profile')
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ function parseLaunchOptions(argv: string[]): LaunchOptions {
|
||||
continue
|
||||
}
|
||||
|
||||
if ((lower === 'auto' || lower === 'openai' || lower === 'ollama' || lower === 'codex' || lower === 'gemini' || lower === 'atomic-chat') && requestedProfile === 'auto') {
|
||||
if ((lower === 'auto' || lower === 'openai' || lower === 'ollama' || lower === 'codex' || lower === 'gemini' || lower ==='mistral' || lower === 'atomic-chat') && requestedProfile === 'auto') {
|
||||
requestedProfile = lower as ProviderProfile | 'auto'
|
||||
continue
|
||||
}
|
||||
@@ -124,6 +124,8 @@ 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') {
|
||||
@@ -139,7 +141,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|atomic-chat|auto] [--fast] [--goal <latency|balanced|coding>] [-- <cli args>]')
|
||||
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>]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -205,6 +207,11 @@ 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)
|
||||
|
||||
50
scripts/start-grpc.ts
Normal file
50
scripts/start-grpc.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { GrpcServer } from '../src/grpc/server.ts'
|
||||
import { init } from '../src/entrypoints/init.ts'
|
||||
|
||||
// Polyfill MACRO which is normally injected by the bundler
|
||||
Object.assign(globalThis, {
|
||||
MACRO: {
|
||||
VERSION: '0.1.7',
|
||||
DISPLAY_VERSION: '0.1.7',
|
||||
PACKAGE_URL: '@gitlawb/openclaude',
|
||||
}
|
||||
})
|
||||
|
||||
async function main() {
|
||||
console.log('Starting OpenClaude gRPC Server...')
|
||||
await init()
|
||||
|
||||
// Mirror CLI bootstrap: hydrate secure tokens and resolve provider profile
|
||||
const { enableConfigs } = await import('../src/utils/config.js')
|
||||
enableConfigs()
|
||||
const { applySafeConfigEnvironmentVariables } = await import('../src/utils/managedEnv.js')
|
||||
applySafeConfigEnvironmentVariables()
|
||||
const { hydrateGeminiAccessTokenFromSecureStorage } = await import('../src/utils/geminiCredentials.js')
|
||||
hydrateGeminiAccessTokenFromSecureStorage()
|
||||
const { hydrateGithubModelsTokenFromSecureStorage } = await import('../src/utils/githubModelsCredentials.js')
|
||||
hydrateGithubModelsTokenFromSecureStorage()
|
||||
|
||||
const { buildStartupEnvFromProfile, applyProfileEnvToProcessEnv } = await import('../src/utils/providerProfile.js')
|
||||
const { getProviderValidationError, validateProviderEnvOrExit } = await import('../src/utils/providerValidation.js')
|
||||
const startupEnv = await buildStartupEnvFromProfile({ processEnv: process.env })
|
||||
if (startupEnv !== process.env) {
|
||||
const startupProfileError = await getProviderValidationError(startupEnv)
|
||||
if (startupProfileError) {
|
||||
console.warn(`Warning: ignoring saved provider profile. ${startupProfileError}`)
|
||||
} else {
|
||||
applyProfileEnvToProcessEnv(process.env, startupEnv)
|
||||
}
|
||||
}
|
||||
await validateProviderEnvOrExit()
|
||||
|
||||
const port = process.env.GRPC_PORT ? parseInt(process.env.GRPC_PORT, 10) : 50051
|
||||
const host = process.env.GRPC_HOST || 'localhost'
|
||||
const server = new GrpcServer()
|
||||
|
||||
server.start(port, host)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error starting gRPC server:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -20,6 +20,23 @@ describe('formatReachabilityFailureDetail', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('redacts credentials and sensitive query parameters in endpoint details', () => {
|
||||
const detail = formatReachabilityFailureDetail(
|
||||
'http://user:pass@localhost:11434/v1/models?token=abc123&mode=test',
|
||||
502,
|
||||
'bad gateway',
|
||||
{
|
||||
transport: 'chat_completions',
|
||||
requestedModel: 'llama3.1:8b',
|
||||
resolvedModel: 'llama3.1:8b',
|
||||
},
|
||||
)
|
||||
|
||||
expect(detail).toBe(
|
||||
'Unexpected status 502 from http://redacted:redacted@localhost:11434/v1/models?token=redacted&mode=test. Body: bad gateway',
|
||||
)
|
||||
})
|
||||
|
||||
test('adds alias/entitlement hint for codex model support 400s', () => {
|
||||
const detail = formatReachabilityFailureDetail(
|
||||
'https://chatgpt.com/backend-api/codex/responses',
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
resolveProviderRequest,
|
||||
isLocalProviderUrl as isProviderLocalUrl,
|
||||
} from '../src/services/api/providerConfig.js'
|
||||
import {
|
||||
getLocalOpenAICompatibleProviderLabel,
|
||||
probeOllamaGenerationReadiness,
|
||||
} from '../src/utils/providerDiscovery.js'
|
||||
import { redactUrlForDisplay } from '../src/utils/urlRedaction.js'
|
||||
|
||||
type CheckResult = {
|
||||
ok: boolean
|
||||
@@ -69,7 +74,7 @@ export function formatReachabilityFailureDetail(
|
||||
},
|
||||
): string {
|
||||
const compactBody = responseBody.trim().replace(/\s+/g, ' ').slice(0, 240)
|
||||
const base = `Unexpected status ${status} from ${endpoint}.`
|
||||
const base = `Unexpected status ${status} from ${redactUrlForDisplay(endpoint)}.`
|
||||
const bodySuffix = compactBody ? ` Body: ${compactBody}` : ''
|
||||
|
||||
if (request.transport !== 'codex_responses' || status !== 400) {
|
||||
@@ -118,14 +123,18 @@ function isLocalBaseUrl(baseUrl: string): boolean {
|
||||
}
|
||||
|
||||
const GEMINI_DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/openai'
|
||||
const GITHUB_MODELS_DEFAULT_BASE = 'https://models.github.ai/inference'
|
||||
const MISTRAL_DEFAULT_BASE_URL = 'https://api.mistral.ai/v1'
|
||||
const GITHUB_COPILOT_BASE = 'https://api.githubcopilot.com'
|
||||
|
||||
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_MODELS_DEFAULT_BASE
|
||||
return process.env.OPENAI_BASE_URL ?? GITHUB_COPILOT_BASE
|
||||
}
|
||||
return process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1'
|
||||
}
|
||||
@@ -155,9 +164,34 @@ 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_MODELS_DEFAULT_BASE
|
||||
const baseUrl = process.env.OPENAI_BASE_URL ?? GITHUB_COPILOT_BASE
|
||||
results.push(pass('Provider mode', 'GitHub Models provider enabled.'))
|
||||
|
||||
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN
|
||||
@@ -186,12 +220,17 @@ 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()
|
||||
}
|
||||
@@ -221,7 +260,7 @@ function checkOpenAIEnv(): CheckResult[] {
|
||||
results.push(pass('OPENAI_MODEL', process.env.OPENAI_MODEL))
|
||||
}
|
||||
|
||||
results.push(pass('OPENAI_BASE_URL', request.baseUrl))
|
||||
results.push(pass('OPENAI_BASE_URL', redactUrlForDisplay(request.baseUrl)))
|
||||
|
||||
if (request.transport === 'codex_responses') {
|
||||
const credentials = resolveCodexApiCredentials(process.env)
|
||||
@@ -268,12 +307,13 @@ 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) {
|
||||
if (!useGemini && !useOpenAI && !useGithub && !useMistral) {
|
||||
return pass('Provider reachability', 'Skipped (OpenAI-compatible mode disabled).')
|
||||
}
|
||||
|
||||
if (useGithub) {
|
||||
if (useGithub && !useOpenAI) {
|
||||
return pass(
|
||||
'Provider reachability',
|
||||
'Skipped for GitHub Models (inference endpoint differs from OpenAI /models probe).',
|
||||
@@ -291,6 +331,7 @@ async function checkBaseUrlReachability(): Promise<CheckResult> {
|
||||
const endpoint = request.transport === 'codex_responses'
|
||||
? `${request.baseUrl}/responses`
|
||||
: `${request.baseUrl}/models`
|
||||
const redactedEndpoint = redactUrlForDisplay(endpoint)
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 4000)
|
||||
@@ -326,6 +367,8 @@ 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}`
|
||||
}
|
||||
@@ -338,7 +381,10 @@ async function checkBaseUrlReachability(): Promise<CheckResult> {
|
||||
})
|
||||
|
||||
if (response.status === 200 || response.status === 401 || response.status === 403) {
|
||||
return pass('Provider reachability', `Reached ${endpoint} (status ${response.status}).`)
|
||||
return pass(
|
||||
'Provider reachability',
|
||||
`Reached ${redactedEndpoint} (status ${response.status}).`,
|
||||
)
|
||||
}
|
||||
|
||||
const responseBody = await response.text().catch(() => '')
|
||||
@@ -354,12 +400,100 @@ async function checkBaseUrlReachability(): Promise<CheckResult> {
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return fail('Provider reachability', `Failed to reach ${endpoint}: ${message}`)
|
||||
return fail(
|
||||
'Provider reachability',
|
||||
`Failed to reach ${redactedEndpoint}: ${message}`,
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkProviderGenerationReadiness(): 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) {
|
||||
return pass('Provider generation readiness', 'Skipped (OpenAI-compatible mode disabled).')
|
||||
}
|
||||
|
||||
if (useGithub && !useOpenAI) {
|
||||
return pass(
|
||||
'Provider generation readiness',
|
||||
'Skipped for GitHub Models (runtime generation uses a different endpoint flow).',
|
||||
)
|
||||
}
|
||||
|
||||
if (useGemini || useMistral) {
|
||||
return pass(
|
||||
'Provider generation readiness',
|
||||
'Skipped for managed provider mode.',
|
||||
)
|
||||
}
|
||||
|
||||
if (!useOpenAI) {
|
||||
return pass('Provider generation readiness', 'Skipped (OpenAI-compatible mode disabled).')
|
||||
}
|
||||
|
||||
const request = resolveProviderRequest({
|
||||
model: process.env.OPENAI_MODEL,
|
||||
baseUrl: process.env.OPENAI_BASE_URL,
|
||||
})
|
||||
|
||||
if (request.transport === 'codex_responses') {
|
||||
return pass(
|
||||
'Provider generation readiness',
|
||||
'Skipped for Codex responses (reachability probe already performs a lightweight generation request).',
|
||||
)
|
||||
}
|
||||
|
||||
if (!isLocalBaseUrl(request.baseUrl)) {
|
||||
return pass('Provider generation readiness', 'Skipped for non-local provider URL.')
|
||||
}
|
||||
|
||||
const localProviderLabel = getLocalOpenAICompatibleProviderLabel(request.baseUrl)
|
||||
if (localProviderLabel !== 'Ollama') {
|
||||
return pass(
|
||||
'Provider generation readiness',
|
||||
`Skipped for ${localProviderLabel} (no provider-specific generation probe).`,
|
||||
)
|
||||
}
|
||||
|
||||
const readiness = await probeOllamaGenerationReadiness({
|
||||
baseUrl: request.baseUrl,
|
||||
model: request.requestedModel,
|
||||
})
|
||||
|
||||
if (readiness.state === 'ready') {
|
||||
return pass(
|
||||
'Provider generation readiness',
|
||||
`Generated a test response with ${readiness.probeModel ?? request.requestedModel}.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (readiness.state === 'unreachable') {
|
||||
return fail(
|
||||
'Provider generation readiness',
|
||||
`Could not reach Ollama at ${redactUrlForDisplay(request.baseUrl)}.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (readiness.state === 'no_models') {
|
||||
return fail(
|
||||
'Provider generation readiness',
|
||||
'Ollama is reachable, but no installed models were found. Pull a model first (for example: ollama pull qwen2.5-coder:7b).',
|
||||
)
|
||||
}
|
||||
|
||||
const detailSuffix = readiness.detail ? ` Detail: ${readiness.detail}.` : ''
|
||||
return fail(
|
||||
'Provider generation readiness',
|
||||
`Ollama is reachable, but generation failed for ${readiness.probeModel ?? request.requestedModel}.${detailSuffix}`,
|
||||
)
|
||||
}
|
||||
|
||||
function isAtomicChatUrl(baseUrl: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(baseUrl)
|
||||
@@ -373,7 +507,8 @@ 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_GITHUB) ||
|
||||
isTruthy(process.env.CLAUDE_CODE_USE_MISTRAL)
|
||||
) {
|
||||
return pass('Ollama processor mode', 'Skipped (OpenAI-compatible mode disabled).')
|
||||
}
|
||||
@@ -425,6 +560,14 @@ 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)
|
||||
@@ -435,7 +578,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_MODELS_DEFAULT_BASE,
|
||||
process.env.OPENAI_BASE_URL ?? GITHUB_COPILOT_BASE,
|
||||
GITHUB_TOKEN_SET: Boolean(
|
||||
process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN,
|
||||
),
|
||||
@@ -521,6 +664,7 @@ async function main(): Promise<void> {
|
||||
results.push(checkBuildArtifacts())
|
||||
results.push(...checkOpenAIEnv())
|
||||
results.push(await checkBaseUrlReachability())
|
||||
results.push(await checkProviderGenerationReadiness())
|
||||
results.push(checkOllamaProcessorMode())
|
||||
|
||||
if (!options.json) {
|
||||
|
||||
@@ -249,6 +249,11 @@ export type ToolUseContext = {
|
||||
/** When true, canUseTool must always be called even when hooks auto-approve.
|
||||
* Used by speculation for overlay file path rewriting. */
|
||||
requireCanUseTool?: boolean
|
||||
/**
|
||||
* Optional callback used by hook-chain fallback actions that launch
|
||||
* AgentTool from hook runtime paths.
|
||||
*/
|
||||
hookChainsCanUseTool?: CanUseToolFn
|
||||
messages: Message[]
|
||||
fileReadingLimits?: {
|
||||
maxTokens?: number
|
||||
|
||||
290
src/__tests__/bugfixes.test.ts
Normal file
290
src/__tests__/bugfixes.test.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* 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/)
|
||||
})
|
||||
|
||||
test('codex web search path guarantees a non-empty result body', async () => {
|
||||
const content = await file(
|
||||
'tools/WebSearchTool/WebSearchTool.ts',
|
||||
).text()
|
||||
|
||||
expect(content).toContain("results.push('No results found.')")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
55
src/__tests__/providerCounts.test.ts
Normal file
55
src/__tests__/providerCounts.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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]}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
191
src/__tests__/security-hardening.test.ts
Normal file
191
src/__tests__/security-hardening.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Security hardening regression tests.
|
||||
*
|
||||
* Covers:
|
||||
* 1. MCP tool result Unicode sanitization
|
||||
* 2. Sandbox settings source filtering (exclude projectSettings)
|
||||
* 3. Plugin git clone/pull hooks disabled
|
||||
* 4. ANTHROPIC_FOUNDRY_API_KEY removed from SAFE_ENV_VARS
|
||||
* 5. WebFetch SSRF protection via ssrfGuardedLookup
|
||||
*/
|
||||
|
||||
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: MCP tool result Unicode sanitization
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('MCP tool result sanitization', () => {
|
||||
test('transformResultContent sanitizes text content', async () => {
|
||||
const content = await file('services/mcp/client.ts').text()
|
||||
// Tool definitions are already sanitized (line ~1798)
|
||||
expect(content).toContain('recursivelySanitizeUnicode(result.tools)')
|
||||
// Tool results must also be sanitized
|
||||
expect(content).toMatch(
|
||||
/case 'text':[\s\S]*?recursivelySanitizeUnicode\(resultContent\.text\)/,
|
||||
)
|
||||
})
|
||||
|
||||
test('resource text content is also sanitized', async () => {
|
||||
const content = await file('services/mcp/client.ts').text()
|
||||
expect(content).toMatch(
|
||||
/recursivelySanitizeUnicode\(\s*`\$\{prefix\}\$\{resource\.text\}`/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 2: Sandbox settings source filtering
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Sandbox settings trust boundary', () => {
|
||||
test('getSandboxEnabledSetting does not use getSettings_DEPRECATED', async () => {
|
||||
const content = await file('utils/sandbox/sandbox-adapter.ts').text()
|
||||
// Extract the getSandboxEnabledSetting function body
|
||||
const fnMatch = content.match(
|
||||
/function getSandboxEnabledSetting\(\)[^{]*\{([\s\S]*?)\n\}/,
|
||||
)
|
||||
expect(fnMatch).not.toBeNull()
|
||||
const fnBody = fnMatch![1]
|
||||
// Must NOT use getSettings_DEPRECATED (reads all sources including project)
|
||||
expect(fnBody).not.toContain('getSettings_DEPRECATED')
|
||||
// Must use getSettingsForSource for individual trusted sources
|
||||
expect(fnBody).toContain("getSettingsForSource('userSettings')")
|
||||
expect(fnBody).toContain("getSettingsForSource('policySettings')")
|
||||
// Must NOT read from projectSettings
|
||||
expect(fnBody).not.toContain("'projectSettings'")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 3: Plugin git hooks disabled
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Plugin git operations disable hooks', () => {
|
||||
test('gitClone includes core.hooksPath=/dev/null', async () => {
|
||||
const content = await file('utils/plugins/marketplaceManager.ts').text()
|
||||
// The clone args must disable hooks
|
||||
const cloneSection = content.slice(
|
||||
content.indexOf('export async function gitClone('),
|
||||
content.indexOf('export async function gitClone(') + 2000,
|
||||
)
|
||||
expect(cloneSection).toContain("'core.hooksPath=/dev/null'")
|
||||
})
|
||||
|
||||
test('gitPull includes core.hooksPath=/dev/null', async () => {
|
||||
const content = await file('utils/plugins/marketplaceManager.ts').text()
|
||||
const pullSection = content.slice(
|
||||
content.indexOf('export async function gitPull('),
|
||||
content.indexOf('export async function gitPull(') + 2000,
|
||||
)
|
||||
expect(pullSection).toContain("'core.hooksPath=/dev/null'")
|
||||
})
|
||||
|
||||
test('gitSubmoduleUpdate includes core.hooksPath=/dev/null', async () => {
|
||||
const content = await file('utils/plugins/marketplaceManager.ts').text()
|
||||
const subSection = content.slice(
|
||||
content.indexOf('async function gitSubmoduleUpdate('),
|
||||
content.indexOf('async function gitSubmoduleUpdate(') + 1000,
|
||||
)
|
||||
expect(subSection).toContain("'core.hooksPath=/dev/null'")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 4: ANTHROPIC_FOUNDRY_API_KEY not in SAFE_ENV_VARS
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('SAFE_ENV_VARS excludes credentials', () => {
|
||||
test('ANTHROPIC_FOUNDRY_API_KEY is not in SAFE_ENV_VARS', async () => {
|
||||
const content = await file('utils/managedEnvConstants.ts').text()
|
||||
// Extract the SAFE_ENV_VARS set definition
|
||||
const safeStart = content.indexOf('export const SAFE_ENV_VARS')
|
||||
const safeEnd = content.indexOf('])', safeStart)
|
||||
const safeSection = content.slice(safeStart, safeEnd)
|
||||
expect(safeSection).not.toContain('ANTHROPIC_FOUNDRY_API_KEY')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 5: WebFetch SSRF protection
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('WebFetch SSRF guard', () => {
|
||||
test('getWithPermittedRedirects uses ssrfGuardedLookup', async () => {
|
||||
const content = await file('tools/WebFetchTool/utils.ts').text()
|
||||
expect(content).toContain(
|
||||
"import { ssrfGuardedLookup } from '../../utils/hooks/ssrfGuard.js'",
|
||||
)
|
||||
// The axios.get call in getWithPermittedRedirects must include lookup
|
||||
const fnSection = content.slice(
|
||||
content.indexOf('export async function getWithPermittedRedirects('),
|
||||
content.indexOf('export async function getWithPermittedRedirects(') +
|
||||
1000,
|
||||
)
|
||||
expect(fnSection).toContain('lookup: ssrfGuardedLookup')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 6: Swarm permission file polling removed (security hardening)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Swarm permission file polling removed', () => {
|
||||
test('useSwarmPermissionPoller hook no longer exists', async () => {
|
||||
const content = await file(
|
||||
'hooks/useSwarmPermissionPoller.ts',
|
||||
).text()
|
||||
// The file-based polling hook must not exist — it read from an
|
||||
// unauthenticated resolved/ directory where any local process could
|
||||
// forge approval files.
|
||||
expect(content).not.toContain('function useSwarmPermissionPoller(')
|
||||
// The file-based processResponse must not exist
|
||||
expect(content).not.toContain('function processResponse(')
|
||||
})
|
||||
|
||||
test('poller does not import from permissionSync', async () => {
|
||||
const content = await file(
|
||||
'hooks/useSwarmPermissionPoller.ts',
|
||||
).text()
|
||||
// Must not import anything from permissionSync — all file-based
|
||||
// functions have been removed from this module's dependencies
|
||||
expect(content).not.toContain('permissionSync')
|
||||
})
|
||||
|
||||
test('file-based permission functions are marked deprecated', async () => {
|
||||
const content = await file(
|
||||
'utils/swarm/permissionSync.ts',
|
||||
).text()
|
||||
// All file-based functions must have @deprecated JSDoc
|
||||
const deprecatedFns = [
|
||||
'writePermissionRequest',
|
||||
'readPendingPermissions',
|
||||
'readResolvedPermission',
|
||||
'resolvePermission',
|
||||
'pollForResponse',
|
||||
'removeWorkerResponse',
|
||||
]
|
||||
for (const fn of deprecatedFns) {
|
||||
// Find the function and check that @deprecated appears before it
|
||||
const fnIndex = content.indexOf(`export async function ${fn}(`)
|
||||
if (fnIndex === -1) continue // submitPermissionRequest is a const, not async function
|
||||
const preceding = content.slice(Math.max(0, fnIndex - 500), fnIndex)
|
||||
expect(preceding).toContain('@deprecated')
|
||||
}
|
||||
})
|
||||
|
||||
test('mailbox-based functions are NOT deprecated', async () => {
|
||||
const content = await file(
|
||||
'utils/swarm/permissionSync.ts',
|
||||
).text()
|
||||
// These are the active path — must not be deprecated
|
||||
const activeFns = [
|
||||
'sendPermissionRequestViaMailbox',
|
||||
'sendPermissionResponseViaMailbox',
|
||||
]
|
||||
for (const fn of activeFns) {
|
||||
const fnIndex = content.indexOf(`export async function ${fn}(`)
|
||||
expect(fnIndex).not.toBe(-1)
|
||||
const preceding = content.slice(Math.max(0, fnIndex - 300), fnIndex)
|
||||
expect(preceding).not.toContain('@deprecated')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1562,29 +1562,8 @@ export function clearInvokedSkillsForAgent(agentId: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
// Slow operations tracking removed (was internal-only).
|
||||
// Functions kept as no-ops to avoid breaking callers.
|
||||
|
||||
const EMPTY_SLOW_OPERATIONS: ReadonlyArray<{
|
||||
operation: string
|
||||
@@ -1592,32 +1571,17 @@ 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
|
||||
}> {
|
||||
// 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
|
||||
return EMPTY_SLOW_OPERATIONS
|
||||
}
|
||||
|
||||
export function getMainThreadAgentType(): string | undefined {
|
||||
|
||||
@@ -14,21 +14,14 @@
|
||||
import { getOauthConfig } from '../constants/oauth.js'
|
||||
import { getClaudeAIOAuthTokens } from '../utils/auth.js'
|
||||
|
||||
/** Ant-only dev override: CLAUDE_BRIDGE_OAUTH_TOKEN, else undefined. */
|
||||
/** Dev override: CLAUDE_BRIDGE_OAUTH_TOKEN, else undefined. */
|
||||
export function getBridgeTokenOverride(): string | undefined {
|
||||
return (
|
||||
(process.env.USER_TYPE === 'ant' &&
|
||||
process.env.CLAUDE_BRIDGE_OAUTH_TOKEN) ||
|
||||
undefined
|
||||
)
|
||||
return process.env.CLAUDE_BRIDGE_OAUTH_TOKEN || undefined
|
||||
}
|
||||
|
||||
/** Ant-only dev override: CLAUDE_BRIDGE_BASE_URL, else undefined. */
|
||||
/** Dev override: CLAUDE_BRIDGE_BASE_URL, else undefined. */
|
||||
export function getBridgeBaseUrlOverride(): string | undefined {
|
||||
return (
|
||||
(process.env.USER_TYPE === 'ant' && process.env.CLAUDE_BRIDGE_BASE_URL) ||
|
||||
undefined
|
||||
)
|
||||
return process.env.CLAUDE_BRIDGE_BASE_URL || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,13 +70,13 @@ export async function isBridgeEnabledBlocking(): Promise<boolean> {
|
||||
export async function getBridgeDisabledReason(): Promise<string | null> {
|
||||
if (feature('BRIDGE_MODE')) {
|
||||
if (!isClaudeAISubscriber()) {
|
||||
return 'Remote Control requires a claude.ai subscription. Run `claude auth login` to sign in with your claude.ai account.'
|
||||
return 'Remote Control requires a claude.ai subscription. Run `openclaude auth login` to sign in with your claude.ai account.'
|
||||
}
|
||||
if (!hasProfileScope()) {
|
||||
return 'Remote Control requires a full-scope login token. Long-lived tokens (from `claude setup-token` or CLAUDE_CODE_OAUTH_TOKEN) are limited to inference-only for security reasons. Run `claude auth login` to use Remote Control.'
|
||||
return 'Remote Control requires a full-scope login token. Long-lived tokens (from `openclaude setup-token` or CLAUDE_CODE_OAUTH_TOKEN) are limited to inference-only for security reasons. Run `openclaude auth login` to use Remote Control.'
|
||||
}
|
||||
if (!getOauthAccountInfo()?.organizationUuid) {
|
||||
return 'Unable to determine your organization for Remote Control eligibility. Run `claude auth login` to refresh your account information.'
|
||||
return 'Unable to determine your organization for Remote Control eligibility. Run `openclaude auth login` to refresh your account information.'
|
||||
}
|
||||
if (!(await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge'))) {
|
||||
return 'Remote Control is not yet enabled for your account.'
|
||||
@@ -166,7 +166,7 @@ export function checkBridgeMinVersion(): string | null {
|
||||
minVersion: string
|
||||
}>('tengu_bridge_min_version', { minVersion: '0.0.0' })
|
||||
if (config.minVersion && lt(MACRO.VERSION, config.minVersion)) {
|
||||
return `Your version of Claude Code (${MACRO.VERSION}) is too old for Remote Control.\nVersion ${config.minVersion} or higher is required. Run \`claude update\` to update.`
|
||||
return `Your version of OpenClaude (${MACRO.VERSION}) is too old for Remote Control.\nVersion ${config.minVersion} or higher is required. Run \`openclaude update\` to update.`
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -2194,14 +2194,10 @@ 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 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.
|
||||
// Locally, session-ingress may run on a different port, so
|
||||
// CLAUDE_BRIDGE_SESSION_INGRESS_URL can override the default.
|
||||
const sessionIngressUrl =
|
||||
process.env.USER_TYPE === 'ant' &&
|
||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||
: baseUrl
|
||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
|
||||
|
||||
const { getBranch, getRemoteUrl, findGitRoot } = await import(
|
||||
'../utils/git.js'
|
||||
@@ -2252,7 +2248,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
})
|
||||
// biome-ignore lint/suspicious/noConsole: intentional dialog output
|
||||
console.log(
|
||||
`\nClaude Remote Control is launching in spawn mode which lets you create new sessions in this project from Claude Code on Web or your Mobile app. Learn more here: https://code.claude.com/docs/en/remote-control\n\n` +
|
||||
`\nClaude Remote Control is launching in spawn mode which lets you create new sessions in this project from OpenClaude on the web or your mobile app. Learn more here: https://code.claude.com/docs/en/remote-control\n\n` +
|
||||
`Spawn mode for this project:\n` +
|
||||
` [1] same-dir \u2014 sessions share the current directory (default)\n` +
|
||||
` [2] worktree \u2014 each session gets an isolated git worktree\n\n` +
|
||||
@@ -2851,10 +2847,7 @@ export async function runBridgeHeadless(
|
||||
)
|
||||
}
|
||||
const sessionIngressUrl =
|
||||
process.env.USER_TYPE === 'ant' &&
|
||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||
: baseUrl
|
||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
|
||||
|
||||
const { getBranch, getRemoteUrl, findGitRoot } = await import(
|
||||
'../utils/git.js'
|
||||
|
||||
@@ -217,25 +217,39 @@ 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: 10_000, validateStatus: s => s < 500 },
|
||||
{ headers, timeout: timeoutMs, validateStatus: s => s < 500 },
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
logForDebugging(
|
||||
`[bridge] Session fetch request failed: ${errorMessage(err)}`,
|
||||
)
|
||||
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)}`,
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
const detail = extractErrorDetail(response.data)
|
||||
logForDebugging(
|
||||
`[bridge] Session fetch failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
|
||||
`[bridge] Session fetch failed with status ${response.status} url=${url}${detail ? `: ${detail}` : ''}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ export async function getEnvLessBridgeConfig(): Promise<EnvLessBridgeConfig> {
|
||||
export async function checkEnvLessBridgeMinVersion(): Promise<string | null> {
|
||||
const cfg = await getEnvLessBridgeConfig()
|
||||
if (cfg.min_version && lt(MACRO.VERSION, cfg.min_version)) {
|
||||
return `Your version of Claude Code (${MACRO.VERSION}) is too old for Remote Control.\nVersion ${cfg.min_version} or higher is required. Run \`claude update\` to update.`
|
||||
return `Your version of OpenClaude (${MACRO.VERSION}) is too old for Remote Control.\nVersion ${cfg.min_version} or higher is required. Run \`openclaude update\` to update.`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -415,7 +415,7 @@ export async function initReplBridge(
|
||||
`[bridge:repl] Skipping: ${versionError}`,
|
||||
true,
|
||||
)
|
||||
onStateChange?.('failed', 'run `claude update` to upgrade')
|
||||
onStateChange?.('failed', 'run `openclaude update` to upgrade')
|
||||
return null
|
||||
}
|
||||
logForDebugging(
|
||||
@@ -456,7 +456,7 @@ export async function initReplBridge(
|
||||
const versionError = checkBridgeMinVersion()
|
||||
if (versionError) {
|
||||
logBridgeSkip('version_too_old', `[bridge:repl] Skipping: ${versionError}`)
|
||||
onStateChange?.('failed', 'run `claude update` to upgrade')
|
||||
onStateChange?.('failed', 'run `openclaude update` to upgrade')
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -465,10 +465,7 @@ export async function initReplBridge(
|
||||
const branch = await getBranch()
|
||||
const gitRepoUrl = await getRemoteUrl()
|
||||
const sessionIngressUrl =
|
||||
process.env.USER_TYPE === 'ant' &&
|
||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||
: baseUrl
|
||||
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
|
||||
|
||||
@@ -147,7 +147,7 @@ export async function enrollTrustedDevice(): Promise<void> {
|
||||
device_id?: string
|
||||
}>(
|
||||
`${baseUrl}/api/auth/trusted_devices`,
|
||||
{ display_name: `Claude Code on ${hostname()} · ${process.platform}` },
|
||||
{ display_name: `OpenClaude on ${hostname()} · ${process.platform}` },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
|
||||
@@ -287,7 +287,7 @@ export async function authStatus(opts: {
|
||||
}
|
||||
if (!loggedIn) {
|
||||
process.stdout.write(
|
||||
'Not logged in. Run claude auth login to authenticate.\n',
|
||||
'Not logged in. Run openclaude auth login to authenticate.\n',
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -83,7 +83,7 @@ export async function autoModeCritiqueHandler(options: {
|
||||
process.stdout.write(
|
||||
'No custom auto mode rules found.\n\n' +
|
||||
'Add rules to your settings file under autoMode.{allow, soft_deny, environment}.\n' +
|
||||
'Run `claude auto-mode defaults` to see the default rules for reference.\n',
|
||||
'Run `openclaude auto-mode defaults` to see the default rules for reference.\n',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -11,7 +11,12 @@ import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopI
|
||||
import { render } from '../../ink.js';
|
||||
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js';
|
||||
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js';
|
||||
import { clearMcpClientConfig, clearServerTokensFromLocalStorage, readClientSecret, saveMcpClientSecret } from '../../services/mcp/auth.js';
|
||||
import {
|
||||
clearMcpClientConfig,
|
||||
clearServerTokensFromSecureStorage,
|
||||
readClientSecret,
|
||||
saveMcpClientSecret,
|
||||
} from '../../services/mcp/auth.js'
|
||||
import { doctorAllServers, doctorServer, type McpDoctorReport, type McpDoctorScopeFilter } from '../../services/mcp/doctor.js';
|
||||
import { connectToServer, getMcpServerConnectionBatchSize } from '../../services/mcp/client.js';
|
||||
import { addMcpConfig, getAllMcpConfigs, getMcpConfigByName, getMcpConfigsByScope, removeMcpConfig } from '../../services/mcp/config.js';
|
||||
@@ -228,7 +233,7 @@ export async function mcpRemoveHandler(name: string, options: {
|
||||
});
|
||||
process.stderr.write('\nTo remove from a specific scope, use:\n');
|
||||
scopes.forEach(scope => {
|
||||
process.stderr.write(` claude mcp remove "${name}" -s ${scope}\n`);
|
||||
process.stderr.write(` openclaude mcp remove "${name}" -s ${scope}\n`);
|
||||
});
|
||||
cliError();
|
||||
}
|
||||
@@ -245,7 +250,7 @@ export async function mcpListHandler(): Promise<void> {
|
||||
} = await getAllMcpConfigs();
|
||||
if (Object.keys(configs).length === 0) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('No MCP servers configured. Use `claude mcp add` to add a server.');
|
||||
console.log('No MCP servers configured. Use `openclaude mcp add` to add a server.');
|
||||
} else {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('Checking MCP server health...\n');
|
||||
@@ -369,7 +374,7 @@ export async function mcpGetHandler(name: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`);
|
||||
console.log(`\nTo remove this server, run: openclaude mcp remove "${name}" -s ${server.scope}`);
|
||||
// Use gracefulShutdown to properly clean up MCP server connections
|
||||
// (process.exit bypasses cleanup handlers, leaving child processes orphaned)
|
||||
await gracefulShutdown(0);
|
||||
@@ -450,5 +455,5 @@ export async function mcpResetChoicesHandler(): Promise<void> {
|
||||
disabledMcpjsonServers: [],
|
||||
enableAllProjectMcpServers: false
|
||||
}));
|
||||
cliOk('All project-scoped (.mcp.json) server approvals and rejections have been reset.\n' + 'You will be prompted for approval next time you start Claude Code.');
|
||||
cliOk('All project-scoped (.mcp.json) server approvals and rejections have been reset.\n' + 'You will be prompted for approval next time you start OpenClaude.');
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ export async function pluginListHandler(options: {
|
||||
// through to the session section so the failure is visible.
|
||||
if (inlineLoadErrors.length === 0) {
|
||||
cliOk(
|
||||
'No plugins installed. Use `claude plugin install` to install a plugin.',
|
||||
'No plugins installed. Use `openclaude plugin install` to install a plugin.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,15 +362,9 @@ const proactiveModule =
|
||||
feature('PROACTIVE') || feature('KAIROS')
|
||||
? (require('../proactive/index.js') as typeof import('../proactive/index.js'))
|
||||
: null
|
||||
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 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 extractMemoriesModule = feature('EXTRACT_MEMORIES')
|
||||
? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js'))
|
||||
: null
|
||||
@@ -2701,11 +2695,7 @@ function runHeadlessStreaming(
|
||||
// the end of run() picks up the queued command.
|
||||
let cronScheduler: import('../utils/cronScheduler.js').CronScheduler | null =
|
||||
null
|
||||
if (
|
||||
feature('AGENT_TRIGGERS') &&
|
||||
cronSchedulerModule &&
|
||||
cronGate?.isKairosCronEnabled()
|
||||
) {
|
||||
if (cronGate.isKairosCronEnabled()) {
|
||||
cronScheduler = cronSchedulerModule.createCronScheduler({
|
||||
onFire: prompt => {
|
||||
if (inputClosed) return
|
||||
@@ -2727,8 +2717,8 @@ function runHeadlessStreaming(
|
||||
void run()
|
||||
},
|
||||
isLoading: () => running || inputClosed,
|
||||
getJitterConfig: cronJitterConfigModule?.getCronJitterConfig,
|
||||
isKilled: () => !cronGate?.isKairosCronEnabled(),
|
||||
getJitterConfig: cronJitterConfigModule.getCronJitterConfig,
|
||||
isKilled: () => !cronGate.isKairosCronEnabled(),
|
||||
})
|
||||
cronScheduler.start()
|
||||
}
|
||||
@@ -4592,7 +4582,7 @@ function handleSetPermissionMode(
|
||||
subtype: 'error',
|
||||
request_id: requestId,
|
||||
error:
|
||||
'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions',
|
||||
'Cannot set permission mode to bypassPermissions. Enable it with --allow-dangerously-skip-permissions or set permissions.allowBypassPermissionsMode in settings.json',
|
||||
},
|
||||
})
|
||||
return toolPermissionContext
|
||||
@@ -5036,7 +5026,7 @@ async function loadInitialMessages(
|
||||
)
|
||||
if (!parsedSessionId) {
|
||||
let errorMessage =
|
||||
'Error: --resume requires a valid session ID when used with --print. Usage: claude -p --resume <session-id>'
|
||||
'Error: --resume requires a valid session ID when used with --print. Usage: openclaude -p --resume <session-id>'
|
||||
if (typeof options.resume === 'string') {
|
||||
errorMessage += `. Session IDs must be in UUID format (e.g., 550e8400-e29b-41d4-a716-446655440000). Provided value "${options.resume}" is not a valid UUID`
|
||||
}
|
||||
|
||||
@@ -35,15 +35,20 @@ export async function update() {
|
||||
// binary (without it).
|
||||
if (getAPIProvider() !== 'firstParty') {
|
||||
writeToStdout(
|
||||
chalk.yellow('Auto-update is not available for third-party provider builds.\n') +
|
||||
'To update, pull the latest source from the repository and rebuild:\n' +
|
||||
' git pull && bun install && bun run build\n',
|
||||
chalk.yellow(
|
||||
`Auto-update is not available for third-party provider builds.\n`,
|
||||
) +
|
||||
`Current version: ${MACRO.DISPLAY_VERSION}\n\n` +
|
||||
`To update, reinstall from npm:\n` +
|
||||
chalk.bold(` npm install -g ${MACRO.PACKAGE_URL}@latest`) + '\n\n' +
|
||||
`Or, if you built from source, pull and rebuild:\n` +
|
||||
chalk.bold(' git pull && bun install && bun run build') + '\n',
|
||||
)
|
||||
return
|
||||
await gracefulShutdown(0)
|
||||
}
|
||||
|
||||
logEvent('tengu_update_check', {})
|
||||
writeToStdout(`Current version: ${MACRO.VERSION}\n`)
|
||||
writeToStdout(`Current version: ${MACRO.DISPLAY_VERSION}\n`)
|
||||
|
||||
const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest'
|
||||
writeToStdout(`Checking for updates to ${channel} version...\n`)
|
||||
@@ -123,9 +128,14 @@ export async function update() {
|
||||
if (diagnostic.installationType === 'development') {
|
||||
writeToStdout('\n')
|
||||
writeToStdout(
|
||||
chalk.yellow('Warning: Cannot update development build') + '\n',
|
||||
chalk.yellow('You are running a development build — auto-update is unavailable.') + '\n',
|
||||
)
|
||||
await gracefulShutdown(1)
|
||||
writeToStdout('To update, pull the latest source and rebuild:\n')
|
||||
writeToStdout(chalk.bold(' git pull && bun install && bun run build') + '\n')
|
||||
writeToStdout('\n')
|
||||
writeToStdout('Or reinstall from npm:\n')
|
||||
writeToStdout(chalk.bold(` npm install -g ${MACRO.PACKAGE_URL}@latest`) + '\n')
|
||||
await gracefulShutdown(0)
|
||||
}
|
||||
|
||||
// Check if running from a package manager
|
||||
@@ -136,8 +146,8 @@ export async function update() {
|
||||
if (packageManager === 'homebrew') {
|
||||
writeToStdout('Claude is managed by Homebrew.\n')
|
||||
const latest = await getLatestVersion(channel)
|
||||
if (latest && !gte(MACRO.VERSION, latest)) {
|
||||
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`)
|
||||
if (latest && !gte(MACRO.DISPLAY_VERSION, latest)) {
|
||||
writeToStdout(`Update available: ${MACRO.DISPLAY_VERSION} → ${latest}\n`)
|
||||
writeToStdout('\n')
|
||||
writeToStdout('To update, run:\n')
|
||||
writeToStdout(chalk.bold(' brew upgrade claude-code') + '\n')
|
||||
@@ -147,8 +157,8 @@ export async function update() {
|
||||
} else if (packageManager === 'winget') {
|
||||
writeToStdout('Claude is managed by winget.\n')
|
||||
const latest = await getLatestVersion(channel)
|
||||
if (latest && !gte(MACRO.VERSION, latest)) {
|
||||
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`)
|
||||
if (latest && !gte(MACRO.DISPLAY_VERSION, latest)) {
|
||||
writeToStdout(`Update available: ${MACRO.DISPLAY_VERSION} → ${latest}\n`)
|
||||
writeToStdout('\n')
|
||||
writeToStdout('To update, run:\n')
|
||||
writeToStdout(
|
||||
@@ -160,8 +170,8 @@ export async function update() {
|
||||
} else if (packageManager === 'apk') {
|
||||
writeToStdout('Claude is managed by apk.\n')
|
||||
const latest = await getLatestVersion(channel)
|
||||
if (latest && !gte(MACRO.VERSION, latest)) {
|
||||
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`)
|
||||
if (latest && !gte(MACRO.DISPLAY_VERSION, latest)) {
|
||||
writeToStdout(`Update available: ${MACRO.DISPLAY_VERSION} → ${latest}\n`)
|
||||
writeToStdout('\n')
|
||||
writeToStdout('To update, run:\n')
|
||||
writeToStdout(chalk.bold(' apk upgrade claude-code') + '\n')
|
||||
@@ -250,14 +260,14 @@ export async function update() {
|
||||
await gracefulShutdown(1)
|
||||
}
|
||||
|
||||
if (result.latestVersion === MACRO.VERSION) {
|
||||
if (result.latestVersion === MACRO.DISPLAY_VERSION) {
|
||||
writeToStdout(
|
||||
chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n',
|
||||
chalk.green(`OpenClaude is up to date (${MACRO.DISPLAY_VERSION})`) + '\n',
|
||||
)
|
||||
} else {
|
||||
writeToStdout(
|
||||
chalk.green(
|
||||
`Successfully updated from ${MACRO.VERSION} to version ${result.latestVersion}`,
|
||||
`Successfully updated from ${MACRO.DISPLAY_VERSION} to version ${result.latestVersion}`,
|
||||
) + '\n',
|
||||
)
|
||||
await regenerateCompletionCache()
|
||||
@@ -266,7 +276,7 @@ export async function update() {
|
||||
} catch (error) {
|
||||
process.stderr.write('Error: Failed to install native update\n')
|
||||
process.stderr.write(String(error) + '\n')
|
||||
process.stderr.write('Try running "claude doctor" for diagnostics\n')
|
||||
process.stderr.write('Try running "openclaude doctor" for diagnostics\n')
|
||||
await gracefulShutdown(1)
|
||||
}
|
||||
}
|
||||
@@ -320,15 +330,15 @@ export async function update() {
|
||||
}
|
||||
|
||||
// Check if versions match exactly, including any build metadata (like SHA)
|
||||
if (latestVersion === MACRO.VERSION) {
|
||||
if (latestVersion === MACRO.DISPLAY_VERSION) {
|
||||
writeToStdout(
|
||||
chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n',
|
||||
chalk.green(`OpenClaude is up to date (${MACRO.DISPLAY_VERSION})`) + '\n',
|
||||
)
|
||||
await gracefulShutdown(0)
|
||||
}
|
||||
|
||||
writeToStdout(
|
||||
`New version available: ${latestVersion} (current: ${MACRO.VERSION})\n`,
|
||||
`New version available: ${latestVersion} (current: ${MACRO.DISPLAY_VERSION})\n`,
|
||||
)
|
||||
writeToStdout('Installing update...\n')
|
||||
|
||||
@@ -388,7 +398,7 @@ export async function update() {
|
||||
case 'success':
|
||||
writeToStdout(
|
||||
chalk.green(
|
||||
`Successfully updated from ${MACRO.VERSION} to version ${latestVersion}`,
|
||||
`Successfully updated from ${MACRO.DISPLAY_VERSION} to version ${latestVersion}`,
|
||||
) + '\n',
|
||||
)
|
||||
await regenerateCompletionCache()
|
||||
@@ -400,12 +410,12 @@ export async function update() {
|
||||
if (useLocalUpdate) {
|
||||
process.stderr.write('Try manually updating with:\n')
|
||||
process.stderr.write(
|
||||
` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`,
|
||||
` cd ~/.openclaude/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: claude install\n',
|
||||
'Or consider using native installation with: openclaude install\n',
|
||||
)
|
||||
}
|
||||
await gracefulShutdown(1)
|
||||
@@ -415,11 +425,11 @@ export async function update() {
|
||||
if (useLocalUpdate) {
|
||||
process.stderr.write('Try manually updating with:\n')
|
||||
process.stderr.write(
|
||||
` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`,
|
||||
` cd ~/.openclaude/local && npm update ${MACRO.PACKAGE_URL}\n`,
|
||||
)
|
||||
} else {
|
||||
process.stderr.write(
|
||||
'Or consider using native installation with: claude install\n',
|
||||
'Or consider using native installation with: openclaude install\n',
|
||||
)
|
||||
}
|
||||
await gracefulShutdown(1)
|
||||
|
||||
30
src/commands.test.ts
Normal file
30
src/commands.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { formatDescriptionWithSource } from './commands.js'
|
||||
|
||||
describe('formatDescriptionWithSource', () => {
|
||||
test('returns empty text for prompt commands missing a description', () => {
|
||||
const command = {
|
||||
name: 'example',
|
||||
type: 'prompt',
|
||||
source: 'builtin',
|
||||
description: undefined,
|
||||
} as any
|
||||
|
||||
expect(formatDescriptionWithSource(command)).toBe('')
|
||||
})
|
||||
|
||||
test('formats plugin commands with missing description safely', () => {
|
||||
const command = {
|
||||
name: 'example',
|
||||
type: 'prompt',
|
||||
source: 'plugin',
|
||||
description: undefined,
|
||||
pluginInfo: {
|
||||
pluginManifest: {
|
||||
name: 'MyPlugin',
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
expect(formatDescriptionWithSource(command)).toBe('(MyPlugin) ')
|
||||
})
|
||||
})
|
||||
@@ -21,6 +21,7 @@ import dream from './commands/dream/index.js'
|
||||
import ctx_viz from './commands/ctx_viz/index.js'
|
||||
import doctor from './commands/doctor/index.js'
|
||||
import onboardGithub from './commands/onboard-github/index.js'
|
||||
import knowledge from './commands/knowledge/index.js'
|
||||
import memory from './commands/memory/index.js'
|
||||
import help from './commands/help/index.js'
|
||||
import ide from './commands/ide/index.js'
|
||||
@@ -32,6 +33,8 @@ 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 cacheStats from './commands/cacheStats/index.js'
|
||||
import mcp from './commands/mcp/index.js'
|
||||
import mobile from './commands/mobile/index.js'
|
||||
import onboarding from './commands/onboarding/index.js'
|
||||
@@ -136,6 +139,7 @@ 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'
|
||||
@@ -143,6 +147,7 @@ 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,
|
||||
@@ -194,7 +199,7 @@ import stats from './commands/stats/index.js'
|
||||
const usageReport: Command = {
|
||||
type: 'prompt',
|
||||
name: 'insights',
|
||||
description: 'Generate a report analyzing your Claude Code sessions',
|
||||
description: 'Generate a report analyzing your OpenClaude sessions',
|
||||
contentLength: 0,
|
||||
progressMessage: 'analyzing your sessions',
|
||||
source: 'builtin',
|
||||
@@ -263,8 +268,11 @@ const COMMANDS = memoize((): Command[] => [
|
||||
addDir,
|
||||
advisor,
|
||||
agents,
|
||||
autoFix,
|
||||
branch,
|
||||
btw,
|
||||
cacheProbe,
|
||||
cacheStats,
|
||||
chrome,
|
||||
clear,
|
||||
color,
|
||||
@@ -287,6 +295,7 @@ const COMMANDS = memoize((): Command[] => [
|
||||
ide,
|
||||
init,
|
||||
keybindings,
|
||||
knowledge,
|
||||
installGitHubApp,
|
||||
installSlackApp,
|
||||
mcp,
|
||||
@@ -324,6 +333,7 @@ const COMMANDS = memoize((): Command[] => [
|
||||
usage,
|
||||
usageReport,
|
||||
vim,
|
||||
wiki,
|
||||
...(webCmd ? [webCmd] : []),
|
||||
...(forkCmd ? [forkCmd] : []),
|
||||
...(buddy ? [buddy] : []),
|
||||
@@ -734,23 +744,23 @@ export function getCommand(commandName: string, commands: Command[]): Command {
|
||||
*/
|
||||
export function formatDescriptionWithSource(cmd: Command): string {
|
||||
if (cmd.type !== 'prompt') {
|
||||
return cmd.description
|
||||
return cmd.description ?? ''
|
||||
}
|
||||
|
||||
if (cmd.kind === 'workflow') {
|
||||
return `${cmd.description} (workflow)`
|
||||
return `${cmd.description ?? ''} (workflow)`
|
||||
}
|
||||
|
||||
if (cmd.source === 'plugin') {
|
||||
const pluginName = cmd.pluginInfo?.pluginManifest.name
|
||||
if (pluginName) {
|
||||
return `(${pluginName}) ${cmd.description}`
|
||||
return `(${pluginName}) ${cmd.description ?? ''}`
|
||||
}
|
||||
return `${cmd.description} (plugin)`
|
||||
return `${cmd.description ?? ''} (plugin)`
|
||||
}
|
||||
|
||||
if (cmd.source === 'builtin' || cmd.source === 'mcp') {
|
||||
return cmd.description
|
||||
return cmd.description ?? ''
|
||||
}
|
||||
|
||||
if (cmd.source === 'bundled') {
|
||||
|
||||
25
src/commands/auto-fix.ts
Normal file
25
src/commands/auto-fix.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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
|
||||
56
src/commands/benchmark.ts
Normal file
56
src/commands/benchmark.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { ToolUseContext } from '../Tool.js'
|
||||
import type { Command } from '../types/command.js'
|
||||
import {
|
||||
benchmarkModel,
|
||||
benchmarkMultipleModels,
|
||||
formatBenchmarkResults,
|
||||
isBenchmarkSupported,
|
||||
} from '../utils/model/benchmark.js'
|
||||
import { getOllamaModelOptions } from '../utils/model/ollamaModels.js'
|
||||
|
||||
async function runBenchmark(
|
||||
model?: string,
|
||||
context?: ToolUseContext,
|
||||
): Promise<void> {
|
||||
if (!isBenchmarkSupported()) {
|
||||
context?.stdout?.write(
|
||||
'Benchmark not supported for this provider.\n' +
|
||||
'Supported: OpenAI-compatible endpoints (Ollama, NVIDIA NIM, MiniMax)\n',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let modelsToBenchmark: string[]
|
||||
|
||||
if (model) {
|
||||
modelsToBenchmark = [model]
|
||||
} else {
|
||||
const ollamaModels = getOllamaModelOptions()
|
||||
modelsToBenchmark = ollamaModels.slice(0, 3).map((m) => m.value)
|
||||
}
|
||||
|
||||
context?.stdout?.write(`Benchmarking ${modelsToBenchmark.length} model(s)...\n`)
|
||||
|
||||
const results = await benchmarkMultipleModels(
|
||||
modelsToBenchmark,
|
||||
(completed, total, result) => {
|
||||
context?.stdout?.write(
|
||||
`[${completed}/${total}] ${result.model}: ` +
|
||||
`${result.success ? result.tokensPerSecond.toFixed(1) + ' tps' : 'FAILED'}\n`,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
context?.stdout?.write('\n' + formatBenchmarkResults(results) + '\n')
|
||||
}
|
||||
|
||||
export const benchmark: Command = {
|
||||
name: 'benchmark',
|
||||
|
||||
async onExecute(context: ToolUseContext): Promise<void> {
|
||||
const args = context.args ?? {}
|
||||
const model = args.model as string | undefined
|
||||
|
||||
await runBenchmark(model, context)
|
||||
},
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { Command } from '../../commands.js'
|
||||
const buddy = {
|
||||
type: 'local-jsx',
|
||||
name: 'buddy',
|
||||
description: 'Hatch, pet, and manage your Open Claude companion',
|
||||
description: 'Hatch, pet, and manage your OpenClaude companion',
|
||||
immediate: true,
|
||||
argumentHint: '[status|mute|unmute|help]',
|
||||
load: () => import('./buddy.js'),
|
||||
|
||||
413
src/commands/cache-probe/cache-probe.ts
Normal file
413
src/commands/cache-probe/cache-probe.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
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 }
|
||||
}
|
||||
17
src/commands/cache-probe/index.ts
Normal file
17
src/commands/cache-probe/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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
|
||||
157
src/commands/cacheStats/cacheStats.test.ts
Normal file
157
src/commands/cacheStats/cacheStats.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Tests for `/cache-stats` command rendering.
|
||||
*
|
||||
* The command has non-trivial string formatting (timestamp slicing, model
|
||||
* label padding, conditional N/A footnote, recent-rows cap) which can
|
||||
* silently regress — these snapshot tests keep it honest.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import type { CacheMetrics } from '../../services/api/cacheMetrics.js'
|
||||
import {
|
||||
_setHistoryCapForTesting,
|
||||
recordRequest,
|
||||
resetSessionCacheStats,
|
||||
} from '../../services/api/cacheStatsTracker.js'
|
||||
import { call } from './cacheStats.js'
|
||||
|
||||
function supported(partial: Partial<CacheMetrics>): CacheMetrics {
|
||||
return {
|
||||
read: 0,
|
||||
created: 0,
|
||||
total: 0,
|
||||
hitRate: null,
|
||||
supported: true,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
const UNSUPPORTED: CacheMetrics = {
|
||||
read: 0,
|
||||
created: 0,
|
||||
total: 0,
|
||||
hitRate: null,
|
||||
supported: false,
|
||||
}
|
||||
|
||||
// The command signature requires a LocalJSXCommandContext. Our command
|
||||
// doesn't actually read it — we pass an empty stand-in so the test can
|
||||
// invoke call() without dragging the whole REPL context in.
|
||||
const EMPTY_CTX = {} as Parameters<typeof call>[1]
|
||||
|
||||
// /cache-stats always returns a text result. Narrow the union here so
|
||||
// the assertions don't need to redo the discriminant check every call.
|
||||
async function runCommand(): Promise<string> {
|
||||
const result = await call('', EMPTY_CTX)
|
||||
if (result.type !== 'text') {
|
||||
throw new Error(
|
||||
`cacheStats command must return type:'text', got ${result.type}`,
|
||||
)
|
||||
}
|
||||
return result.value
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetSessionCacheStats()
|
||||
_setHistoryCapForTesting(500)
|
||||
})
|
||||
|
||||
describe('/cache-stats — empty session', () => {
|
||||
test('shows friendly "no requests yet" message', async () => {
|
||||
const value = await runCommand()
|
||||
expect(value).toContain('No API requests yet this session')
|
||||
expect(value).toContain('/cache-stats')
|
||||
})
|
||||
})
|
||||
|
||||
describe('/cache-stats — supported-only session', () => {
|
||||
test('renders Cache stats header, turn and session summaries', async () => {
|
||||
recordRequest(
|
||||
supported({ read: 500, total: 1_000, hitRate: 0.5 }),
|
||||
'claude-sonnet-4',
|
||||
)
|
||||
const value = await runCommand()
|
||||
expect(value).toContain('Cache stats')
|
||||
expect(value).toContain('Current turn:')
|
||||
expect(value).toContain('Session total:')
|
||||
// Compact metric line should appear in the recent-requests table.
|
||||
expect(value).toContain('claude-sonnet-4')
|
||||
expect(value).toContain('read')
|
||||
})
|
||||
|
||||
test('omits the N/A footnote when every row is supported', async () => {
|
||||
recordRequest(supported({ read: 200, total: 400, hitRate: 0.5 }), 'model-A')
|
||||
const value = await runCommand()
|
||||
expect(value).not.toContain('N/A rows')
|
||||
})
|
||||
})
|
||||
|
||||
describe('/cache-stats — mixed supported + unsupported', () => {
|
||||
test('renders N/A footnote when any row is unsupported', async () => {
|
||||
recordRequest(UNSUPPORTED, 'gpt-4-copilot')
|
||||
recordRequest(
|
||||
supported({ read: 100, total: 500, hitRate: 0.2 }),
|
||||
'claude-sonnet-4',
|
||||
)
|
||||
const value = await runCommand()
|
||||
expect(value).toContain(
|
||||
'N/A rows: provider API does not expose cache usage',
|
||||
)
|
||||
expect(value).toContain('GitHub Copilot')
|
||||
expect(value).toContain('Ollama')
|
||||
})
|
||||
})
|
||||
|
||||
describe('/cache-stats — recent-rows cap', () => {
|
||||
test('caps the breakdown at 20 rows and reports omitted count', async () => {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
recordRequest(
|
||||
supported({ read: i, total: 100, hitRate: i / 100 }),
|
||||
`model-${i}`,
|
||||
)
|
||||
}
|
||||
const value = await runCommand()
|
||||
// 20 shown, 5 omitted from the oldest end.
|
||||
expect(value).toContain('(20 of 25, 5 older omitted)')
|
||||
// Oldest rows (model-0..model-4) should not appear; newest must.
|
||||
expect(value).toContain('model-24')
|
||||
expect(value).not.toContain('model-0 ')
|
||||
})
|
||||
|
||||
test('does not mention "older omitted" when all rows fit', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
recordRequest(supported({ read: i, total: 10 }), `m${i}`)
|
||||
}
|
||||
const value = await runCommand()
|
||||
expect(value).not.toContain('older omitted')
|
||||
expect(value).toContain('(5)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('/cache-stats — model label rendering', () => {
|
||||
test('truncates long model labels to fit the column width', async () => {
|
||||
// cacheStats.ts pads+slices the label to 28 chars for alignment.
|
||||
const longLabel = 'some-extremely-long-model-identifier-that-wraps'
|
||||
recordRequest(supported({ read: 10, total: 100, hitRate: 0.1 }), longLabel)
|
||||
const value = await runCommand()
|
||||
// Sliced to 28 chars.
|
||||
expect(value).toContain(longLabel.slice(0, 28))
|
||||
// And the full string should NOT appear (would mean no truncation).
|
||||
expect(value).not.toContain(longLabel)
|
||||
})
|
||||
})
|
||||
|
||||
describe('/cache-stats — timestamp rendering', () => {
|
||||
test('renders each row with full date and time (YYYY-MM-DD HH:MM:SS)', async () => {
|
||||
recordRequest(supported({ read: 5, total: 10, hitRate: 0.5 }), 'claude-x')
|
||||
const value = await runCommand()
|
||||
// Match the full ISO-ish date + time the row uses. We assert the shape,
|
||||
// not a specific timestamp — real clock is used, so a regex on the
|
||||
// format is the right assertion.
|
||||
expect(value).toMatch(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/)
|
||||
// Bare time-of-day alone (no date) should NOT appear in isolation — it
|
||||
// must always be preceded by the date. Guards against regression if
|
||||
// someone shortens the formatter again.
|
||||
const timeOnlyInRow = /\n\s*#\s*\d+\s+\d{2}:\d{2}:\d{2}\s/.test(value)
|
||||
expect(timeOnlyInRow).toBe(false)
|
||||
})
|
||||
})
|
||||
74
src/commands/cacheStats/cacheStats.ts
Normal file
74
src/commands/cacheStats/cacheStats.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
getCacheStatsHistory,
|
||||
getCurrentTurnCacheMetrics,
|
||||
getSessionCacheMetrics,
|
||||
type CacheStatsEntry,
|
||||
} from '../../services/api/cacheStatsTracker.js'
|
||||
import {
|
||||
formatCacheMetricsCompact,
|
||||
formatCacheMetricsFull,
|
||||
type CacheMetrics,
|
||||
} from '../../services/api/cacheMetrics.js'
|
||||
import type { LocalCommandCall } from '../../types/command.js'
|
||||
|
||||
// Cap the per-request breakdown to keep output readable. Users wanting
|
||||
// the full history can rely on OPENCLAUDE_LOG_TOKEN_USAGE=verbose for
|
||||
// structured per-request stderr output.
|
||||
const MAX_RECENT_ROWS = 20
|
||||
|
||||
function formatRow(entry: CacheStatsEntry, idx: number): string {
|
||||
// `YYYY-MM-DD HH:MM:SS` — long-running sessions can span midnight and a
|
||||
// bare time-of-day makes the wrong row look "most recent" when two
|
||||
// entries on different days share the same HH:MM:SS.
|
||||
const iso = new Date(entry.timestamp).toISOString()
|
||||
const ts = `${iso.slice(0, 10)} ${iso.slice(11, 19)}`
|
||||
const line = formatCacheMetricsCompact(entry.metrics)
|
||||
return ` #${String(idx + 1).padStart(3)} ${ts} ${entry.label.padEnd(28).slice(0, 28)} ${line}`
|
||||
}
|
||||
|
||||
function summarize(label: string, m: CacheMetrics): string {
|
||||
return `${label.padEnd(18)}${formatCacheMetricsFull(m)}`
|
||||
}
|
||||
|
||||
export const call: LocalCommandCall = async () => {
|
||||
const history = getCacheStatsHistory()
|
||||
const session = getSessionCacheMetrics()
|
||||
const turn = getCurrentTurnCacheMetrics()
|
||||
|
||||
if (history.length === 0) {
|
||||
return {
|
||||
type: 'text',
|
||||
value:
|
||||
'Cache stats\n No API requests yet this session.\n Start a turn and re-run /cache-stats to see results.',
|
||||
}
|
||||
}
|
||||
|
||||
const recent = history.slice(-MAX_RECENT_ROWS)
|
||||
const omitted = history.length - recent.length
|
||||
|
||||
const lines: string[] = ['Cache stats', '']
|
||||
lines.push(summarize('Current turn:', turn))
|
||||
lines.push(summarize('Session total:', session))
|
||||
lines.push('')
|
||||
lines.push(`Recent requests (${recent.length}${omitted > 0 ? ` of ${history.length}, ${omitted} older omitted` : ''}):`)
|
||||
lines.push(` # time model cache`)
|
||||
for (const [i, entry] of recent.entries()) {
|
||||
lines.push(formatRow(entry, history.length - recent.length + i))
|
||||
}
|
||||
|
||||
// Honesty footnote — providers without cache reporting (vanilla Copilot,
|
||||
// Ollama) show [Cache: N/A] rather than a fake 0%. Tell the user so they
|
||||
// don't read "N/A" as "broken".
|
||||
const hasUnsupported = recent.some((e) => !e.metrics.supported)
|
||||
if (hasUnsupported) {
|
||||
lines.push('')
|
||||
lines.push(
|
||||
' N/A rows: provider API does not expose cache usage (GitHub Copilot, Ollama).',
|
||||
)
|
||||
lines.push(
|
||||
' The request still ran normally — only the metric is unavailable.',
|
||||
)
|
||||
}
|
||||
|
||||
return { type: 'text', value: lines.join('\n') }
|
||||
}
|
||||
24
src/commands/cacheStats/index.ts
Normal file
24
src/commands/cacheStats/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* /cache-stats — per-session cache diagnostics.
|
||||
*
|
||||
* Always-on diagnostic command (no toggle) that surfaces the metrics
|
||||
* tracked in `cacheStatsTracker.ts`. Breaks cache usage down by request
|
||||
* and also reports the session-wide aggregate — useful when the user
|
||||
* suspects a cache bust (e.g. after /reload-plugins) and wants to see
|
||||
* whether recent turns still hit the cache.
|
||||
*
|
||||
* Lazy-loaded (implementation in cacheStats.ts) to keep startup time
|
||||
* minimal — same pattern used by /cost and /cache-probe.
|
||||
*/
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const cacheStats = {
|
||||
type: 'local',
|
||||
name: 'cache-stats',
|
||||
description:
|
||||
'Show per-turn and session cache hit/miss stats (works across all providers)',
|
||||
supportsNonInteractive: true,
|
||||
load: () => import('./cacheStats.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default cacheStats
|
||||
@@ -197,7 +197,7 @@ function ClaudeInChromeMenu(t0) {
|
||||
}
|
||||
let t6;
|
||||
if ($[20] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t6 = <Text>Claude in Chrome works with the Chrome extension to let you control your browser directly from Claude Code. Navigate websites, fill forms, capture screenshots, record GIFs, and debug with console logs and network requests.</Text>;
|
||||
t6 = <Text>Claude in Chrome works with the Chrome extension to let you control your browser directly from OpenClaude. Navigate websites, fill forms, capture screenshots, record GIFs, and debug with console logs and network requests.</Text>;
|
||||
$[20] = t6;
|
||||
} else {
|
||||
t6 = $[20];
|
||||
|
||||
@@ -45,7 +45,7 @@ function getPromptContent(
|
||||
<!-- CHANGELOG:END -->`
|
||||
let slackStep = `
|
||||
|
||||
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.`
|
||||
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.`
|
||||
if (process.env.USER_TYPE === 'ant' && isUndercover()) {
|
||||
prefix = getUndercoverInstructions() + '\n'
|
||||
reviewerArg = ''
|
||||
|
||||
@@ -48,7 +48,7 @@ export function createMovedToPluginCommand({
|
||||
text: `This command has been moved to a plugin. Tell the user:
|
||||
|
||||
1. To install the plugin, run:
|
||||
claude plugin install ${pluginName}@claude-code-marketplace
|
||||
openclaude plugin install ${pluginName}@claude-code-marketplace
|
||||
|
||||
2. After installation, use /${pluginName}:${pluginCommand} to run this command
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
|
||||
const doctor: Command = {
|
||||
name: 'doctor',
|
||||
description: 'Diagnose and verify your Claude Code installation and settings',
|
||||
description: 'Diagnose and verify your OpenClaude installation and settings',
|
||||
isEnabled: () => !isEnvTruthy(process.env.DISABLE_DOCTOR_COMMAND),
|
||||
type: 'local-jsx',
|
||||
load: () => import('./doctor.js'),
|
||||
|
||||
@@ -7,7 +7,7 @@ const feedback = {
|
||||
aliases: ['bug'],
|
||||
type: 'local-jsx',
|
||||
name: 'feedback',
|
||||
description: `Submit feedback about Claude Code`,
|
||||
description: `Submit feedback about OpenClaude`,
|
||||
argumentHint: '[report]',
|
||||
isEnabled: () =>
|
||||
!(
|
||||
|
||||
43
src/commands/init.test.ts
Normal file
43
src/commands/init.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
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',
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { Command } from '../commands.js'
|
||||
import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js'
|
||||
import { isEnvTruthy } from '../utils/envUtils.js'
|
||||
import { isNewInitEnabled } from './initMode.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.
|
||||
|
||||
@@ -25,19 +24,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 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.
|
||||
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.
|
||||
|
||||
## Phase 1: Ask what to set up
|
||||
|
||||
Use AskUserQuestion to find out what the user wants:
|
||||
|
||||
- "Which CLAUDE.md files should /init set up?"
|
||||
Options: "Project CLAUDE.md" | "Personal CLAUDE.local.md" | "Both project + personal"
|
||||
- "Which instruction files should /init set up?"
|
||||
Options: "Project AGENTS.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 CLAUDE.md"
|
||||
Options: "Skills + hooks" | "Skills only" | "Hooks only" | "Neither, just the instruction file(s)"
|
||||
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."
|
||||
|
||||
@@ -59,24 +58,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 CLAUDE.md files and skills. Ask only things the code can't answer.
|
||||
Use AskUserQuestion to gather what you still need to write good instruction files and skills. Ask only things the code can't answer.
|
||||
|
||||
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 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 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 CLAUDE.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 AGENTS.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, 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**:
|
||||
**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**:
|
||||
|
||||
- **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.
|
||||
- **CLAUDE.md note** (looser) — influences Claude's behavior but not enforced. Fits communication/thinking preferences: "plan before coding", "be terse", "explain tradeoffs".
|
||||
- **AGENTS.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 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.
|
||||
**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.
|
||||
|
||||
**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:
|
||||
|
||||
@@ -86,17 +85,19 @@ 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\`
|
||||
• **CLAUDE.md note** (guideline) — "run lint/typecheck/test before marking done"
|
||||
• **AGENTS.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 CLAUDE.md (if user chose project or both)
|
||||
## Phase 4: Write AGENTS.md (if user chose project or both)
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**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.
|
||||
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.
|
||||
|
||||
Include:
|
||||
- Build/test/lint commands Claude can't guess (non-standard scripts, flags, or sequences)
|
||||
@@ -111,7 +112,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 CLAUDE.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 AGENTS.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")
|
||||
@@ -123,20 +124,20 @@ Do not repeat yourself and do not make up sections like "Common Development Task
|
||||
Prefix the file with:
|
||||
|
||||
\`\`\`
|
||||
# CLAUDE.md
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
\`\`\`
|
||||
|
||||
If CLAUDE.md already exists: read it, propose specific changes as diffs, and explain why each change improves it. Do not silently overwrite.
|
||||
If AGENTS.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 CLAUDE.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 AGENTS.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 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.
|
||||
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.
|
||||
|
||||
## 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 CLAUDE.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 AGENTS.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.
|
||||
|
||||
@@ -147,7 +148,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 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 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 CLAUDE.local.md already exists: read it, propose specific additions, and do not silently overwrite.
|
||||
|
||||
@@ -183,7 +184,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 CLAUDE.md and skills (if chosen) are in place.
|
||||
Tell the user you're going to suggest a few additional optimizations now that AGENTS.md and skills (if chosen) are in place.
|
||||
|
||||
Check the environment and ask about each gap you find (use AskUserQuestion):
|
||||
|
||||
@@ -195,7 +196,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 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.
|
||||
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.
|
||||
|
||||
2. Pick the event and matcher from the preference:
|
||||
- "after every edit" → \`PostToolUse\` with matcher \`Write|Edit\`
|
||||
@@ -227,11 +228,9 @@ const command = {
|
||||
type: 'prompt',
|
||||
name: 'init',
|
||||
get description() {
|
||||
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'
|
||||
return isNewInitEnabled()
|
||||
? 'Initialize new project instruction file(s) and optional skills/hooks with codebase documentation'
|
||||
: 'Initialize a new project instruction file with codebase documentation'
|
||||
},
|
||||
contentLength: 0, // Dynamic content
|
||||
progressMessage: 'analyzing your codebase',
|
||||
@@ -242,12 +241,7 @@ const command = {
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text:
|
||||
feature('NEW_INIT') &&
|
||||
(process.env.USER_TYPE === 'ant' ||
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT))
|
||||
? NEW_INIT_PROMPT
|
||||
: OLD_INIT_PROMPT,
|
||||
text: isNewInitEnabled() ? NEW_INIT_PROMPT : OLD_INIT_PROMPT,
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
13
src/commands/initMode.ts
Normal file
13
src/commands/initMode.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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
|
||||
}
|
||||
@@ -1,17 +1,12 @@
|
||||
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'
|
||||
@@ -22,7 +17,6 @@ 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'
|
||||
@@ -47,180 +41,6 @@ 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
|
||||
// ============================================================================
|
||||
@@ -427,7 +247,7 @@ function getSessionMetaDir(): string {
|
||||
return join(getDataDir(), 'session-meta')
|
||||
}
|
||||
|
||||
const FACET_EXTRACTION_PROMPT = `Analyze this Claude Code session and extract structured facets.
|
||||
const FACET_EXTRACTION_PROMPT = `Analyze this OpenClaude session and extract structured facets.
|
||||
|
||||
CRITICAL GUIDELINES:
|
||||
|
||||
@@ -867,7 +687,7 @@ function formatTranscriptForFacets(log: LogOption): string {
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const SUMMARIZE_CHUNK_PROMPT = `Summarize this portion of a Claude Code session transcript. Focus on:
|
||||
const SUMMARIZE_CHUNK_PROMPT = `Summarize this portion of a OpenClaude session transcript. Focus on:
|
||||
1. What the user asked for
|
||||
2. What Claude did (tools used, files modified)
|
||||
3. Any friction or issues
|
||||
@@ -1336,12 +1156,12 @@ type InsightSection = {
|
||||
const INSIGHT_SECTIONS: InsightSection[] = [
|
||||
{
|
||||
name: 'project_areas',
|
||||
prompt: `Analyze this Claude Code usage data and identify project areas.
|
||||
prompt: `Analyze this OpenClaude usage data and identify project areas.
|
||||
|
||||
RESPOND WITH ONLY A VALID JSON OBJECT:
|
||||
{
|
||||
"areas": [
|
||||
{"name": "Area name", "session_count": N, "description": "2-3 sentences about what was worked on and how Claude Code was used."}
|
||||
{"name": "Area name", "session_count": N, "description": "2-3 sentences about what was worked on and how OpenClaude was used."}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1350,18 +1170,18 @@ Include 4-5 areas. Skip internal CC operations.`,
|
||||
},
|
||||
{
|
||||
name: 'interaction_style',
|
||||
prompt: `Analyze this Claude Code usage data and describe the user's interaction style.
|
||||
prompt: `Analyze this OpenClaude usage data and describe the user's interaction style.
|
||||
|
||||
RESPOND WITH ONLY A VALID JSON OBJECT:
|
||||
{
|
||||
"narrative": "2-3 paragraphs analyzing HOW the user interacts with Claude Code. Use second person 'you'. Describe patterns: iterate quickly vs detailed upfront specs? Interrupt often or let Claude run? Include specific examples. Use **bold** for key insights.",
|
||||
"narrative": "2-3 paragraphs analyzing HOW the user interacts with OpenClaude. Use second person 'you'. Describe patterns: iterate quickly vs detailed upfront specs? Interrupt often or let Claude run? Include specific examples. Use **bold** for key insights.",
|
||||
"key_pattern": "One sentence summary of most distinctive interaction style"
|
||||
}`,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
name: 'what_works',
|
||||
prompt: `Analyze this Claude Code usage data and identify what's working well for this user. Use second person ("you").
|
||||
prompt: `Analyze this OpenClaude usage data and identify what's working well for this user. Use second person ("you").
|
||||
|
||||
RESPOND WITH ONLY A VALID JSON OBJECT:
|
||||
{
|
||||
@@ -1376,7 +1196,7 @@ Include 3 impressive workflows.`,
|
||||
},
|
||||
{
|
||||
name: 'friction_analysis',
|
||||
prompt: `Analyze this Claude Code usage data and identify friction points for this user. Use second person ("you").
|
||||
prompt: `Analyze this OpenClaude usage data and identify friction points for this user. Use second person ("you").
|
||||
|
||||
RESPOND WITH ONLY A VALID JSON OBJECT:
|
||||
{
|
||||
@@ -1391,7 +1211,7 @@ Include 3 friction categories with 2 examples each.`,
|
||||
},
|
||||
{
|
||||
name: 'suggestions',
|
||||
prompt: `Analyze this Claude Code usage data and suggest improvements.
|
||||
prompt: `Analyze this OpenClaude usage data and suggest improvements.
|
||||
|
||||
## CC FEATURES REFERENCE (pick from these for features_to_try):
|
||||
1. **MCP Servers**: Connect Claude to external tools, databases, and APIs via Model Context Protocol.
|
||||
@@ -1434,7 +1254,7 @@ IMPORTANT for features_to_try: Pick 2-3 from the CC FEATURES REFERENCE above. In
|
||||
},
|
||||
{
|
||||
name: 'on_the_horizon',
|
||||
prompt: `Analyze this Claude Code usage data and identify future opportunities.
|
||||
prompt: `Analyze this OpenClaude usage data and identify future opportunities.
|
||||
|
||||
RESPOND WITH ONLY A VALID JSON OBJECT:
|
||||
{
|
||||
@@ -1451,7 +1271,7 @@ Include 3 opportunities. Think BIG - autonomous workflows, parallel agents, iter
|
||||
? [
|
||||
{
|
||||
name: 'cc_team_improvements',
|
||||
prompt: `Analyze this Claude Code usage data and suggest product improvements for the CC team.
|
||||
prompt: `Analyze this OpenClaude usage data and suggest product improvements for the CC team.
|
||||
|
||||
RESPOND WITH ONLY A VALID JSON OBJECT:
|
||||
{
|
||||
@@ -1465,7 +1285,7 @@ Include 2-3 improvements based on friction patterns observed.`,
|
||||
},
|
||||
{
|
||||
name: 'model_behavior_improvements',
|
||||
prompt: `Analyze this Claude Code usage data and suggest model behavior improvements.
|
||||
prompt: `Analyze this OpenClaude usage data and suggest model behavior improvements.
|
||||
|
||||
RESPOND WITH ONLY A VALID JSON OBJECT:
|
||||
{
|
||||
@@ -1481,7 +1301,7 @@ Include 2-3 improvements based on friction patterns observed.`,
|
||||
: []),
|
||||
{
|
||||
name: 'fun_ending',
|
||||
prompt: `Analyze this Claude Code usage data and find a memorable moment.
|
||||
prompt: `Analyze this OpenClaude usage data and find a memorable moment.
|
||||
|
||||
RESPOND WITH ONLY A VALID JSON OBJECT:
|
||||
{
|
||||
@@ -1735,7 +1555,7 @@ async function generateParallelInsights(
|
||||
.join('\n') || ''
|
||||
|
||||
// Now generate "At a Glance" with access to other sections' outputs
|
||||
const atAGlancePrompt = `You're writing an "At a Glance" summary for a Claude Code usage insights report for Claude Code users. The goal is to help them understand their usage and improve how they can use Claude better, especially as models improve.
|
||||
const atAGlancePrompt = `You're writing an "At a Glance" summary for a OpenClaude usage insights report for OpenClaude users. The goal is to help them understand their usage and improve how they can use Claude better, especially as models improve.
|
||||
|
||||
Use this 4-part structure:
|
||||
|
||||
@@ -1743,7 +1563,7 @@ Use this 4-part structure:
|
||||
|
||||
2. **What's hindering you** - Split into (a) Claude's fault (misunderstandings, wrong approaches, bugs) and (b) user-side friction (not providing enough context, environment issues -- ideally more general than just one project). Be honest but constructive.
|
||||
|
||||
3. **Quick wins to try** - Specific Claude Code features they could try from the examples below, or a workflow technique if you think it's really compelling. (Avoid stuff like "Ask Claude to confirm before taking actions" or "Type out more context up front" which are less compelling.)
|
||||
3. **Quick wins to try** - Specific OpenClaude features they could try from the examples below, or a workflow technique if you think it's really compelling. (Avoid stuff like "Ask Claude to confirm before taking actions" or "Type out more context up front" which are less compelling.)
|
||||
|
||||
4. **Ambitious workflows for better models** - As we move to much more capable models over the next 3-6 months, what should they prepare for? What workflows that seem impossible now will become possible? Draw from the appropriate section below.
|
||||
|
||||
@@ -2006,7 +1826,7 @@ function generateHtmlReport(
|
||||
const interactionStyle = insights.interaction_style
|
||||
const interactionHtml = interactionStyle?.narrative
|
||||
? `
|
||||
<h2 id="section-usage">How You Use Claude Code</h2>
|
||||
<h2 id="section-usage">How You Use OpenClaude</h2>
|
||||
<div class="narrative">
|
||||
${markdownToHtml(interactionStyle.narrative)}
|
||||
${interactionStyle.key_pattern ? `<div class="key-insight"><strong>Key pattern:</strong> ${escapeHtml(interactionStyle.key_pattern)}</div>` : ''}
|
||||
@@ -2070,7 +1890,7 @@ function generateHtmlReport(
|
||||
<h2 id="section-features">Existing CC Features to Try</h2>
|
||||
<div class="claude-md-section">
|
||||
<h3>Suggested CLAUDE.md Additions</h3>
|
||||
<p style="font-size: 12px; color: #64748b; margin-bottom: 12px;">Just copy this into Claude Code to add it to your CLAUDE.md.</p>
|
||||
<p style="font-size: 12px; color: #64748b; margin-bottom: 12px;">Just copy this into OpenClaude to add it to your CLAUDE.md.</p>
|
||||
<div class="claude-md-actions">
|
||||
<button class="copy-all-btn" onclick="copyAllCheckedClaudeMd()">Copy All Checked</button>
|
||||
</div>
|
||||
@@ -2095,7 +1915,7 @@ function generateHtmlReport(
|
||||
${
|
||||
suggestions.features_to_try && suggestions.features_to_try.length > 0
|
||||
? `
|
||||
<p style="font-size: 13px; color: #64748b; margin-bottom: 12px;">Just copy this into Claude Code and it'll set it up for you.</p>
|
||||
<p style="font-size: 13px; color: #64748b; margin-bottom: 12px;">Just copy this into OpenClaude and it'll set it up for you.</p>
|
||||
<div class="features-section">
|
||||
${suggestions.features_to_try
|
||||
.map(
|
||||
@@ -2129,8 +1949,8 @@ function generateHtmlReport(
|
||||
${
|
||||
suggestions.usage_patterns && suggestions.usage_patterns.length > 0
|
||||
? `
|
||||
<h2 id="section-patterns">New Ways to Use Claude Code</h2>
|
||||
<p style="font-size: 13px; color: #64748b; margin-bottom: 12px;">Just copy this into Claude Code and it'll walk you through it.</p>
|
||||
<h2 id="section-patterns">New Ways to Use OpenClaude</h2>
|
||||
<p style="font-size: 13px; color: #64748b; margin-bottom: 12px;">Just copy this into OpenClaude and it'll walk you through it.</p>
|
||||
<div class="patterns-section">
|
||||
${suggestions.usage_patterns
|
||||
.map(
|
||||
@@ -2143,7 +1963,7 @@ function generateHtmlReport(
|
||||
pat.copyable_prompt
|
||||
? `
|
||||
<div class="copyable-prompt-section">
|
||||
<div class="prompt-label">Paste into Claude Code:</div>
|
||||
<div class="prompt-label">Paste into OpenClaude:</div>
|
||||
<div class="copyable-prompt-row">
|
||||
<code class="copyable-prompt">${escapeHtml(pat.copyable_prompt)}</code>
|
||||
<button class="copy-btn" onclick="copyText(this)">Copy</button>
|
||||
@@ -2178,7 +1998,7 @@ function generateHtmlReport(
|
||||
<div class="horizon-title">${escapeHtml(opp.title || '')}</div>
|
||||
<div class="horizon-possible">${escapeHtml(opp.whats_possible || '')}</div>
|
||||
${opp.how_to_try ? `<div class="horizon-tip"><strong>Getting started:</strong> ${escapeHtml(opp.how_to_try)}</div>` : ''}
|
||||
${opp.copyable_prompt ? `<div class="pattern-prompt"><div class="prompt-label">Paste into Claude Code:</div><code>${escapeHtml(opp.copyable_prompt)}</code><button class="copy-btn" onclick="copyText(this)">Copy</button></div>` : ''}
|
||||
${opp.copyable_prompt ? `<div class="pattern-prompt"><div class="prompt-label">Paste into OpenClaude:</div><code>${escapeHtml(opp.copyable_prompt)}</code><button class="copy-btn" onclick="copyText(this)">Copy</button></div>` : ''}
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
@@ -2485,13 +2305,13 @@ function generateHtmlReport(
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Claude Code Insights</title>
|
||||
<title>OpenClaude Insights</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>${css}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Claude Code Insights</h1>
|
||||
<h1>OpenClaude Insights</h1>
|
||||
<p class="subtitle">${data.total_messages.toLocaleString()} messages across ${data.total_sessions} sessions${data.total_sessions_scanned && data.total_sessions_scanned > data.total_sessions ? ` (${data.total_sessions_scanned.toLocaleString()} total)` : ''} | ${data.date_range.start} to ${data.date_range.end}</p>
|
||||
|
||||
${atAGlanceHtml}
|
||||
@@ -2557,7 +2377,7 @@ function generateHtmlReport(
|
||||
data.multi_clauding.overlap_events === 0
|
||||
? `
|
||||
<p style="font-size: 14px; color: #64748b; padding: 8px 0;">
|
||||
No parallel session usage detected. You typically work with one Claude Code session at a time.
|
||||
No parallel session usage detected. You typically work with one OpenClaude session at a time.
|
||||
</p>
|
||||
`
|
||||
: `
|
||||
@@ -2576,7 +2396,7 @@ function generateHtmlReport(
|
||||
</div>
|
||||
</div>
|
||||
<p style="font-size: 13px; color: #475569; margin-top: 12px;">
|
||||
You run multiple Claude Code sessions simultaneously. Multi-clauding is detected when sessions
|
||||
You run multiple OpenClaude sessions simultaneously. Multi-clauding is detected when sessions
|
||||
overlap in time, suggesting parallel workflows.
|
||||
</p>
|
||||
`
|
||||
@@ -2659,7 +2479,6 @@ 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
|
||||
@@ -2680,14 +2499,9 @@ 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>,
|
||||
@@ -2725,10 +2539,6 @@ 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,
|
||||
@@ -2793,24 +2603,12 @@ async function scanAllSessions(): Promise<LiteSessionInfo[]> {
|
||||
// Main Function
|
||||
// ============================================================================
|
||||
|
||||
export async function generateUsageReport(options?: {
|
||||
collectRemote?: boolean
|
||||
}): Promise<{
|
||||
export async function generateUsageReport(): 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
|
||||
@@ -3017,7 +2815,6 @@ export async function generateUsageReport(options?: {
|
||||
insights,
|
||||
htmlPath,
|
||||
data: aggregated,
|
||||
remoteStats,
|
||||
facets: substantiveFacets,
|
||||
}
|
||||
}
|
||||
@@ -3039,35 +2836,12 @@ function safeKeys(obj: Record<string, unknown> | undefined | null): string[] {
|
||||
const usageReport: Command = {
|
||||
type: 'prompt',
|
||||
name: 'insights',
|
||||
description: 'Generate a report analyzing your Claude Code sessions',
|
||||
description: 'Generate a report analyzing your OpenClaude sessions',
|
||||
contentLength: 0, // Dynamic content
|
||||
progressMessage: 'analyzing your sessions',
|
||||
source: 'builtin',
|
||||
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 },
|
||||
)
|
||||
async getPromptForCommand(_args) {
|
||||
const { insights, htmlPath, data } = await generateUsageReport()
|
||||
|
||||
let reportUrl = `file://${htmlPath}`
|
||||
let uploadHint = ''
|
||||
@@ -3085,20 +2859,6 @@ 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
|
||||
@@ -3114,11 +2874,10 @@ ${atAGlance.quick_wins ? `**Quick wins to try:** ${atAGlance.quick_wins} See _Fe
|
||||
${atAGlance.ambitious_workflows ? `**Ambitious workflows:** ${atAGlance.ambitious_workflows} See _On the Horizon_.` : ''}`
|
||||
: '_No insights generated_'
|
||||
|
||||
const header = `# Claude Code Insights
|
||||
const header = `# OpenClaude Insights
|
||||
|
||||
${stats}
|
||||
${data.date_range.start} to ${data.date_range.end}
|
||||
${remoteInfo}
|
||||
`
|
||||
|
||||
const userSummary = `${header}${summaryText}
|
||||
@@ -3129,7 +2888,7 @@ Your full shareable insights report is ready: ${reportUrl}${uploadHint}`
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text: `The user just ran /insights to generate a usage report analyzing their Claude Code sessions.
|
||||
text: `The user just ran /insights to generate a usage report analyzing their OpenClaude sessions.
|
||||
|
||||
Here is the full insights data:
|
||||
${jsonStringify(insights, null, 2)}
|
||||
|
||||
@@ -39,16 +39,16 @@ type InstallState = {
|
||||
message: string;
|
||||
warnings?: string[];
|
||||
};
|
||||
function getInstallationPath(): string {
|
||||
export function getInstallationPath(): string {
|
||||
const isWindows = env.platform === 'win32';
|
||||
const homeDir = homedir();
|
||||
if (isWindows) {
|
||||
// Convert to Windows-style path
|
||||
const windowsPath = join(homeDir, '.local', 'bin', 'claude.exe');
|
||||
const windowsPath = join(homeDir, '.local', 'bin', 'openclaude.exe');
|
||||
// Replace forward slashes with backslashes for Windows display
|
||||
return windowsPath.replace(/\//g, '\\');
|
||||
}
|
||||
return '~/.local/bin/claude';
|
||||
return '~/.local/bin/openclaude';
|
||||
}
|
||||
function SetupNotes(t0) {
|
||||
const $ = _c(5);
|
||||
@@ -210,12 +210,12 @@ function Install({
|
||||
useEffect(() => {
|
||||
if (state.type === 'success') {
|
||||
// Give success message time to render before exiting
|
||||
setTimeout(onDone, 2000, 'Claude Code installation completed successfully', {
|
||||
setTimeout(onDone, 2000, 'OpenClaude installation completed successfully', {
|
||||
display: 'system' as const
|
||||
});
|
||||
} else if (state.type === 'error') {
|
||||
// Give error message time to render before exiting
|
||||
setTimeout(onDone, 3000, 'Claude Code installation failed', {
|
||||
setTimeout(onDone, 3000, 'OpenClaude installation failed', {
|
||||
display: 'system' as const
|
||||
});
|
||||
}
|
||||
@@ -226,7 +226,7 @@ function Install({
|
||||
{state.type === 'cleaning-npm' && <Text color="warning">Cleaning up old npm installations...</Text>}
|
||||
|
||||
{state.type === 'installing' && <Text color="claude">
|
||||
Installing Claude Code native build {state.version}...
|
||||
Installing OpenClaude native build {state.version}...
|
||||
</Text>}
|
||||
|
||||
{state.type === 'setting-up' && <Text color="claude">Setting up launcher and shell integration...</Text>}
|
||||
@@ -237,7 +237,7 @@ function Install({
|
||||
<Box>
|
||||
<StatusIcon status="success" withSpace />
|
||||
<Text color="success" bold>
|
||||
Claude Code successfully installed!
|
||||
OpenClaude successfully installed!
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginLeft={2} flexDirection="column" gap={1}>
|
||||
@@ -254,7 +254,7 @@ function Install({
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Next: Run </Text>
|
||||
<Text color="claude" bold>
|
||||
claude --help
|
||||
openclaude --help
|
||||
</Text>
|
||||
<Text dimColor> to get started</Text>
|
||||
</Box>
|
||||
@@ -279,7 +279,7 @@ function Install({
|
||||
export const install = {
|
||||
type: 'local-jsx' as const,
|
||||
name: 'install',
|
||||
description: 'Install Claude Code native build',
|
||||
description: 'Install OpenClaude native build',
|
||||
argumentHint: '[options]',
|
||||
async call(onDone: (result: string, options?: {
|
||||
display?: CommandResultDisplay;
|
||||
|
||||
12
src/commands/knowledge/index.ts
Normal file
12
src/commands/knowledge/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const knowledge: Command = {
|
||||
type: 'local',
|
||||
name: 'knowledge',
|
||||
description: 'Manage native Knowledge Graph',
|
||||
supportsNonInteractive: true,
|
||||
argumentHint: 'enable <yes|no> | clear | status | list',
|
||||
load: () => import('./knowledge.js'),
|
||||
}
|
||||
|
||||
export default knowledge
|
||||
74
src/commands/knowledge/knowledge.test.ts
Normal file
74
src/commands/knowledge/knowledge.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, beforeEach } from 'bun:test'
|
||||
import { call as knowledgeCall } from './knowledge.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { getArc, addEntity, resetArc } from '../../utils/conversationArc.js'
|
||||
import { getGlobalGraph, resetGlobalGraph } from '../../utils/knowledgeGraph.js'
|
||||
|
||||
describe('knowledge command', () => {
|
||||
const mockContext = {} as any
|
||||
|
||||
beforeEach(() => {
|
||||
resetArc()
|
||||
resetGlobalGraph()
|
||||
})
|
||||
|
||||
const knowledgeCallWithCapture = async (args: string) => {
|
||||
const result = await knowledgeCall(args, mockContext)
|
||||
if (result.type === 'text') {
|
||||
return result.value
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Attempt to reset config - even if mocked, we try to set our key
|
||||
try {
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
knowledgeGraphEnabled: true
|
||||
}))
|
||||
} catch {
|
||||
// Ignore if config is heavily mocked
|
||||
}
|
||||
resetArc()
|
||||
})
|
||||
|
||||
it('enables and disables knowledge graph engine', async () => {
|
||||
// Test Disable
|
||||
const res1 = await knowledgeCallWithCapture('enable no')
|
||||
expect(res1.toLowerCase()).toContain('disabled')
|
||||
|
||||
// Safety check: only verify state if property is actually present (avoid CI mock interference)
|
||||
const config1 = getGlobalConfig()
|
||||
if (config1 && 'knowledgeGraphEnabled' in config1) {
|
||||
expect(config1.knowledgeGraphEnabled).toBe(false)
|
||||
}
|
||||
|
||||
// Test Enable
|
||||
const res2 = await knowledgeCallWithCapture('enable yes')
|
||||
expect(res2.toLowerCase()).toContain('enabled')
|
||||
|
||||
const config2 = getGlobalConfig()
|
||||
if (config2 && 'knowledgeGraphEnabled' in config2) {
|
||||
expect(config2.knowledgeGraphEnabled).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('clears the knowledge graph', async () => {
|
||||
// Add a fact first
|
||||
addEntity('test', 'fact')
|
||||
const graph = getGlobalGraph()
|
||||
expect(Object.keys(graph.entities).length).toBe(1)
|
||||
|
||||
// Clear it
|
||||
const res = await knowledgeCallWithCapture('clear')
|
||||
const graphAfter = getGlobalGraph()
|
||||
expect(Object.keys(graphAfter.entities).length).toBe(0)
|
||||
expect(res.toLowerCase()).toContain('cleared')
|
||||
})
|
||||
|
||||
it('shows error on unknown subcommand', async () => {
|
||||
const res = await knowledgeCallWithCapture('invalid')
|
||||
expect(res.toLowerCase()).toContain('unknown subcommand')
|
||||
})
|
||||
})
|
||||
63
src/commands/knowledge/knowledge.ts
Normal file
63
src/commands/knowledge/knowledge.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { LocalCommandCall } from '../../types/command.js';
|
||||
import { getArcSummary, resetArc, getArcStats } from '../../utils/conversationArc.js';
|
||||
import { getGlobalGraph, resetGlobalGraph } from '../../utils/knowledgeGraph.js';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
export const call: LocalCommandCall = async (args, _context) => {
|
||||
const arg = (args ? String(args) : '').trim().toLowerCase();
|
||||
const splitArgs = arg.split(/\s+/).filter(Boolean);
|
||||
const subCommand = splitArgs[0];
|
||||
|
||||
if (!subCommand || subCommand === 'status') {
|
||||
const config = getGlobalConfig();
|
||||
const stats = getArcStats();
|
||||
const graph = getGlobalGraph();
|
||||
const entityCount = Object.keys(graph.entities).length;
|
||||
|
||||
const statusText = (config.knowledgeGraphEnabled !== false)
|
||||
? chalk.green('ENABLED')
|
||||
: chalk.red('DISABLED');
|
||||
|
||||
let output = `${chalk.bold('Knowledge Graph Engine')}: ${statusText}\n`;
|
||||
if (stats) {
|
||||
output += `• Stats: ${stats.goalCount} goals, ${stats.milestoneCount} milestones, ${entityCount} technical facts learned`;
|
||||
}
|
||||
|
||||
return { type: 'text', value: output };
|
||||
}
|
||||
|
||||
if (subCommand === 'enable') {
|
||||
const val = splitArgs[1];
|
||||
const isEnabled = val === 'yes' || val === 'true';
|
||||
const isDisabled = val === 'no' || val === 'false';
|
||||
|
||||
if (!isEnabled && !isDisabled) {
|
||||
return { type: 'text', value: 'Usage: /knowledge enable <yes|no>' };
|
||||
}
|
||||
|
||||
saveGlobalConfig(current => ({ ...current, knowledgeGraphEnabled: isEnabled }));
|
||||
return {
|
||||
type: 'text',
|
||||
value: `✨ Knowledge Graph engine ${isEnabled ? chalk.green('enabled') : chalk.red('disabled')}.`
|
||||
};
|
||||
}
|
||||
|
||||
if (subCommand === 'clear') {
|
||||
resetArc();
|
||||
resetGlobalGraph();
|
||||
return {
|
||||
type: 'text',
|
||||
value: '🗑️ Knowledge graph memory has been cleared for this session.'
|
||||
};
|
||||
}
|
||||
|
||||
if (subCommand === 'list') {
|
||||
return { type: 'text', value: getArcSummary() };
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Unknown subcommand: ${subCommand}. Available: enable, clear, status, list`
|
||||
};
|
||||
};
|
||||
@@ -34,16 +34,16 @@ export function registerMcpAddCommand(mcp: Command): void {
|
||||
mcp
|
||||
.command('add <name> <commandOrUrl> [args...]')
|
||||
.description(
|
||||
'Add an MCP server to Claude Code.\n\n' +
|
||||
'Add an MCP server to OpenClaude.\n\n' +
|
||||
'Examples:\n' +
|
||||
' # Add HTTP server:\n' +
|
||||
' claude mcp add --transport http sentry https://mcp.sentry.dev/mcp\n\n' +
|
||||
' openclaude mcp add --transport http sentry https://mcp.sentry.dev/mcp\n\n' +
|
||||
' # Add HTTP server with headers:\n' +
|
||||
' claude mcp add --transport http corridor https://app.corridor.dev/api/mcp --header "Authorization: Bearer ..."\n\n' +
|
||||
' openclaude mcp add --transport http corridor https://app.corridor.dev/api/mcp --header "Authorization: Bearer ..."\n\n' +
|
||||
' # Add stdio server with environment variables:\n' +
|
||||
' claude mcp add -e API_KEY=xxx my-server -- npx my-mcp-server\n\n' +
|
||||
' openclaude mcp add -e API_KEY=xxx my-server -- npx my-mcp-server\n\n' +
|
||||
' # Add stdio server with subprocess flags:\n' +
|
||||
' claude mcp add my-server -- my-command --some-flag arg1',
|
||||
' openclaude mcp add my-server -- my-command --some-flag arg1',
|
||||
)
|
||||
.option(
|
||||
'-s, --scope <scope>',
|
||||
@@ -75,7 +75,7 @@ export function registerMcpAddCommand(mcp: Command): void {
|
||||
.addOption(
|
||||
new Option(
|
||||
'--xaa',
|
||||
"Enable XAA (SEP-990) for this server. Requires 'claude mcp xaa setup' first. Also requires --client-id and --client-secret (for the MCP server's AS).",
|
||||
"Enable XAA (SEP-990) for this server. Requires 'openclaude mcp xaa setup' first. Also requires --client-id and --client-secret (for the MCP server's AS).",
|
||||
).hideHelp(!isXaaEnabled()),
|
||||
)
|
||||
.action(async (name, commandOrUrl, args, options) => {
|
||||
@@ -87,12 +87,12 @@ export function registerMcpAddCommand(mcp: Command): void {
|
||||
if (!name) {
|
||||
cliError(
|
||||
'Error: Server name is required.\n' +
|
||||
'Usage: claude mcp add <name> <command> [args...]',
|
||||
'Usage: openclaude mcp add <name> <command> [args...]',
|
||||
)
|
||||
} else if (!actualCommand) {
|
||||
cliError(
|
||||
'Error: Command is required when server name is provided.\n' +
|
||||
'Usage: claude mcp add <name> <command> [args...]',
|
||||
'Usage: openclaude mcp add <name> <command> [args...]',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ export function registerMcpAddCommand(mcp: Command): void {
|
||||
if (!options.clientSecret) missing.push('--client-secret')
|
||||
if (!getXaaIdpSettings()) {
|
||||
missing.push(
|
||||
"'claude mcp xaa setup' (settings.xaaIdp not configured)",
|
||||
"'openclaude mcp xaa setup' (settings.xaaIdp not configured)",
|
||||
)
|
||||
}
|
||||
if (missing.length) {
|
||||
@@ -254,10 +254,10 @@ export function registerMcpAddCommand(mcp: Command): void {
|
||||
`\nWarning: The command "${actualCommand}" looks like a URL, but is being interpreted as a stdio server as --transport was not specified.\n`,
|
||||
)
|
||||
process.stderr.write(
|
||||
`If this is an HTTP server, use: claude mcp add --transport http ${name} ${actualCommand}\n`,
|
||||
`If this is an HTTP server, use: openclaude mcp add --transport http ${name} ${actualCommand}\n`,
|
||||
)
|
||||
process.stderr.write(
|
||||
`If this is an SSE server, use: claude mcp add --transport sse ${name} ${actualCommand}\n`,
|
||||
`If this is an SSE server, use: openclaude mcp add --transport sse ${name} ${actualCommand}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ export function registerMcpXaaIdpCommand(mcp: Command): void {
|
||||
const idp = getXaaIdpSettings()
|
||||
if (!idp) {
|
||||
return cliError(
|
||||
"Error: no XAA IdP connection. Run 'claude mcp xaa setup' first.",
|
||||
"Error: no XAA IdP connection. Run 'openclaude mcp xaa setup' first.",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ export function registerMcpXaaIdpCommand(mcp: Command): void {
|
||||
`Client secret: ${hasSecret ? '(stored in keychain)' : '(not set — PKCE-only)'}\n`,
|
||||
)
|
||||
process.stdout.write(
|
||||
`Logged in: ${hasIdToken ? 'yes (id_token cached)' : "no — run 'claude mcp xaa login'"}\n`,
|
||||
`Logged in: ${hasIdToken ? 'yes (id_token cached)' : "no — run 'openclaude mcp xaa login'"}\n`,
|
||||
)
|
||||
cliOk()
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ export default {
|
||||
type: 'local-jsx',
|
||||
name: 'model',
|
||||
get description() {
|
||||
return `Set the AI model for Claude Code (currently ${renderModelName(getMainLoopModel())})`
|
||||
return `Set the AI model for OpenClaude (currently ${renderModelName(getMainLoopModel())})`
|
||||
},
|
||||
argumentHint: '[model]',
|
||||
get immediate() {
|
||||
|
||||
@@ -1,20 +1,44 @@
|
||||
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'
|
||||
|
||||
@@ -30,7 +54,9 @@ test('opens the model picker without awaiting local model discovery refresh', as
|
||||
discoverOpenAICompatibleModelOptions,
|
||||
}))
|
||||
|
||||
const { call } = await import(`./model.js?ts=${Date.now()}-${Math.random()}`)
|
||||
expect(getAdditionalModelOptionsCacheScope()).toBe('openai:http://127.0.0.1:8080/v1')
|
||||
|
||||
const { call } = await import('./model.js')
|
||||
const result = await Promise.race([
|
||||
call(() => {}, {} as never, ''),
|
||||
new Promise(resolve => setTimeout(() => resolve('timeout'), 50)),
|
||||
|
||||
@@ -284,7 +284,7 @@ function haveSameModelOptions(left: ModelOption[], right: ModelOption[]): boolea
|
||||
});
|
||||
}
|
||||
async function refreshOpenAIModelOptionsCache(): Promise<void> {
|
||||
if (getAPIProvider() !== 'openai') {
|
||||
if (!getAdditionalModelOptionsCacheScope()?.startsWith('openai:')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -2,8 +2,9 @@ import type { Command } from '../../commands.js'
|
||||
|
||||
const onboardGithub: Command = {
|
||||
name: 'onboard-github',
|
||||
aliases: ['onboarding-github', 'onboardgithub', 'onboardinggithub'],
|
||||
description:
|
||||
'Interactive setup for GitHub Models: device login or PAT, saved to secure storage',
|
||||
'Interactive setup for GitHub Copilot: OAuth device login stored in secure storage',
|
||||
type: 'local-jsx',
|
||||
load: () => import('./onboard-github.js'),
|
||||
}
|
||||
|
||||
148
src/commands/onboard-github/onboard-github.test.ts
Normal file
148
src/commands/onboard-github/onboard-github.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import {
|
||||
activateGithubOnboardingMode,
|
||||
applyGithubOnboardingProcessEnv,
|
||||
buildGithubOnboardingSettingsEnv,
|
||||
hasExistingGithubModelsLoginToken,
|
||||
shouldForceGithubRelogin,
|
||||
} from './onboard-github.js'
|
||||
|
||||
describe('shouldForceGithubRelogin', () => {
|
||||
test.each(['force', '--force', 'relogin', '--relogin', 'reauth', '--reauth'])(
|
||||
'treats %s as force re-login',
|
||||
arg => {
|
||||
expect(shouldForceGithubRelogin(arg)).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
test('returns false for empty or unknown args', () => {
|
||||
expect(shouldForceGithubRelogin('')).toBe(false)
|
||||
expect(shouldForceGithubRelogin(undefined)).toBe(false)
|
||||
expect(shouldForceGithubRelogin('something-else')).toBe(false)
|
||||
})
|
||||
|
||||
test('treats force flags as present in multi-word args', () => {
|
||||
expect(shouldForceGithubRelogin('--force extra')).toBe(true)
|
||||
expect(shouldForceGithubRelogin('foo --relogin bar')).toBe(true)
|
||||
expect(shouldForceGithubRelogin('abc reauth xyz')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasExistingGithubModelsLoginToken', () => {
|
||||
test('returns true when GITHUB_TOKEN is present', () => {
|
||||
expect(
|
||||
hasExistingGithubModelsLoginToken({ GITHUB_TOKEN: 'token' }, ''),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('returns true when GH_TOKEN is present', () => {
|
||||
expect(
|
||||
hasExistingGithubModelsLoginToken({ GH_TOKEN: 'token' }, ''),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('returns true when stored token exists', () => {
|
||||
expect(hasExistingGithubModelsLoginToken({}, 'stored-token')).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false when both env and stored token are missing', () => {
|
||||
expect(hasExistingGithubModelsLoginToken({}, '')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onboarding auth precedence cleanup', () => {
|
||||
test('clears preexisting OpenAI auth when switching to GitHub', () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
CLAUDE_CODE_USE_OPENAI: '1',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
OPENAI_API_KEY: 'sk-stale-openai-key',
|
||||
OPENAI_ORG: 'org-old',
|
||||
OPENAI_PROJECT: 'project-old',
|
||||
OPENAI_ORGANIZATION: 'org-legacy',
|
||||
OPENAI_BASE_URL: 'https://api.openai.com/v1',
|
||||
OPENAI_API_BASE: 'https://api.openai.com/v1',
|
||||
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED: '1',
|
||||
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID: 'profile_old',
|
||||
}
|
||||
|
||||
applyGithubOnboardingProcessEnv('github:copilot', env)
|
||||
|
||||
expect(env.CLAUDE_CODE_USE_GITHUB).toBe('1')
|
||||
expect(env.OPENAI_MODEL).toBe('github:copilot')
|
||||
|
||||
expect(env.OPENAI_API_KEY).toBeUndefined()
|
||||
expect(env.OPENAI_ORG).toBeUndefined()
|
||||
expect(env.OPENAI_PROJECT).toBeUndefined()
|
||||
expect(env.OPENAI_ORGANIZATION).toBeUndefined()
|
||||
expect(env.OPENAI_BASE_URL).toBeUndefined()
|
||||
expect(env.OPENAI_API_BASE).toBeUndefined()
|
||||
|
||||
expect(env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
|
||||
expect(env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED).toBeUndefined()
|
||||
expect(env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBeUndefined()
|
||||
|
||||
const settingsEnv = buildGithubOnboardingSettingsEnv('github:copilot')
|
||||
expect(settingsEnv.CLAUDE_CODE_USE_GITHUB).toBe('1')
|
||||
expect(settingsEnv.OPENAI_MODEL).toBe('github:copilot')
|
||||
expect(settingsEnv.OPENAI_API_KEY).toBeUndefined()
|
||||
expect(settingsEnv.OPENAI_ORG).toBeUndefined()
|
||||
expect(settingsEnv.OPENAI_PROJECT).toBeUndefined()
|
||||
expect(settingsEnv.OPENAI_ORGANIZATION).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('activateGithubOnboardingMode', () => {
|
||||
test('activates settings/env/hydration in order when merge succeeds', () => {
|
||||
const calls: string[] = []
|
||||
|
||||
const result = activateGithubOnboardingMode(' github:copilot ', {
|
||||
mergeSettingsEnv: model => {
|
||||
calls.push(`merge:${model}`)
|
||||
return { ok: true }
|
||||
},
|
||||
applyProcessEnv: model => {
|
||||
calls.push(`apply:${model}`)
|
||||
},
|
||||
hydrateToken: () => {
|
||||
calls.push('hydrate')
|
||||
},
|
||||
onChangeAPIKey: () => {
|
||||
calls.push('onChangeAPIKey')
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(calls).toEqual([
|
||||
'merge:github:copilot',
|
||||
'apply:github:copilot',
|
||||
'hydrate',
|
||||
'onChangeAPIKey',
|
||||
])
|
||||
})
|
||||
|
||||
test('stops activation when settings merge fails', () => {
|
||||
const calls: string[] = []
|
||||
|
||||
const result = activateGithubOnboardingMode(DEFAULT_MODEL_FOR_TESTS, {
|
||||
mergeSettingsEnv: () => {
|
||||
calls.push('merge')
|
||||
return { ok: false, detail: 'settings write failed' }
|
||||
},
|
||||
applyProcessEnv: () => {
|
||||
calls.push('apply')
|
||||
},
|
||||
hydrateToken: () => {
|
||||
calls.push('hydrate')
|
||||
},
|
||||
onChangeAPIKey: () => {
|
||||
calls.push('onChangeAPIKey')
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: false, detail: 'settings write failed' })
|
||||
expect(calls).toEqual(['merge'])
|
||||
})
|
||||
})
|
||||
|
||||
const DEFAULT_MODEL_FOR_TESTS = 'github:copilot'
|
||||
@@ -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,
|
||||
@@ -12,29 +12,134 @@ import {
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import {
|
||||
hydrateGithubModelsTokenFromSecureStorage,
|
||||
readGithubModelsToken,
|
||||
saveGithubModelsToken,
|
||||
} from '../../utils/githubModelsCredentials.js'
|
||||
import { updateSettingsForSource } from '../../utils/settings/settings.js'
|
||||
import { getSettingsForSource, updateSettingsForSource } from '../../utils/settings/settings.js'
|
||||
|
||||
const DEFAULT_MODEL = 'github:copilot'
|
||||
const FORCE_RELOGIN_ARGS = new Set([
|
||||
'force',
|
||||
'--force',
|
||||
'relogin',
|
||||
'--relogin',
|
||||
'reauth',
|
||||
'--reauth',
|
||||
])
|
||||
|
||||
type Step =
|
||||
| 'menu'
|
||||
| 'device-busy'
|
||||
| 'pat'
|
||||
| 'error'
|
||||
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',
|
||||
])
|
||||
|
||||
export function shouldForceGithubRelogin(args?: string): boolean {
|
||||
const normalized = (args ?? '').trim().toLowerCase()
|
||||
if (!normalized) {
|
||||
return false
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
export function buildGithubOnboardingSettingsEnv(
|
||||
model: string,
|
||||
): Record<string, string | undefined> {
|
||||
return {
|
||||
CLAUDE_CODE_USE_GITHUB: '1',
|
||||
OPENAI_MODEL: model,
|
||||
OPENAI_API_KEY: undefined,
|
||||
OPENAI_ORG: undefined,
|
||||
OPENAI_PROJECT: undefined,
|
||||
OPENAI_ORGANIZATION: undefined,
|
||||
OPENAI_BASE_URL: undefined,
|
||||
OPENAI_API_BASE: undefined,
|
||||
CLAUDE_CODE_USE_OPENAI: undefined,
|
||||
CLAUDE_CODE_USE_GEMINI: undefined,
|
||||
CLAUDE_CODE_USE_BEDROCK: undefined,
|
||||
CLAUDE_CODE_USE_VERTEX: undefined,
|
||||
CLAUDE_CODE_USE_FOUNDRY: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function applyGithubOnboardingProcessEnv(
|
||||
model: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): void {
|
||||
env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||
env.OPENAI_MODEL = model
|
||||
|
||||
delete env.OPENAI_API_KEY
|
||||
delete env.OPENAI_ORG
|
||||
delete env.OPENAI_PROJECT
|
||||
delete env.OPENAI_ORGANIZATION
|
||||
delete env.OPENAI_BASE_URL
|
||||
delete env.OPENAI_API_BASE
|
||||
|
||||
delete env.CLAUDE_CODE_USE_OPENAI
|
||||
delete env.CLAUDE_CODE_USE_GEMINI
|
||||
delete env.CLAUDE_CODE_USE_BEDROCK
|
||||
delete env.CLAUDE_CODE_USE_VERTEX
|
||||
delete env.CLAUDE_CODE_USE_FOUNDRY
|
||||
delete env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
|
||||
delete env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
|
||||
}
|
||||
|
||||
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: {
|
||||
CLAUDE_CODE_USE_GITHUB: '1',
|
||||
OPENAI_MODEL: model,
|
||||
CLAUDE_CODE_USE_OPENAI: undefined as any,
|
||||
CLAUDE_CODE_USE_GEMINI: undefined as any,
|
||||
CLAUDE_CODE_USE_BEDROCK: undefined as any,
|
||||
CLAUDE_CODE_USE_VERTEX: undefined as any,
|
||||
CLAUDE_CODE_USE_FOUNDRY: undefined as any,
|
||||
},
|
||||
env: newEnv,
|
||||
})
|
||||
if (error) {
|
||||
return { ok: false, detail: error.message }
|
||||
@@ -42,6 +147,32 @@ function mergeUserSettingsEnv(model: string): { ok: boolean; detail?: string } {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
export function activateGithubOnboardingMode(
|
||||
model: string = DEFAULT_MODEL,
|
||||
options?: {
|
||||
mergeSettingsEnv?: (model: string) => { ok: boolean; detail?: string }
|
||||
applyProcessEnv?: (model: string) => void
|
||||
hydrateToken?: () => void
|
||||
onChangeAPIKey?: () => void
|
||||
},
|
||||
): { ok: boolean; detail?: string } {
|
||||
const normalizedModel = model.trim() || DEFAULT_MODEL
|
||||
const mergeSettingsEnv = options?.mergeSettingsEnv ?? mergeUserSettingsEnv
|
||||
const applyProcessEnv = options?.applyProcessEnv ?? applyGithubOnboardingProcessEnv
|
||||
const hydrateToken =
|
||||
options?.hydrateToken ?? hydrateGithubModelsTokenFromSecureStorage
|
||||
|
||||
const merged = mergeSettingsEnv(normalizedModel)
|
||||
if (!merged.ok) {
|
||||
return merged
|
||||
}
|
||||
|
||||
applyProcessEnv(normalizedModel)
|
||||
hydrateToken()
|
||||
options?.onChangeAPIKey?.()
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function OnboardGithub(props: {
|
||||
onDone: Parameters<LocalJSXCommandCall>[0]
|
||||
onChangeAPIKey: () => void
|
||||
@@ -53,32 +184,42 @@ 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) => {
|
||||
const saved = saveGithubModelsToken(token)
|
||||
async (
|
||||
token: string,
|
||||
model: string = DEFAULT_MODEL,
|
||||
oauthToken?: string,
|
||||
) => {
|
||||
const saved = saveGithubModelsToken(token, oauthToken)
|
||||
if (!saved.success) {
|
||||
setErrorMsg(saved.warning ?? 'Could not save token to secure storage.')
|
||||
setStep('error')
|
||||
return
|
||||
}
|
||||
const merged = mergeUserSettingsEnv(model.trim() || DEFAULT_MODEL)
|
||||
if (!merged.ok) {
|
||||
const activated = activateGithubOnboardingMode(model, {
|
||||
onChangeAPIKey,
|
||||
})
|
||||
if (!activated.ok) {
|
||||
setErrorMsg(
|
||||
`Token saved, but settings were not updated: ${merged.detail ?? 'unknown error'}. ` +
|
||||
`Token saved, but settings were not updated: ${activated.detail ?? 'unknown error'}. ` +
|
||||
`Add env CLAUDE_CODE_USE_GITHUB=1 and OPENAI_MODEL to ~/.claude/settings.json manually.`,
|
||||
)
|
||||
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 Models onboard complete. Token stored in secure storage; user settings updated. Restart if the model does not switch.',
|
||||
'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.',
|
||||
{ display: 'user' },
|
||||
)
|
||||
},
|
||||
@@ -96,11 +237,12 @@ function OnboardGithub(props: {
|
||||
verification_uri: device.verification_uri,
|
||||
})
|
||||
await openVerificationUri(device.verification_uri)
|
||||
const token = await pollAccessToken(device.device_code, {
|
||||
const oauthToken = await pollAccessToken(device.device_code, {
|
||||
initialInterval: device.interval,
|
||||
timeoutSeconds: device.expires_in,
|
||||
})
|
||||
await finalize(token, DEFAULT_MODEL)
|
||||
const copilotToken = await exchangeForCopilotToken(oauthToken)
|
||||
await finalize(copilotToken.token, DEFAULT_MODEL, oauthToken)
|
||||
} catch (e) {
|
||||
setErrorMsg(e instanceof Error ? e.message : String(e))
|
||||
setStep('error')
|
||||
@@ -139,7 +281,7 @@ function OnboardGithub(props: {
|
||||
if (step === 'device-busy') {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>GitHub device login</Text>
|
||||
<Text>GitHub Copilot sign-in</Text>
|
||||
{deviceHint ? (
|
||||
<>
|
||||
<Text>
|
||||
@@ -147,54 +289,22 @@ function OnboardGithub(props: {
|
||||
{deviceHint.verification_uri}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
A browser window may have opened. Waiting for authorization…
|
||||
A browser window may have opened. Waiting for authorization...
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text dimColor>Requesting device code from GitHub…</Text>
|
||||
<Text dimColor>Requesting device code from GitHub...</Text>
|
||||
)}
|
||||
<Spinner />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
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 (device code)',
|
||||
label: 'Sign in with browser',
|
||||
value: 'device' as const,
|
||||
},
|
||||
{
|
||||
label: 'Paste personal access token',
|
||||
value: 'pat' as const,
|
||||
},
|
||||
{
|
||||
label: 'Cancel',
|
||||
value: 'cancel' as const,
|
||||
@@ -203,10 +313,10 @@ function OnboardGithub(props: {
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text bold>GitHub Models setup</Text>
|
||||
<Text bold>GitHub Copilot 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
|
||||
and enables CLAUDE_CODE_USE_GITHUB in your user settings - no export
|
||||
GITHUB_TOKEN needed for future runs.
|
||||
</Text>
|
||||
<Select
|
||||
@@ -216,10 +326,6 @@ function OnboardGithub(props: {
|
||||
onDone('GitHub onboard cancelled', { display: 'system' })
|
||||
return
|
||||
}
|
||||
if (v === 'pat') {
|
||||
setStep('pat')
|
||||
return
|
||||
}
|
||||
void runDeviceFlow()
|
||||
}}
|
||||
/>
|
||||
@@ -227,7 +333,28 @@ function OnboardGithub(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export const call: LocalJSXCommandCall = async (onDone, context) => {
|
||||
export const call: LocalJSXCommandCall = async (onDone, context, args) => {
|
||||
const forceRelogin = shouldForceGithubRelogin(args)
|
||||
if (hasExistingGithubModelsLoginToken() && !forceRelogin) {
|
||||
const activated = activateGithubOnboardingMode(DEFAULT_MODEL, {
|
||||
onChangeAPIKey: context.onChangeAPIKey,
|
||||
})
|
||||
if (!activated.ok) {
|
||||
onDone(
|
||||
`GitHub token detected, but settings activation failed: ${activated.detail ?? 'unknown error'}. ` +
|
||||
'Set CLAUDE_CODE_USE_GITHUB=1 and OPENAI_MODEL=github:copilot in user settings manually.',
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
onDone(
|
||||
'GitHub Models already authorized. Activated GitHub Models mode using your existing token. Use /onboard-github --force to re-authenticate.',
|
||||
{ display: 'user' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardGithub
|
||||
onDone={onDone}
|
||||
|
||||
@@ -713,7 +713,7 @@ function EmptyStateMessage(t0) {
|
||||
{
|
||||
let t1;
|
||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t1 = <><Text dimColor={true}>Git is required to install marketplaces.</Text><Text dimColor={true}>Please install git and restart Claude Code.</Text></>;
|
||||
t1 = <><Text dimColor={true}>Git is required to install marketplaces.</Text><Text dimColor={true}>Please install git and restart OpenClaude.</Text></>;
|
||||
$[0] = t1;
|
||||
} else {
|
||||
t1 = $[0];
|
||||
|
||||
@@ -3,7 +3,7 @@ const plugin = {
|
||||
type: 'local-jsx',
|
||||
name: 'plugin',
|
||||
aliases: ['plugins', 'marketplace'],
|
||||
description: 'Manage Claude Code plugins',
|
||||
description: 'Manage OpenClaude plugins',
|
||||
immediate: true,
|
||||
load: () => import('./plugin.js')
|
||||
} satisfies Command;
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
|
||||
import { expect, test } from 'bun:test'
|
||||
import { afterEach, expect, mock, 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,
|
||||
buildProviderManagerCompletion,
|
||||
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
|
||||
@@ -52,10 +61,59 @@ async function renderFinalFrame(node: React.ReactNode): Promise<string> {
|
||||
patchConsole: false,
|
||||
})
|
||||
|
||||
await instance.waitUntilExit()
|
||||
// Timeout guard: if render throws before exit effect fires, don't hang
|
||||
await Promise.race([
|
||||
instance.waitUntilExit(),
|
||||
new Promise<void>(resolve => setTimeout(resolve, 3000)),
|
||||
])
|
||||
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 & {
|
||||
@@ -90,6 +148,34 @@ 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')
|
||||
@@ -179,6 +265,32 @@ test('wizard step remount prevents a typed API key from leaking into the next fi
|
||||
expect(output).not.toContain('sk-secret-12345678')
|
||||
})
|
||||
|
||||
test('buildProviderManagerCompletion records provider switch event and model-visible reminder', () => {
|
||||
const completion = buildProviderManagerCompletion({
|
||||
action: 'activated',
|
||||
activeProviderName: 'Sadaf Provider',
|
||||
activeProviderModel: 'sadaf-model',
|
||||
message: 'Provider switched to Sadaf Provider (sadaf-model)',
|
||||
})
|
||||
|
||||
expect(completion.message).toBe(
|
||||
'Provider switched to Sadaf Provider (sadaf-model)',
|
||||
)
|
||||
expect(completion.metaMessages).toEqual([
|
||||
'<system-reminder>Provider switched mid-session to Sadaf Provider using model sadaf-model. Use this provider/model for subsequent requests unless the user switches again.</system-reminder>',
|
||||
])
|
||||
})
|
||||
|
||||
test('buildProviderManagerCompletion skips provider reminder when manager is cancelled', () => {
|
||||
const completion = buildProviderManagerCompletion({
|
||||
action: 'cancelled',
|
||||
message: 'Provider manager closed',
|
||||
})
|
||||
|
||||
expect(completion.message).toBe('Provider manager closed')
|
||||
expect(completion.metaMessages).toBeUndefined()
|
||||
})
|
||||
|
||||
test('buildProfileSaveMessage maps provider fields without echoing secrets', () => {
|
||||
const message = buildProfileSaveMessage(
|
||||
'openai',
|
||||
@@ -229,6 +341,167 @@ 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('explicitly declared env takes precedence over applySavedProfileToCurrentSession', 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('gpt-4o')
|
||||
expect(processEnv.OPENAI_BASE_URL).toBe(
|
||||
"https://api.openai.com/v1",
|
||||
)
|
||||
expect(processEnv.CODEX_API_KEY).toBeUndefined()
|
||||
expect(processEnv.CHATGPT_ACCOUNT_ID).toBeUndefined()
|
||||
expect(processEnv.OPENAI_API_KEY).toBe("sk-openai")
|
||||
expect(processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED).toBeUndefined()
|
||||
expect(processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBeUndefined()
|
||||
})
|
||||
|
||||
test('explicitly declared env takes precedence over applySavedProfileToCurrentSession', 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).not.toBeUndefined()
|
||||
expect(processEnv.OPENAI_MODEL).toBe('gpt-4o')
|
||||
expect(processEnv.OPENAI_BASE_URL).toBe(
|
||||
"https://api.openai.com/v1",
|
||||
)
|
||||
expect(processEnv.CODEX_API_KEY).toBe("stale-codex-key")
|
||||
expect(processEnv.CHATGPT_ACCOUNT_ID).toBe('acct_stale')
|
||||
expect(processEnv.CHATGPT_ACCOUNT_ID).toBeTruthy()
|
||||
})
|
||||
|
||||
test('buildCurrentProviderSummary redacts poisoned model and endpoint values', () => {
|
||||
const summary = buildCurrentProviderSummary({
|
||||
processEnv: {
|
||||
@@ -241,8 +514,8 @@ test('buildCurrentProviderSummary redacts poisoned model and endpoint values', (
|
||||
})
|
||||
|
||||
expect(summary.providerLabel).toBe('OpenAI-compatible')
|
||||
expect(summary.modelLabel).toBe('sk-...5678')
|
||||
expect(summary.endpointLabel).toBe('sk-...5678')
|
||||
expect(summary.modelLabel).toBe('sk-...678')
|
||||
expect(summary.endpointLabel).toBe('sk-...678')
|
||||
})
|
||||
|
||||
test('buildCurrentProviderSummary labels generic local openai-compatible providers', () => {
|
||||
@@ -260,7 +533,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', () => {
|
||||
test('buildCurrentProviderSummary does not relabel local gpt-5.4 providers as Codex when custom base URL is set', () => {
|
||||
const summary = buildCurrentProviderSummary({
|
||||
processEnv: {
|
||||
CLAUDE_CODE_USE_OPENAI: '1',
|
||||
@@ -275,6 +548,21 @@ test('buildCurrentProviderSummary does not relabel local gpt-5.4 providers as Co
|
||||
expect(summary.endpointLabel).toBe('http://127.0.0.1:8080/v1')
|
||||
})
|
||||
|
||||
test('buildCurrentProviderSummary recognizes GitHub Models mode', () => {
|
||||
const summary = buildCurrentProviderSummary({
|
||||
processEnv: {
|
||||
CLAUDE_CODE_USE_GITHUB: '1',
|
||||
OPENAI_MODEL: 'github:copilot',
|
||||
OPENAI_BASE_URL: 'https://models.github.ai/inference',
|
||||
},
|
||||
persisted: null,
|
||||
})
|
||||
|
||||
expect(summary.providerLabel).toBe('GitHub Models')
|
||||
expect(summary.modelLabel).toBe('github:copilot')
|
||||
expect(summary.endpointLabel).toBe('https://models.github.ai/inference')
|
||||
})
|
||||
|
||||
test('getProviderWizardDefaults ignores poisoned current provider values', () => {
|
||||
const defaults = getProviderWizardDefaults({
|
||||
OPENAI_API_KEY: 'sk-secret-12345678',
|
||||
@@ -288,3 +576,12 @@ 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')
|
||||
})
|
||||
|
||||
@@ -2,7 +2,10 @@ import * as React from 'react'
|
||||
|
||||
import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js'
|
||||
import { ProviderManager } from '../../components/ProviderManager.js'
|
||||
import {
|
||||
ProviderManager,
|
||||
type ProviderManagerResult,
|
||||
} from '../../components/ProviderManager.js'
|
||||
import TextInput from '../../components/TextInput.js'
|
||||
import {
|
||||
Select,
|
||||
@@ -10,8 +13,12 @@ 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,
|
||||
@@ -20,13 +27,18 @@ 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,
|
||||
@@ -46,6 +58,7 @@ import {
|
||||
readGeminiAccessToken,
|
||||
saveGeminiAccessToken,
|
||||
} from '../../utils/geminiCredentials.js'
|
||||
import { isBareMode } from '../../utils/envUtils.js'
|
||||
import {
|
||||
getGoalDefaultOpenAIModel,
|
||||
normalizeRecommendationGoal,
|
||||
@@ -54,12 +67,70 @@ import {
|
||||
type RecommendationGoal,
|
||||
} from '../../utils/providerRecommendation.js'
|
||||
import {
|
||||
getOllamaChatBaseUrl,
|
||||
getLocalOpenAICompatibleProviderLabel,
|
||||
hasLocalOllama,
|
||||
listOllamaModels,
|
||||
probeOllamaGenerationReadiness,
|
||||
type OllamaGenerationReadiness,
|
||||
} from '../../utils/providerDiscovery.js'
|
||||
|
||||
type ProviderChoice = 'auto' | ProviderProfile | 'clear'
|
||||
export function buildProviderManagerCompletion(result?: ProviderManagerResult): {
|
||||
message: string
|
||||
metaMessages?: string[]
|
||||
} {
|
||||
const message =
|
||||
result?.message ??
|
||||
(result?.action === 'saved'
|
||||
? 'Provider profile updated'
|
||||
: 'Provider manager closed')
|
||||
const metaMessages =
|
||||
result?.action === 'activated' && result.activeProviderName
|
||||
? [
|
||||
`<system-reminder>Provider switched mid-session to ${result.activeProviderName}${
|
||||
result.activeProviderModel
|
||||
? ` using model ${result.activeProviderModel}`
|
||||
: ''
|
||||
}. Use this provider/model for subsequent requests unless the user switches again.</system-reminder>`,
|
||||
]
|
||||
: undefined
|
||||
|
||||
return { message, metaMessages }
|
||||
}
|
||||
|
||||
function describeOllamaReadinessIssue(
|
||||
readiness: OllamaGenerationReadiness,
|
||||
options?: {
|
||||
baseUrl?: string
|
||||
allowManualFallback?: boolean
|
||||
},
|
||||
): string {
|
||||
const endpoint = options?.baseUrl ?? 'http://localhost:11434'
|
||||
|
||||
if (readiness.state === 'unreachable') {
|
||||
return `Could not reach Ollama at ${endpoint}. Start Ollama first, then run /provider again.`
|
||||
}
|
||||
|
||||
if (readiness.state === 'no_models') {
|
||||
const manualSuffix = options?.allowManualFallback
|
||||
? ', or enter details manually'
|
||||
: ''
|
||||
return `Ollama is running, but no installed models were found. Pull a chat model such as qwen2.5-coder:7b or llama3.1:8b first${manualSuffix}.`
|
||||
}
|
||||
|
||||
if (readiness.state === 'generation_failed') {
|
||||
const modelHint = readiness.probeModel ?? 'the selected model'
|
||||
const detailSuffix = readiness.detail
|
||||
? ` Details: ${readiness.detail}.`
|
||||
: ''
|
||||
const manualSuffix = options?.allowManualFallback
|
||||
? ' You can also enter details manually.'
|
||||
: ''
|
||||
return `Ollama is reachable and models are installed, but a generation probe failed for ${modelHint}.${detailSuffix} Run "ollama run ${modelHint}" once and retry.${manualSuffix}`
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
type ProviderChoice = 'auto' | ProviderProfile | 'codex-oauth' | 'clear'
|
||||
|
||||
type Step =
|
||||
| { name: 'choose' }
|
||||
@@ -74,6 +145,14 @@ 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' }
|
||||
@@ -82,6 +161,7 @@ type Step =
|
||||
apiKey?: string
|
||||
authMode: 'api-key' | 'access-token' | 'adc'
|
||||
}
|
||||
| { name: 'codex-oauth' }
|
||||
| { name: 'codex-check' }
|
||||
|
||||
type CurrentProviderSummary = {
|
||||
@@ -116,8 +196,12 @@ 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()
|
||||
@@ -126,7 +210,7 @@ function isEnvTruthy(value: string | undefined): boolean {
|
||||
|
||||
function getSafeDisplayValue(
|
||||
value: string | undefined,
|
||||
processEnv: NodeJS.ProcessEnv,
|
||||
processEnv: SecretSourceEnv,
|
||||
profileEnv?: ProfileEnv,
|
||||
fallback = '(not set)',
|
||||
): string {
|
||||
@@ -138,20 +222,29 @@ function getSafeDisplayValue(
|
||||
export function getProviderWizardDefaults(
|
||||
processEnv: NodeJS.ProcessEnv = process.env,
|
||||
): ProviderWizardDefaults {
|
||||
const secretSource = processEnv as SecretSourceEnv
|
||||
const safeOpenAIModel =
|
||||
sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, processEnv) ||
|
||||
sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, secretSource) ||
|
||||
'gpt-4o'
|
||||
const safeOpenAIBaseUrl =
|
||||
sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, processEnv) ||
|
||||
sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, secretSource) ||
|
||||
DEFAULT_OPENAI_BASE_URL
|
||||
const safeGeminiModel =
|
||||
sanitizeProviderConfigValue(processEnv.GEMINI_MODEL, processEnv) ||
|
||||
sanitizeProviderConfigValue(processEnv.GEMINI_MODEL, secretSource) ||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +253,7 @@ 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'
|
||||
|
||||
@@ -168,11 +262,43 @@ export function buildCurrentProviderSummary(options?: {
|
||||
providerLabel: 'Google Gemini',
|
||||
modelLabel: getSafeDisplayValue(
|
||||
processEnv.GEMINI_MODEL ?? DEFAULT_GEMINI_MODEL,
|
||||
processEnv,
|
||||
secretSource,
|
||||
),
|
||||
endpointLabel: getSafeDisplayValue(
|
||||
processEnv.GEMINI_BASE_URL ?? DEFAULT_GEMINI_BASE_URL,
|
||||
processEnv,
|
||||
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
|
||||
),
|
||||
savedProfileLabel,
|
||||
}
|
||||
}
|
||||
|
||||
if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB)) {
|
||||
return {
|
||||
providerLabel: 'GitHub Models',
|
||||
modelLabel: getSafeDisplayValue(
|
||||
processEnv.OPENAI_MODEL ?? 'github:copilot',
|
||||
secretSource,
|
||||
),
|
||||
endpointLabel: getSafeDisplayValue(
|
||||
processEnv.OPENAI_BASE_URL ??
|
||||
processEnv.OPENAI_API_BASE ??
|
||||
'https://models.github.ai/inference',
|
||||
secretSource,
|
||||
),
|
||||
savedProfileLabel,
|
||||
}
|
||||
@@ -193,8 +319,8 @@ export function buildCurrentProviderSummary(options?: {
|
||||
|
||||
return {
|
||||
providerLabel,
|
||||
modelLabel: getSafeDisplayValue(request.requestedModel, processEnv),
|
||||
endpointLabel: getSafeDisplayValue(request.baseUrl, processEnv),
|
||||
modelLabel: getSafeDisplayValue(request.requestedModel, secretSource),
|
||||
endpointLabel: getSafeDisplayValue(request.baseUrl, secretSource),
|
||||
savedProfileLabel,
|
||||
}
|
||||
}
|
||||
@@ -205,11 +331,11 @@ export function buildCurrentProviderSummary(options?: {
|
||||
processEnv.ANTHROPIC_MODEL ??
|
||||
processEnv.CLAUDE_MODEL ??
|
||||
'claude-sonnet-4-6',
|
||||
processEnv,
|
||||
secretSource,
|
||||
),
|
||||
endpointLabel: getSafeDisplayValue(
|
||||
processEnv.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com',
|
||||
processEnv,
|
||||
secretSource,
|
||||
),
|
||||
savedProfileLabel,
|
||||
}
|
||||
@@ -242,6 +368,24 @@ 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',
|
||||
@@ -305,6 +449,10 @@ export function buildProfileSaveMessage(
|
||||
profile: ProviderProfile,
|
||||
env: ProfileEnv,
|
||||
filePath: string,
|
||||
options?: {
|
||||
activatedInSession?: boolean
|
||||
activationWarning?: string | null
|
||||
},
|
||||
): string {
|
||||
const summary = buildSavedProfileSummary(profile, env)
|
||||
const lines = [
|
||||
@@ -318,13 +466,24 @@ export function buildProfileSaveMessage(
|
||||
}
|
||||
|
||||
lines.push(`Profile: ${filePath}`)
|
||||
lines.push('Restart OpenClaude to use it.')
|
||||
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.')
|
||||
}
|
||||
|
||||
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',
|
||||
'',
|
||||
@@ -335,7 +494,7 @@ function buildUsageText(): string {
|
||||
`Current endpoint: ${summary.endpointLabel}`,
|
||||
`Saved profile: ${summary.savedProfileLabel}`,
|
||||
'',
|
||||
'Choose Auto, Ollama, OpenAI-compatible, Gemini, or Codex, then save a profile for the next OpenClaude restart.',
|
||||
availableProviders,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@@ -344,12 +503,45 @@ 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)
|
||||
onDone(buildProfileSaveMessage(profile, env, filePath), {
|
||||
display: 'system',
|
||||
})
|
||||
const shouldActivateInSession = profile === 'codex'
|
||||
const activationWarning = shouldActivateInSession
|
||||
? await applySharedProfileToCurrentSession({ profileFile })
|
||||
: null
|
||||
|
||||
onDone(
|
||||
buildProfileSaveMessage(profile, env, filePath, {
|
||||
activatedInSession:
|
||||
shouldActivateInSession && activationWarning === null,
|
||||
activationWarning,
|
||||
}),
|
||||
{
|
||||
display: 'system',
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
onDone(`Failed to save provider profile: ${message}`, {
|
||||
@@ -433,6 +625,10 @@ 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',
|
||||
@@ -456,11 +652,26 @@ 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') {
|
||||
@@ -478,10 +689,7 @@ function ProviderChooser({
|
||||
onCancel={onCancel}
|
||||
>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>
|
||||
Save a provider profile for the next OpenClaude restart without
|
||||
editing environment variables first.
|
||||
</Text>
|
||||
<Text>{helperText}</Text>
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>Current model: {summary.modelLabel}</Text>
|
||||
<Text dimColor>Current endpoint: {summary.endpointLabel}</Text>
|
||||
@@ -567,6 +775,7 @@ function AutoRecommendationStep({
|
||||
| {
|
||||
state: 'openai'
|
||||
defaultModel: string
|
||||
reason: string
|
||||
}
|
||||
| {
|
||||
state: 'error'
|
||||
@@ -580,19 +789,27 @@ function AutoRecommendationStep({
|
||||
void (async () => {
|
||||
const defaultModel = getGoalDefaultOpenAIModel(goal)
|
||||
try {
|
||||
const ollamaAvailable = await hasLocalOllama()
|
||||
if (!ollamaAvailable) {
|
||||
const readiness = await probeOllamaGenerationReadiness()
|
||||
if (readiness.state !== 'ready') {
|
||||
if (!cancelled) {
|
||||
setStatus({ state: 'openai', defaultModel })
|
||||
setStatus({
|
||||
state: 'openai',
|
||||
defaultModel,
|
||||
reason: describeOllamaReadinessIssue(readiness),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const models = await listOllamaModels()
|
||||
const recommended = recommendOllamaModel(models, goal)
|
||||
const recommended = recommendOllamaModel(readiness.models, goal)
|
||||
if (!recommended) {
|
||||
if (!cancelled) {
|
||||
setStatus({ state: 'openai', defaultModel })
|
||||
setStatus({
|
||||
state: 'openai',
|
||||
defaultModel,
|
||||
reason:
|
||||
'Ollama responded to a generation probe, but no recommended chat model matched this goal.',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -633,7 +850,9 @@ function AutoRecommendationStep({
|
||||
{ label: 'Back', value: 'back' },
|
||||
{ label: 'Cancel', value: 'cancel' },
|
||||
]}
|
||||
onChange={value => (value === 'back' ? onBack() : onCancel())}
|
||||
onChange={(value: string) =>
|
||||
value === 'back' ? onBack() : onCancel()
|
||||
}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Box>
|
||||
@@ -646,17 +865,17 @@ function AutoRecommendationStep({
|
||||
<Dialog title="Auto setup fallback" onCancel={onCancel}>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>
|
||||
No viable local Ollama chat model was detected. Auto setup can
|
||||
continue into OpenAI-compatible setup with a default model of{' '}
|
||||
Auto setup can continue into OpenAI-compatible setup with a default model of{' '}
|
||||
{status.defaultModel}.
|
||||
</Text>
|
||||
<Text dimColor>{status.reason}</Text>
|
||||
<Select
|
||||
options={[
|
||||
{ label: 'Continue to OpenAI-compatible setup', value: 'continue' },
|
||||
{ label: 'Back', value: 'back' },
|
||||
{ label: 'Cancel', value: 'cancel' },
|
||||
]}
|
||||
onChange={value => {
|
||||
onChange={(value: string) => {
|
||||
if (value === 'continue') {
|
||||
onNeedOpenAI(status.defaultModel)
|
||||
} else if (value === 'back') {
|
||||
@@ -689,7 +908,7 @@ function AutoRecommendationStep({
|
||||
{ label: 'Back', value: 'back' },
|
||||
{ label: 'Cancel', value: 'cancel' },
|
||||
]}
|
||||
onChange={value => {
|
||||
onChange={(value: string) => {
|
||||
if (value === 'save') {
|
||||
onSave(
|
||||
'ollama',
|
||||
@@ -733,32 +952,19 @@ function OllamaModelStep({
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
const available = await hasLocalOllama()
|
||||
if (!available) {
|
||||
const readiness = await probeOllamaGenerationReadiness()
|
||||
if (readiness.state !== 'ready') {
|
||||
if (!cancelled) {
|
||||
setStatus({
|
||||
state: 'unavailable',
|
||||
message:
|
||||
'Could not reach Ollama at http://localhost:11434. Start Ollama first, then run /provider again.',
|
||||
message: describeOllamaReadinessIssue(readiness),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const models = await listOllamaModels()
|
||||
if (models.length === 0) {
|
||||
if (!cancelled) {
|
||||
setStatus({
|
||||
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.',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const ranked = rankOllamaModels(models, 'balanced')
|
||||
const recommended = recommendOllamaModel(models, 'balanced')
|
||||
const ranked = rankOllamaModels(readiness.models, 'balanced')
|
||||
const recommended = recommendOllamaModel(readiness.models, 'balanced')
|
||||
if (!cancelled) {
|
||||
setStatus({
|
||||
state: 'ready',
|
||||
@@ -791,7 +997,9 @@ function OllamaModelStep({
|
||||
{ label: 'Back', value: 'back' },
|
||||
{ label: 'Cancel', value: 'cancel' },
|
||||
]}
|
||||
onChange={value => (value === 'back' ? onBack() : onCancel())}
|
||||
onChange={(value: string) =>
|
||||
value === 'back' ? onBack() : onCancel()
|
||||
}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Box>
|
||||
@@ -812,7 +1020,7 @@ function OllamaModelStep({
|
||||
defaultFocusValue={status.defaultValue}
|
||||
inlineDescriptions
|
||||
visibleOptionCount={Math.min(8, status.options.length)}
|
||||
onChange={value => {
|
||||
onChange={(value: string) => {
|
||||
onSave(
|
||||
'ollama',
|
||||
buildOllamaProfileEnv(value, {
|
||||
@@ -827,6 +1035,84 @@ 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,
|
||||
@@ -848,7 +1134,9 @@ function CodexCredentialStep({
|
||||
{ label: 'Back', value: 'back' },
|
||||
{ label: 'Cancel', value: 'cancel' },
|
||||
]}
|
||||
onChange={value => (value === 'back' ? onBack() : onCancel())}
|
||||
onChange={(value: string) =>
|
||||
value === 'back' ? onBack() : onCancel()
|
||||
}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Box>
|
||||
@@ -882,9 +1170,10 @@ function CodexCredentialStep({
|
||||
defaultFocusValue="codexplan"
|
||||
inlineDescriptions
|
||||
visibleOptionCount={options.length}
|
||||
onChange={value => {
|
||||
onChange={(value: string) => {
|
||||
const env = buildCodexProfileEnv({
|
||||
model: value,
|
||||
credentialSource: credentials.credentialSource,
|
||||
processEnv: process.env,
|
||||
})
|
||||
if (env) {
|
||||
@@ -899,9 +1188,16 @@ function CodexCredentialStep({
|
||||
}
|
||||
|
||||
function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv):
|
||||
| { ok: true; sourceDescription: string }
|
||||
| {
|
||||
ok: true
|
||||
sourceDescription: string
|
||||
credentialSource: 'oauth' | 'existing'
|
||||
}
|
||||
| { 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
|
||||
@@ -909,7 +1205,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. Re-login with the Codex CLI or set CODEX_API_KEY. ${authHint}`,
|
||||
message: `Codex setup needs existing credentials. ${oauthHint}, or set CODEX_API_KEY. ${authHint}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,15 +1213,19 @@ function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv):
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
'Codex auth is missing chatgpt_account_id. Re-login with the Codex CLI or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID first.',
|
||||
`Codex auth is missing chatgpt_account_id. ${oauthHint}, 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,
|
||||
}
|
||||
}
|
||||
@@ -954,6 +1254,13 @@ 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.`, {
|
||||
@@ -1093,6 +1400,101 @@ 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,
|
||||
@@ -1138,7 +1540,7 @@ export function ProviderWizard({
|
||||
options={options}
|
||||
inlineDescriptions
|
||||
visibleOptionCount={options.length}
|
||||
onChange={value => {
|
||||
onChange={(value: string) => {
|
||||
if (value === 'api-key') {
|
||||
setStep({ name: 'gemini-key' })
|
||||
} else if (value === 'access-token') {
|
||||
@@ -1294,6 +1696,15 @@ export function ProviderWizard({
|
||||
onCancel={() => onDone()}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'codex-oauth':
|
||||
return (
|
||||
<CodexOAuthStep
|
||||
onSave={(profile, env) => finishProfileSave(onDone, profile, env)}
|
||||
onBack={() => setStep({ name: 'choose' })}
|
||||
onCancel={() => onDone()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1318,13 +1729,8 @@ export const call: LocalJSXCommandCall = async (onDone, _context, args) => {
|
||||
<ProviderManager
|
||||
mode="manage"
|
||||
onDone={result => {
|
||||
const message =
|
||||
result?.message ??
|
||||
(result?.action === 'saved'
|
||||
? 'Provider profile updated'
|
||||
: 'Provider manager closed')
|
||||
|
||||
onDone(message, { display: 'system' })
|
||||
const { message, metaMessages } = buildProviderManagerCompletion(result)
|
||||
onDone(message, { display: 'system', metaMessages })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ const web = {
|
||||
type: 'local-jsx',
|
||||
name: 'web-setup',
|
||||
description:
|
||||
'Setup Claude Code on the web (requires connecting your GitHub account)',
|
||||
'Setup OpenClaude on the web (requires connecting your GitHub account)',
|
||||
availability: ['claude-ai'],
|
||||
isEnabled: () =>
|
||||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_lantern', false) &&
|
||||
|
||||
@@ -48,7 +48,7 @@ const review: Command = {
|
||||
const ultrareview: Command = {
|
||||
type: 'local-jsx',
|
||||
name: 'ultrareview',
|
||||
description: `~10–20 min · Finds and verifies bugs in your branch. Runs in Claude Code on the web. See ${CCR_TERMS_URL}`,
|
||||
description: `~10–20 min · Finds and verifies bugs in your branch. Runs in OpenClaude on the web. See ${CCR_TERMS_URL}`,
|
||||
isEnabled: () => isUltrareviewEnabled(),
|
||||
load: () => import('./review/ultrareviewCommand.js'),
|
||||
}
|
||||
|
||||
@@ -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) : '.claude/settings.local.json';
|
||||
const relativePath = localSettingsPath ? relative(getCwdState(), localSettingsPath) : '.openclaude/settings.local.json';
|
||||
const message = color('success', themeName)(`Added "${cleanPattern}" to excluded commands in ${relativePath}`);
|
||||
onDone(message);
|
||||
return null;
|
||||
|
||||
@@ -57,7 +57,7 @@ function SessionInfo(t0) {
|
||||
if (!remoteSessionUrl) {
|
||||
let t4;
|
||||
if ($[4] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t4 = <Pane><Text color="warning">Not in remote mode. Start with `claude --remote` to use this command.</Text><Text dimColor={true}>(press esc to close)</Text></Pane>;
|
||||
t4 = <Pane><Text color="warning">Not in remote mode. Start with `openclaude --remote` to use this command.</Text><Text dimColor={true}>(press esc to close)</Text></Pane>;
|
||||
$[4] = t4;
|
||||
} else {
|
||||
t4 = $[4];
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Command } from '../../commands.js'
|
||||
const stats = {
|
||||
type: 'local-jsx',
|
||||
name: 'stats',
|
||||
description: 'Show your Claude Code usage statistics and activity',
|
||||
description: 'Show your OpenClaude usage statistics and activity',
|
||||
load: () => import('./stats.js'),
|
||||
} satisfies Command
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ const status = {
|
||||
type: 'local-jsx',
|
||||
name: 'status',
|
||||
description:
|
||||
'Show Claude Code status including version, model, account, API connectivity, and tool statuses',
|
||||
'Show OpenClaude status including version, model, account, API connectivity, and tool statuses',
|
||||
immediate: true,
|
||||
load: () => import('./status.js'),
|
||||
} satisfies Command
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Command } from '../commands.js';
|
||||
import { AGENT_TOOL_NAME } from '../tools/AgentTool/constants.js';
|
||||
const statusline = {
|
||||
type: 'prompt',
|
||||
description: "Set up Claude Code's status line UI",
|
||||
description: "Set up OpenClaude's status line UI",
|
||||
contentLength: 0,
|
||||
// Dynamic content
|
||||
aliases: [],
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Command } from '../../commands.js'
|
||||
const stickers = {
|
||||
type: 'local',
|
||||
name: 'stickers',
|
||||
description: 'Order Claude Code stickers',
|
||||
description: 'Order OpenClaude stickers',
|
||||
supportsNonInteractive: false,
|
||||
load: () => import('./stickers.js'),
|
||||
} satisfies Command
|
||||
|
||||
@@ -4,7 +4,7 @@ import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from '../../services/anal
|
||||
const thinkback = {
|
||||
type: 'local-jsx',
|
||||
name: 'think-back',
|
||||
description: 'Your 2025 Claude Code Year in Review',
|
||||
description: 'Your 2025 OpenClaude Year in Review',
|
||||
isEnabled: () =>
|
||||
checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_thinkback'),
|
||||
load: () => import('./thinkback.js'),
|
||||
|
||||
@@ -115,7 +115,7 @@ function startDetachedPoll(taskId: string, sessionId: string, url: string, getAp
|
||||
ultraplanSessionUrl: undefined
|
||||
} : prev);
|
||||
enqueuePendingNotification({
|
||||
value: [`Ultraplan approved — executing in Claude Code on the web. Follow along at: ${url}`, '', 'Results will land as a pull request when the remote session finishes. There is nothing to do here.'].join('\n'),
|
||||
value: [`Ultraplan approved — executing in OpenClaude on the web. Follow along at: ${url}`, '', 'Results will land as a pull request when the remote session finishes. There is nothing to do here.'].join('\n'),
|
||||
mode: 'task-notification'
|
||||
});
|
||||
} else {
|
||||
@@ -184,10 +184,10 @@ function startDetachedPoll(taskId: string, sessionId: string, url: string, getAp
|
||||
// multi-second teleportToRemote round-trip.
|
||||
function buildLaunchMessage(disconnectedBridge?: boolean): string {
|
||||
const prefix = disconnectedBridge ? `${REMOTE_CONTROL_DISCONNECTED_MSG} ` : '';
|
||||
return `${DIAMOND_OPEN} ultraplan\n${prefix}Starting Claude Code on the web…`;
|
||||
return `${DIAMOND_OPEN} ultraplan\n${prefix}Starting OpenClaude on the web…`;
|
||||
}
|
||||
function buildSessionReadyMessage(url: string): string {
|
||||
return `${DIAMOND_OPEN} ultraplan · Monitor progress in Claude Code on the web ${url}\nYou can continue working — when the ${DIAMOND_OPEN} fills, press ↓ to view results`;
|
||||
return `${DIAMOND_OPEN} ultraplan · Monitor progress in OpenClaude on the web ${url}\nYou can continue working — when the ${DIAMOND_OPEN} fills, press ↓ to view results`;
|
||||
}
|
||||
function buildAlreadyActiveMessage(url: string | undefined): string {
|
||||
return url ? `ultraplan: already polling. Open ${url} to check status, or wait for the plan to land here.` : 'ultraplan: already launching. Please wait for the session to start.';
|
||||
@@ -272,7 +272,7 @@ export async function launchUltraplan(opts: {
|
||||
return [
|
||||
// Rendered via <Markdown>; raw <message> is tokenized as HTML
|
||||
// and dropped. Backslash-escape the brackets.
|
||||
'Usage: /ultraplan \\<prompt\\>, or include "ultraplan" anywhere', 'in your prompt', '', 'Advanced multi-agent plan mode with our most powerful model', '(Opus). Runs in Claude Code on the web. When the plan is ready,', 'you can execute it in the web session or send it back here.', 'Terminal stays free while the remote plans.', 'Requires /login.', '', `Terms: ${CCR_TERMS_URL}`].join('\n');
|
||||
'Usage: /ultraplan \\<prompt\\>, or include "ultraplan" anywhere', 'in your prompt', '', 'Advanced multi-agent plan mode with our most powerful model', '(Opus). Runs in OpenClaude on the web. When the plan is ready,', 'you can execute it in the web session or send it back here.', 'Terminal stays free while the remote plans.', 'Requires /login.', '', `Terms: ${CCR_TERMS_URL}`].join('\n');
|
||||
}
|
||||
|
||||
// Set synchronously before the detached flow to prevent duplicate launches
|
||||
@@ -461,7 +461,7 @@ const call: LocalJSXCommandCall = async (onDone, context, args) => {
|
||||
export default {
|
||||
type: 'local-jsx',
|
||||
name: 'ultraplan',
|
||||
description: `~10–30 min · Claude Code on the web drafts an advanced plan you can edit and approve. See ${CCR_TERMS_URL}`,
|
||||
description: `~10–30 min · OpenClaude on the web drafts an advanced plan you can edit and approve. See ${CCR_TERMS_URL}`,
|
||||
argumentHint: '<prompt>',
|
||||
isEnabled: () => "external" === 'ant',
|
||||
load: () => Promise.resolve({
|
||||
|
||||
@@ -4,6 +4,5 @@ export default {
|
||||
type: 'local-jsx',
|
||||
name: 'usage',
|
||||
description: 'Show plan usage limits',
|
||||
availability: ['claude-ai'],
|
||||
load: () => import('./usage.js'),
|
||||
} satisfies Command
|
||||
|
||||
12
src/commands/wiki/index.ts
Normal file
12
src/commands/wiki/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
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
|
||||
123
src/commands/wiki/wiki.tsx
Normal file
123
src/commands/wiki/wiki.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
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
|
||||
}
|
||||
@@ -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 · Try <Text bold>claude doctor</Text> or{' '}
|
||||
✗ Auto-update failed · Try <Text bold>openclaude doctor</Text> or{' '}
|
||||
<Text bold>
|
||||
{hasLocalInstall ? `cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}` : `npm i -g ${MACRO.PACKAGE_URL}`}
|
||||
{hasLocalInstall ? `cd ~/.openclaude/local && npm update ${MACRO.PACKAGE_URL}` : `npm i -g ${MACRO.PACKAGE_URL}`}
|
||||
</Text>
|
||||
</Text>}
|
||||
</Box>;
|
||||
|
||||
@@ -31,9 +31,11 @@ export function BaseTextInput(t0) {
|
||||
} = t0;
|
||||
const {
|
||||
onInput,
|
||||
value,
|
||||
renderedValue,
|
||||
cursorLine,
|
||||
cursorColumn
|
||||
cursorColumn,
|
||||
offset,
|
||||
} = inputState;
|
||||
const t1 = Boolean(props.focus && props.showCursor && terminalFocus);
|
||||
let t2;
|
||||
@@ -78,7 +80,7 @@ export function BaseTextInput(t0) {
|
||||
renderedPlaceholder
|
||||
} = renderPlaceholder({
|
||||
placeholder: props.placeholder,
|
||||
value: props.value,
|
||||
value,
|
||||
showCursor: props.showCursor,
|
||||
focus: props.focus,
|
||||
terminalFocus,
|
||||
@@ -88,9 +90,9 @@ export function BaseTextInput(t0) {
|
||||
useInput(wrappedOnInput, {
|
||||
isActive: props.focus
|
||||
});
|
||||
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 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 {
|
||||
viewportCharOffset,
|
||||
viewportCharEnd
|
||||
@@ -102,13 +104,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}>{props.value?.endsWith(" ") ? "" : " "}{props.argumentHint}</Text>}{children}</Box>;
|
||||
return <Box ref={cursorRef}><HighlightedInput text={renderedValue} highlights={filteredHighlights} />{showArgumentHint && <Text dimColor={true}>{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}>{props.value?.endsWith(" ") ? "" : " "}{props.argumentHint}</Text>;
|
||||
const t6 = showArgumentHint && <Text dimColor={true}>{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>;
|
||||
|
||||
@@ -56,7 +56,7 @@ export function ClaudeInChromeOnboarding(t0) {
|
||||
}
|
||||
let t5;
|
||||
if ($[6] !== t4) {
|
||||
t5 = <Text>Claude in Chrome works with the Chrome extension to let you control your browser directly from Claude Code. You can navigate websites, fill forms, capture screenshots, record GIFs, and debug with console logs and network requests.{t4}</Text>;
|
||||
t5 = <Text>Claude in Chrome works with the Chrome extension to let you control your browser directly from OpenClaude. You can navigate websites, fill forms, capture screenshots, record GIFs, and debug with console logs and network requests.{t4}</Text>;
|
||||
$[6] = t4;
|
||||
$[7] = t5;
|
||||
} else {
|
||||
|
||||
@@ -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 provider wizard', async () => {
|
||||
test('third-party provider branch opens the first-run provider manager', async () => {
|
||||
const output = await renderFrame(
|
||||
<ConsoleOAuthFlow
|
||||
initialStatus={{ state: 'platform_setup' }}
|
||||
@@ -111,7 +111,11 @@ test('third-party provider branch opens the provider wizard', async () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(output).toContain('Set up a provider profile')
|
||||
expect(output).toContain('OpenAI-compatible')
|
||||
expect(output).toContain('Ollama')
|
||||
expect(output).toContain('Set up provider')
|
||||
// Use alphabetically-early sentinels so they remain visible in the
|
||||
// 13-row test frame after the provider list was sorted A→Z.
|
||||
expect(output).toContain('Anthropic')
|
||||
expect(output).toContain('Azure OpenAI')
|
||||
expect(output).toContain('DeepSeek')
|
||||
expect(output).toContain('Google Gemini')
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user