Compare commits

92 Commits
Author SHA1 Message Date
0chencc d1d322f970 fix: preserve inline system messages in Chat Completions conversion
Accept textual system messages within the conversation while preserving their order and the top-level system prompt. Normalize stop responses containing completed tool calls to tool_use in both response modes, keeping truncation and refusal distinct.

Cover request/count/stream/tool round trips, validate against a real provider on the authorized Windows 10 host, regenerate installers, and record the reusable Windows test environment without API credentials.

Fixes #204
2026-09-30 18:49:22 +09:00
0chencc 59488807cc test: clear expected failure status after Windows startup checks
All startup assertions passed, but the last negative probe left LASTEXITCODE=1 for the Actions PowerShell wrapper. Explicitly return success only after assertions and cleanup complete.

Fixes #203
2026-09-30 18:13:47 +09:00
0chencc 23ff76a1e6 fix: bound installer startup checks and keep version queries offline
Skip provider initialization and background release checks for standalone version queries. Run installer probes with a deadline, terminate hung process trees, preserve diagnostics, and require successful stdout version output.

Cover startup failure modes and configure Chat providers before real fresh/repeated installation smoke tests. Add a pinned Claude 2.1.285 Windows case and regenerate both installers.

Fixes #203
2026-09-30 18:11:58 +09:00
0chencc d7e839d973 feat: complete built-in OpenAI Chat Completions adaptation
Add explicit protocol selection while retaining legacy provider types. Preserve streaming errors and usage, translate tool constraints and file inputs, estimate tokens locally, and propagate cancellation and timeouts.

Cover protocol edge cases and real Bun HTTP tool round trips, run the suites in Node and Unix/Windows Bun CI, document limitations in all three READMEs, and regenerate both installers.

Fixes #202
2026-09-30 15:44:38 +09:00
0chencc 677e642637 test: bound Lean script extraction to one shell argument
The clean-source recovery block also imports fs, so the previous regex spanned multiple node commands and fed shell syntax into the JavaScript VM. Stop at the closing single quote and validate that both Lean scripts were found.

Fixes #201
2026-09-29 19:09:45 +09:00
0chencc 35ed8d8b2f fix: repatch complete clean sources on no-upgrade installs
Capture the entry and all JavaScript chunks before patching, then recompute patches from that snapshot without layering them onto modified sources. Recover older installations without snapshots by fetching their exact installed version.

Return nonzero on stale patches before writing source files. Add clean-source, recovery, and installer repeat checks; verify Windows Claude 2.1.283 and 2.1.272 migration, repeated patch counts, byte-identical output, and failed-patch preservation.

Fixes #201
2026-09-29 18:58:31 +09:00
0chencc 0432b2b6ca fix: prevent native updater from shadowing Windows launcher
Disable native background updates before loading Claude, since clawgod owns its update flow. Make repeated Windows installs preserve original launchers, recover from restored or running executables, and report removal failures instead of claiming success.

Add launcher environment and Windows file-lock regression tests. Validate full installs and repeated repairs against Claude 2.1.283 and 2.1.272 on Windows 10.

Fixes #200
2026-09-29 15:58:00 +09:00
0chencc f9d2dcde3d fix: restore auto-mode provider patch for Claude 2.1.280+
Handle the provider helper gate introduced in Claude 2.1.280 while preserving the upstream condition when the feature is disabled.

Fixes #189
Fixes #190
Fixes #191
Fixes #192
Fixes #193
Fixes #194
Fixes #195
Fixes #196
Fixes #197
2026-09-25 17:06:56 +09:00
0chencc 7bf3b15b10 fix: forward provider effort through the OpenAI-compatible proxy
Map request and configured effort to reasoning_effort, preserve environment precedence, and cover auth and proxy requests in CI. Complete the remaining proxy effort support after PR #187.
2026-09-21 14:01:10 +09:00
0chencc ec43a48dfb Merge pull request #187 from pidjan/fix-provider-key-routing
fix: use only ANTHROPIC_AUTH_TOKEN for custom API providers, add effort
2026-09-21 12:57:35 +08:00
pidjan 3808af3ea1 fix: fall back on empty auth token, keep effort for proxies 2026-09-21 03:04:02 +03:00
pidjan 919338321c fix: use only ANTHROPIC_AUTH_TOKEN for custom API providers, add effort 2026-09-19 17:29:01 +03:00
0chencc 8364074087 docs: require closing keywords for issue fixes 2026-09-19 15:58:42 +09:00
0chencc ee324c94b8 fix(lean): reserve Remote Control disabling for max mode
Allow upstream Remote Control eligibility checks in on/off modes, migrate old lean settings, and preserve explicit network restrictions. Keep max restrictions for third-party providers and handle PowerShell Boolean casing.

Validate installer and launcher mode transitions, Windows 2.1.276 gate behavior, existing regression suites, generated artifacts, and workflow syntax.

Refs #186
2026-09-19 15:52:08 +09:00
0chencc a039481e9b ci: add Windows TUI regression and inactive issue cleanup 2026-09-18 23:05:27 +09:00
0chencc 9ec57f5aac docs: record direct commits for maintainer changes 2026-09-18 22:39:39 +09:00
0chencc e0c7dc7c75 Merge pull request #185 from 0Chencc/fix/171/terminal-reply-fragments
fix(terminal): 防止待回复 DA1 分包泄漏到输入区
2026-09-18 21:38:28 +08:00
0chencc 9acc631fa9 fix(terminal): retain split DA1 replies while probes are pending 2026-09-18 22:21:10 +09:00
0chencc 1233e8085f fix(renderer): align Bun.ant shim with caller contracts 2026-09-18 21:48:10 +09:00
0chencc c5074a96dd Merge pull request #184 from DBinK/fix/183/bun-ant-shim
fix(renderer): 实现 Bun.ant.CellSegmenter,修复 Claude Code 2.1.271+ TUI 不渲染
2026-09-18 20:45:52 +08:00
DBinK 5b1549abde fix(renderer): implement Bun.ant.CellSegmenter for Claude Code 2.1.271+
Claude Code 2.1.271 moved its Ink renderer onto Bun.ant.CellSegmenter, an
Anthropic-private Bun API that ships only inside the bundled native binary.
Stock Bun has no Bun.ant namespace, so the renderer threw before the first
frame: the TUI never painted while the process stayed alive, which users
reported as a hang (issue #183). claude.orig was unaffected, so the symptom
read as a login, provider, or model-catalog problem.

Implement the API in JS and load it from cli.cjs before the patched bundle:

- segment() returns one cell per grapheme with style-run and width packing,
  and fills the graphemes/sgrKeys/sgrCloseKeys/uris tables the bundle reads.
- paint() and setCell() write cells and return the packed damage rectangle.
- install() no-ops when a runtime already provides the API.

Also gate the shim behind the new bun-ant-shim feature id, report the
detected renderer runtime during install, cover the implementation with a
Node- and Bun-runnable unit test, and assert both in compat-daily.
2026-09-17 16:55:06 +08:00
0chencc 8286158233 fix(ci): avoid truncating native version output on Windows
Drain ripgrep output instead of stopping its pipeline after the first line. Propagate failures from Chocolatey and each runtime version command immediately.

Verified on soulbind-dev with PowerShell 7: the old pipeline terminates a slow native producer early, the updated steps complete normally, and injected command failures preserve their exit codes. Build consistency and patch regression checks pass. Windows PowerShell 5.1 confirmation remains for CI.

Refs #180
2026-09-12 17:09:12 +09:00
0chencc 96309e5a07 fix(patches): support ultraplan availability metadata in Claude 2.1.268
Preserve availability metadata and runtime feature toggles while preventing the pattern from matching a neighboring command. Add bundle and chunk-graph regression tests and regenerate both installers.

Validated zero failed patches on Linux, macOS, and Windows 2.1.268 bundles; macOS startup on 2.1.246, 2.1.267, and 2.1.268; semantic tests, generated installer consistency, and syntax checks. Native Linux and Windows smoke tests remain for CI.

Refs #175, #176, #177
2026-09-12 06:00:22 +09:00
0chencc 5aceee55c9 ci: migrate workflows to native Node.js 24 actions 2026-09-05 22:23:06 +09:00
0chencc dee5fdc531 ci: add macOS 26 ARM64 daily compatibility checks
Refs #168
2026-09-05 20:24:14 +09:00
0chencc 55b2478ab8 chore(installer): finish shared CLI cleanup
Track runtime helpers in uninstall and install validation, and make shared comments platform-neutral.

Closes #167
2026-09-05 19:12:31 +09:00
0chencc e41e4ea2f1 Merge pull request #169 from plusls/main
feat(patches): auto-mode classifier tuning (timeout/model/retries)
2026-09-05 18:11:17 +08:00
plusls fcc2d3f480 fix(patches): classifier timeout floor via pure helper + unit tests
The inline override parsing in the classifier-timeout replacer used
Number(...) || 0, which let Infinity and overflow (1e309) leak into the
classifier deadline/ceiling instead of falling back to the original
formula. Split logic so gating and env reading stay in the injected code
while parsing is a pure, testable helper:

- src/shared/runtime-helpers.cjs exposes globalThis.__clawgodHelpers,
  pointing at the module's own exports so a new helper only needs an export
  line (feature-gates.cjs is a future merge target). classifierTimeoutFloor
  is a pure value parser: it returns the finite number, or null for
  missing/blank/non-numeric/Infinity. cli.cjs requires it at launch, so the
  container is always set and the patch accesses it directly — no optional
  chaining.
- The patch's injected code owns the gate and env read and branches on null:
    let _ct = gate ? globalThis.__clawgodHelpers
        .classifierTimeoutFloor(process.env.CLAWGOD_CLASSIFIER_TIMEOUT_MS) : null;
    let r = Math.min(cap, base+step*1e4);
    return _ct === null ? r : Math.max(r, _ct)
  null (gate off or parse failure) keeps the original formula; a real number
  — a legitimate 0 included — is applied as a floor. No 0 sentinel: 0 is
  never used to mean "no override". Env + gate still read at call time so
  settings.json env hot-reloads.
- runtime-helpers.cjs is a build source for both installers, written to
  ~/.clawgod by install.sh/install.ps1; cli.cjs requires it at launch.

Tests: src/shared/patch.test.mjs pins classifierTimeoutFloor (legal incl
"0"→0, illegal Infinity/1e309/non-numeric/blank/unset→null) and the gate
composition the patch emits (gate ? floor(env) : null, branch keeps
formula / applies floor), wired into the compat-daily build-sources job.
2026-09-04 19:36:02 +08:00
plusls 0e9e550ef2 refactor(installer): dedupe cli.cjs into src/shared/cli.cjs
src/windows/cli.cjs and src/unix/cli.cjs had drifted apart: the Unix copy
carried longer header/drift notes and richer inline comments (remote-
control undo, lean max to on downgrade, ripgrep, execpath) that Windows
lacked. The underlying code was already identical, so merge both into a
single src/shared/cli.cjs and point both build targets at it — install.sh
with identity, install.ps1 through the per-file escapeNonAscii transform.

Byte-level result: install.sh is unchanged (it already embedded the shared
content); install.ps1 gains only the comment lines, no functional change.

Delete src/unix/cli.cjs and src/windows/cli.cjs, regenerate install.ps1,
update src/README.md layout and the CI syntax-check glob.
2026-09-04 17:41:58 +08:00
plusls e252261f8e feat(patches): auto-mode classifier tuning (timeout/model/retries)
Auto-mode classifier accepts env overrides under the classifier-tuning
feature:
  CLAWGOD_CLASSIFIER_TIMEOUT_MS — stage1 deadline floor, v2.1.251+:
    max(original formula, override). Original 60s+10s/50k-token scaling
    and 120s cap are kept; the env raises the deadline past both when
    larger. Read at call time so settings.json env hot-reloads.
  CLAWGOD_CLASSIFIER_MODEL     — pins the classifier model, bypassing
                                 the GB config / probe / main-model chain
  CLAWGOD_CLASSIFIER_RETRIES   — maxRetries default (unset = 4)
Unset/blank/invalid vars keep original behavior. Older bundles skip
the timeout shape (optional).
2026-09-03 18:33:05 +08:00
0chencc 5819197a59 Merge pull request #166 from plusls/main
feat: feature registry and runtime patch toggles
2026-09-03 12:47:30 +08:00
plusls 106ba6745a docs: document feature toggles (patches.json + CLAWGOD_FEATURE_* env)
Add a Feature Toggles subsection to the Configuration section of all
three READMEs: patches.json persistent config with the full 13-feature
table, and the per-launch CLAWGOD_FEATURE_<NAME> env override with the
dash-to-underscore mapping. Also note the FEATURES_META weave in
src/README.md's build-source layout.
2026-09-03 11:50:42 +08:00
plusls 97de06fb3c refactor(gates): per-feature env overrides instead of CLAWGOD_FEATURES list
CLAWGOD_FEATURES="theme=false,other=true" packed multiple toggles into
one comma list — awkward to type and easy to get wrong. Use one env var
per feature instead, matching the repo's existing CLAWGOD_* style
(CLAWGOD_VERSION, CLAWGOD_LEAN_OFF):

  CLAWGOD_FEATURE_THEME=false
  CLAWGOD_FEATURE_GEO_NEUTRALIZE=false

Feature id upper-cased, dashes mapped to underscores. "true" restores a
feature disabled in patches.json for that launch; non-boolean values
are ignored. patches.json remains the persistent config and the unknown
-key warning is unchanged.

Verified: no-config all-on, THEME/GEO_NEUTRALIZE dash mapping, env true
overriding config false, non-boolean ignored, warning intact; build
--check reproducible.
2026-09-03 11:49:05 +08:00
plusls f12547ec55 feat(patches): add feature registry and runtime toggle infrastructure
Patches could not be toggled individually and had no grouping (one
user-facing feature may need several patches cooperating). Introduce the
declaration layer plus the runtime loader; gate checks inside patch
replacers land in the follow-up commit.

Declaration (src/shared/patch.mjs):
- every patch gets a unique id; 33 of 42 are toggleable
- FEATURES registry maps feature id -> patch ids (13 features, e.g.
  computer-use = subscription + default + gate; theme = 12 color
  variants). A patch id in two features applies while ANY is enabled
- toggleable patches must be referenced by a feature, core patches are
  never referenceable - validateRegistry() enforces this and fails
  before any file is touched
- --dump-features prints the inverted registry as JSON (build-time only,
  no file access); the patcher itself never reads patches.json

Runtime (src/shared/feature-gates.cjs, new):
- wrapper requires it right before cli.original.cjs; it reads
  ~/.clawgod/patches.json ({"<feature>": false}, absent = on) plus the
  CLAWGOD_FEATURES env override and computes globalThis.__clawgodPatches
  (patch id -> bool) from the META constant build.js weaves in from the
  registry (single source of truth, no hand-maintained copy)
- missing/broken config leaves the table absent, so gates default ON -
  exactly the pre-toggle behavior
- unknown keys in patches.json warn (renamed/removed feature residue)

Build/installer:
- build.js weaves META into feature-gates.cjs via the
  {{CLAWGOD:FEATURES_META}} marker; ps1 additionally escapes non-ASCII
- both installers write feature-gates.cjs next to openai-proxy.cjs and
  clean it on uninstall; a default empty patches.json is written only
  when missing, so user choices survive updates and reinstalls

Verified: build --check reproducible (122376 / 130899 bytes); gate
table probed under no-config (33 on), theme:false (12 theme patches
off), env override, unknown-key warning, message-filter:false (3
patches incl. shared attachment-filter-bypass off - ANY semantics)
2026-09-03 10:07:54 +08:00
0chencc 38cec0c5f2 docs: update star history chart 2026-08-28 19:16:59 +09:00
0chencc c8235e2354 Merge pull request #163 from 0Chencc/fix-windows-iex-bom-162
fix(windows): support irm pipe execution
2026-08-28 15:43:48 +08:00
0chencc aba6be8948 fix(windows): support irm pipe execution 2026-08-28 16:42:21 +09:00
0chencc 4198018a8d Merge pull request #161 from 0Chencc/refactor-installer-build-sources
refactor(installer): add reproducible build sources
2026-08-28 15:16:40 +08:00
0chencc 87d23ba2ee refactor(installer): add reproducible build sources
Split embedded installer payloads into shared and platform-specific sources, preserve byte-identical generated artifacts, and enforce drift checks in compatibility and release workflows.\n\nSupersedes #108.
2026-08-28 16:09:28 +09:00
0chencc 87621db142 Merge pull request #160 from 0Chencc/check-issue-159-windows-package-path
fix: preserve Windows chunk paths
2026-08-28 14:42:06 +08:00
0chencc 0501b68254 ci: tolerate successful Windows stderr 2026-08-28 15:39:20 +09:00
0chencc 7888abab9f fix: preserve Windows chunk paths
Escape rewritten graph paths as JavaScript string literals, keep the PowerShell installer UTF-8-safe, propagate patch/startup failures, and add an end-to-end Windows compatibility job.\n\nCloses #159
2026-08-28 15:37:11 +09:00
0chencc c04d1e501a fix: extract Windows B:/~BUN/root chunk graph for v2.1.245+
v2.1.245+ split Claude Code into an ESM chunk-graph. The v2.1.245 fix
(23cc72d) handled the POSIX "/$bunfs/root" mount prefix only; Windows PE
builds mount the bundle at a single-drive path "B:/~BUN/root". On Windows
every chunk was dropped (pathmap={}, bunfs/ empty), so the entry still
imported "B:/~BUN/root/_NNN.js" and threw "Cannot find module".

Unify the in-bundle root detection with a BUN_MOUNT_RE that matches both
"/$bunfs/root" and "B:/~BUN/root" (bunSub() helper) in extract-natives.mjs,
post-process.mjs and patch.mjs graph mode. rewriteGraph() in post-process
now rewrites both path spellings (and tolerates backslash separators).

Verified end-to-end on the real binaries:
- 2.1.247 win32 PE: 1590-module graph extracted, 0 stale "B:/~BUN" refs,
  patch 32 applied / 0 failed
- 2.1.247 darwin-arm64 + linux-x64: no regression
- 2.1.200 legacy single-bundle: unchanged (napi + IIFE path intact)
- soulbind-dev (Debian12 x64): claude --version -> 2.1.247, EXIT=0

Closes #158
Closes #157
Closes #152
2026-08-28 02:27:51 +09:00
0chencc 23cc72dd29 fix: support v2.1.245+ ESM chunk-graph layout
Claude Code split from a single ~28MB CJS bundle into an ESM chunk
graph in v2.1.245: the entry is a ~20KB module that static-imports
~1400 sibling /$bunfs/root/_NNN.js chunks plus lazy chunk-*.js. The
extractor only kept the entry + napi, so every chunk was dropped and
the entry threw "Cannot find module '/$bunfs/root/_821.js'".

Extract all modules (js/text/file/napi) into a flat ~/.clawgod/bunfs/
dir and rewrite every "/$bunfs/root/X" specifier to the on-disk path
via a pathmap.json, in both extract-natives.mjs and post-process.mjs.
Detection now keys off the entry format (ESM import vs CJS IIFE)
instead of a js-module count heuristic that misclassified legacy
bundles with >5 boot modules.

patch.mjs runs in multi-file graph mode. Two new graph patches:
- GrowthBook env overrides dead-code fix: v2.1.245+ short-circuits the
  lazy parse on a second return so CLAUDE_INTERNAL_FC_OVERRIDES
  (features.json) never reaches the feature store
- Agent Teams always enabled matching the new minified gate shape

repatch.mjs now also cleans bunfs/ + pathmap.json. install.ps1 is
synced: its embedded scripts were legacy-only; replaced with the same
verified ESM versions (byte-identical) and uninstall/pre-extract
cleanup now covers bunfs/ + pathmap.json.

Verified end-to-end on macos arm64: 2.1.245 + 2.1.246 (chunk graph)
and 2.1.200 (legacy) all install clean, claude --version returns the
right tag.

Closes #153
Closes #155
2026-08-27 11:43:10 +09:00
0chencc 4401fdb005 ci: add workflow to reject README-only PRs 2026-08-22 03:23:58 +09:00
0chencc 936e8c5684 fix: match minified action helper rename for v2.1.238 claude update
The 'Redirect claude update to clawgod self-update' patch hardcoded the
one-letter minified framework helper as t( (v2.1.232-2.1.237). In 2.1.238
the helper was renamed to n(, so the regex no longer matched and the patch
failed, breaking compat-daily.

Match any minified identifier () instead of hardcoding t(,
so a future rename of the helper keeps the patch working.

Verified end-to-end on claude 2.1.238: 31 applied, 9 skipped, 0 failed.

Closes #151
2026-08-22 03:16:49 +09:00
0chencc 20253023b8 fix: match Bun.isStandaloneExecutable guard for v2.1.236+
Anthropic wraps the standalone-check guard in a typeof-Bun check
starting v2.1.236:

  function fv(){return Bun.isStandaloneExecutable===!0}        <=v2.1.235
  function kw(){return typeof Bun<"u"&&Bun.isStandaloneExecutable===!0}  v2.1.236+

The "Bun.isStandaloneExecutable → true" patch regex only matched the
direct-return form, so fv() was left as non-standalone and the daemon /
fork / multitool dispatch paths broke under clawgod (issue #133 regression).

Relax the regex to match both via an optional `typeof Bun<"u"&&` prefix.
Verified clean patch (0 failed) and fv patched on 2.1.236, no regression
on 2.1.233.

Closes #150
2026-08-21 01:41:25 +09:00
0chencc d0947549de docs: reference compat issues individually for auto-close
GitHub closes referenced issues from commit messages only when each
keyword targets a single issue (space-separated multi-issue on one
line is not honored). List them one per line so all get closed.

Closes #147
Closes #146
Closes #145
Closes #144
Closes #143
2026-08-16 21:04:10 +09:00
0chencc a8f8495495 fix: match claude update action wrapper for v2.1.227+
Anthropic wraps the `claude update` commander action handler in a
framework helper starting v2.1.227:

  .action(async()=>{...})        <= v2.1.226
  .action(t(async(a)=>{...}))    >= v2.1.227

The "Redirect claude update to clawgod self-update" regex only matched
the unwrapped form, so the patch failed (regex stale, sentinel still in
source) and the daily compat run reported a failed patch.

Relax the action group to match both via an optional t( wrapper and a
parameter run. Verified clean patch (0 failed) on 2.1.227/228/231/232/233.

Closes #147 #146 #145 #144 #143
2026-08-16 20:57:22 +09:00
0chencc 497773a24f Merge pull request #142 from ShinyNito/fix/herdr-agent-detection
fix: expose Claude identity to Herdr
2026-08-12 18:36:50 +02:00
ShinyNito 96b87086eb fix: expose Claude identity to Herdr 2026-08-10 12:05:46 +08:00
0chencc 507405a053 fix: only show update prompt when remote version is newer
Replace !== with semver greater-than comparison so the update notice
is suppressed when installed version >= cached latest (e.g. installed
from main before the release is published).

Closes #136
2026-07-23 23:20:22 +09:00
0chencc 5f5f070750 Merge pull request #137 from talwayh1/main
fix: add missing ANSI green theme patches for non-truecolor terminals
2026-07-23 23:06:11 +09:00
YubiandClaude 5fb2572422 fix: add missing ANSI green theme patches for non-truecolor terminals
The existing green theme patches only covered RGB colors and the
clawd_body ANSI logo color. When running in terminals without true color
support (COLORTERM unset, xterm-256color), Claude Code uses the ANSI
color palette, and these key brand colors remained red/orange:

- claude (main brand color): patch ansi:redBright → ansi:greenBright
- claudeShimmer: patch ansi:yellowBright → ansi:greenBright
- briefLabelClaude: patch all 3 variants (ANSI + RGB dark + RGB light)

This caused users on 256-color terminals to still see the original
red/orange brand colors instead of the expected green God mode
indicator.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-23 19:05:56 +08:00
0chencc b75e608a79 fix: uninstall leaves stale launcher when no backup exists on Windows
install.ps1 only restored claude.cmd from claude.orig.cmd — when no
backup existed, the clawgod launcher remained and pointed at deleted
cli.cjs. Add else branch to detect and remove our launcher (matching
install.sh behavior). Also add openai-proxy.cjs and clawgod-import
to cleanup lists in both scripts.

Closes #134
2026-07-23 01:37:19 +09:00
0chencc 01efa1c470 fix: patch fv() in source instead of runtime Bun monkey-patch
Bun 1.4+ freezes Bun.isStandaloneExecutable (configurable:false,
writable:false) — runtime defineProperty and direct assignment both
fail. Move the fix to the patcher: replace fv(){return
Bun.isStandaloneExecutable===!0} with fv(){return!0} in
cli.original.cjs.

Update compat-daily to verify fv() is patched by checking the source
directly instead of requiring cli.cjs at runtime.
2026-07-21 20:43:00 +09:00
0chencc 94582f4edf fix: handle unconfigurable Bun.isStandaloneExecutable on Bun 1.4+ canary
Bun 1.4.0-canary marks isStandaloneExecutable as configurable:false,
so Object.defineProperty throws TypeError. Fall back to direct
assignment when defineProperty fails.
2026-07-21 20:39:15 +09:00
0chencc 9ff9563db1 fix: patch Bun.isStandaloneExecutable for daemon/fork, add functional CI checks
Monkey-patch Bun.isStandaloneExecutable=true alongside process.execPath
so fv() returns true and DLt() uses {prefixArgs:[]} — daemon/fork
spawns "claude.orig respawn <id>" instead of the broken
"claude.orig cli.cjs respawn <id>".

Add functional checks to compat-daily workflow: verify execPath patch,
Bun.isStandaloneExecutable, multitool dispatch (ugrep/bfs/rg), daemon
spawn path, and openai-proxy.cjs presence.

Closes #133
2026-07-21 20:31:21 +09:00
0chencc 570986475d fix: auto-inject version from git tag in release workflow
Replace hardcoded CLAWGOD_SELF_VERSION with a dev placeholder that
the release workflow overwrites via sed before uploading assets.
Prevents .clawgod-version from being stuck on a stale value.

Closes #131
2026-07-20 16:35:22 +09:00
0chencc b9f135f44a fix: PSScriptRoot empty on iex, enable remote control for third-party proxies
install.ps1: wrap $PSScriptRoot Join-Path in try/catch — when run via
iex (claude update redirect), $PSScriptRoot is empty and Join-Path
throws a terminating error that -ErrorAction cannot suppress.

cli.cjs: auto-remove disableRemoteControl from settings.json when
baseURL points to a non-Anthropic endpoint, so third-party proxies
like headroom can function without manual lean-mode override.

Closes #128
Closes #129
Closes #130
2026-07-20 15:48:10 +09:00
0chencc bbf2eca462 feat: OpenAI-compat proxy, provider import tool, fix v2.1.214 patches
Add OpenAI-compatible API translation proxy (Anthropic Messages API
<-> OpenAI Chat Completions API), enabling Claude Code to use xAI/Grok
and other OpenAI-compatible providers via provider.json type field.

Add clawgod-import Rust CLI for one-command provider setup:
  clawgod import grok          — auto-detect grok-cli key
  clawgod import openai-compat — any OpenAI-compatible API

Fix two stale patch regexes on Claude Code v2.1.214:
- Ultrareview: config key moved to variable indirection (Fot/rQt)
- Auto-mode inline gate: anthropicAws replaced by helper function

Closes #126
Closes #127
2026-07-19 02:37:34 +09:00
0chencc f61ff7dc11 fix(patcher): macOS Cmd+V image paste fallback to clipboard read
When running under Bun runtime, macOS Cmd+V sends the image file
path as text instead of triggering clipboard image read. The paste
handler tries to read the file, fails, and displays the raw path.

Patch adds a fallback: when all image path reads fail on macOS and
there's no other text content, fall back to the osascript clipboard
image reader (same path that Ctrl+V uses).

Refs #123
2026-07-08 19:55:00 +09:00
0chencc b3797eaaf8 fix: installer --lean-off must also clear settings.json
The installer's --lean-off only created the flag file but didn't
remove lean keys from settings.json (the wrapper did, but CI tests
use the installer directly). Add cleanup logic to the installer's
--lean-off branch matching the wrapper's behavior.
2026-07-08 02:42:43 +09:00
0chencc 84f1d1768a feat: three-level lean mode (on/max/off) with instant wrapper toggle
- Lean on (default): deny unused tools + disable Workflows/RemoteControl/Artifact
- Lean max: additionally deny Plan mode, Agent Teams, bundled skills
- Lean off: restore all tools
- Toggle via `claude --lean-on` / `claude --lean-max` / `claude --lean-off`
- State persists across updates via .lean-disabled / .lean-max flag files
- Installer respects existing state — never overwrites user choice
- Bump CLAWGOD_SELF_VERSION to 1.6.0
2026-07-08 02:34:26 +09:00
0chencc 2ea55bbbfe feat(wrapper): handle --lean-off / --lean-on directly in wrapper
Users can now toggle lean mode instantly without running the full
installer: `claude --lean-off` / `claude --lean-on`. The wrapper
intercepts these flags, updates settings.json and the .lean-disabled
flag, then exits immediately.

Bump CLAWGOD_SELF_VERSION to 1.5.1.
2026-07-08 02:21:16 +09:00
0chencc 27c609823c feat: lean settings, toggle, CI verification, and static site update
- Lean mode: auto-merge token-saving defaults into ~/.claude/settings.json
  (deny unused tool definitions + disable Workflows/RemoteControl/Artifact)
- Toggle: --lean-off / --lean-on with persistent .lean-disabled flag
- CI: add lean settings verification + lean-off/lean-on toggle test steps
- Static site: add geo-steganography, reliability, lean settings sections
- READMEs: add lean settings entry with toggle instructions (EN/ZH/JP)
- Bump CLAWGOD_SELF_VERSION to 1.5.0
2026-07-08 02:02:35 +09:00
0chencc 2c8ae78b76 chore: bump CLAWGOD_SELF_VERSION to 1.4.1 2026-07-04 18:39:32 +09:00
0chencc aa0f4a3cdb fix(patcher): pass env explicitly in update spawnSync for Bun compat
Bun's spawnSync does not inherit runtime modifications to process.env
by default (unlike Node.js). CLAWGOD_VERSION and CLAWGOD_NO_UPGRADE
set inside the action handler were invisible to the child process.
Add env:process.env to spawnSync options.

Closes #119
2026-07-04 17:53:24 +09:00
0chencc f78ae50391 docs: add Glob/Grep restore and update notification to READMEs
Update all three language versions with new Reliability entries for
Glob/Grep tool restoration and update notification. Bump patch count
from 28 to 29.
2026-07-03 18:15:42 +09:00
0chencc 185d61ad11 chore: bump CLAWGOD_SELF_VERSION to 1.4.0 2026-07-03 18:08:35 +09:00
0chencc b9e6498b0d feat(patcher): restore Glob/Grep tools by un-inlining EMBEDDED_SEARCH_TOOLS
Bun inlines process.env.EMBEDDED_SEARCH_TOOLS as literal "true" at
compile time, making bC() always return true and hiding Glob/Grep
tools. Patch replaces ct("true") with ct(process.env.EMBEDDED_SEARCH_TOOLS)
so the guard reads the actual env var. Also injects bfs/ugrep binary
availability check — if neither is installed, falls back to tool mode.

Closes #120
2026-07-03 18:02:06 +09:00
0chencc 7995d12495 feat(wrapper): add update notification via GitHub API check
On startup, the wrapper checks a cached version file against the
latest GitHub release (24h interval, async fire-and-forget). If a
newer clawgod version exists, prints a one-line notice to stderr
before Claude starts. Non-blocking, no startup delay.

Also writes ~/.clawgod/.clawgod-version during install for the
version comparison.
2026-07-03 03:01:05 +09:00
0chencc 24f405f7ae fix(patcher): add allowUnknownOption to update command for --version support
Commander rejects unregistered options before reaching the action
handler. Our --version/--no-upgrade parsing lives inside action(),
so commander throws "unknown option '--version'" before it ever runs.

Insert .allowUnknownOption() between .description() and .action()
so commander passes all flags through to our argv-based parser.

Closes #116
2026-07-01 20:43:50 +09:00
0chencc 6ec137b293 docs: add geo-steganography neutralization section to all READMEs
Add new "Geo-Steganography Neutralization" section documenting the
three anti-fingerprinting patches (qla/rdp/odp). Update patch count
from 23 to 28. All three language versions (EN/ZH/JP) updated.
2026-07-01 14:55:55 +09:00
0chencc 5a380b8f92 fix(patcher): rdp regex must match nested braces (}} not })
rdp() body contains return{...some(...)...} with nested braces.
[^}]*} stopped at the first inner }, leaving a dangling } that
broke Bun's parser. Use [\s\S]*?\}\} to match the double-close.
2026-07-01 14:32:51 +09:00
0chencc c7b76320a7 fix(patcher): replace smart quotes (U+2018/U+2019) with ASCII in odp patch block
The previous edit introduced Unicode smart quotes into JS string
literals inside the PATCHER_EOF heredoc. Node.js rejects these as
string delimiters, causing SyntaxError at parse time.
2026-07-01 14:27:39 +09:00
0chencc cb0315b215 fix(patcher): use RegExp constructor for odp pattern to avoid Unicode syntax error
The odp regex contained literal Unicode characters (U+2019, U+02BC,
U+02B9) inside a /…/g literal. When the heredoc wrote patch.mjs,
Node.js failed to parse it with "SyntaxError: Invalid or unexpected
token". Switch to new RegExp() with \uXXXX escapes.
2026-07-01 14:22:49 +09:00
0chencc 181ffcc675 feat(patcher): neutralize geo-steganography in system prompt (v2.1.197+)
v2.1.197 introduced client-side geo-detection that encodes user
location into the system prompt via Unicode apostrophe variants and
date format manipulation. Three regex patches disable this:

- qla: bypass apostrophe selection + date format encoding
- rdp: force geo-probe to return null (same as firstParty path)
- odp: force ASCII apostrophe regardless of detection state

Verified on v2.1.197 ELF binary — all three patches match,
zero steganographic residue after patching.
2026-07-01 13:58:32 +09:00
0chencc d20dead4b9 feat(install): add --no-upgrade flag and claude update arg forwarding
- install.sh: --no-upgrade / CLAWGOD_NO_UPGRADE skips npm download,
  restores clean cli.original.cjs from backup, re-patches in place
- install.ps1: -NoUpgrade switch with same behavior
- claude update redirect: forwards --version and --no-upgrade via env
  vars (CLAWGOD_VERSION / CLAWGOD_NO_UPGRADE) to child installer
- README/README_ZH/README_JP: document new update options

Closes #114
2026-06-22 12:39:06 +09:00
0chencc 1274aa743c feat(features): enable auto-compact for third-party API users
Third-party API users don't receive GrowthBook feature flags from
Anthropic's server, so tengu_amber_redwood3 is always falsy. The
auto-compact trigger gate `kHH() && !kr() && !i9$()` blocks
compaction entirely for these users.

Add tengu_amber_redwood3 to the default features.json so the kr()
check passes. Official OAuth users are unaffected — GrowthBook
server-side values take precedence over the env override.

Closes #102
2026-06-12 13:41:58 +09:00
0chencc 42f005e107 extractor: replace feature-string cli.js scan with Bun section parser (#99)
extractor: replace feature-string cli.js scan with Bun section parser
2026-06-12 11:54:41 +09:00
0chencc 2ebe1a88c3 fix(wrapper): monkey-patch process.execPath to fix find/grep/rg under Bun (#101)
fix(wrapper): monkey-patch process.execPath to fix find/grep/rg under Bun
2026-06-12 11:54:37 +09:00
steponeerror c69165fffa fix(launcher): use install-time absolute path for CLAUDE_CODE_EXECPATH
- Bash: replace runtime $(dirname "$0")/claude.orig with install-time
  $CLAUDE_BIN.orig so secondary launchers (clawgod, BIN_DIR/claude) also
  resolve correctly
- Windows: quote set assignment to handle paths with special characters
- Move CLAUDE_BIN detection before launcher template so the absolute path
  is available at heredoc expansion time
2026-06-08 12:23:41 +08:00
steponeerror f8c7ede22f fix(launcher): export CLAUDE_CODE_EXECPATH in launcher scripts
The monkey-patch added in the previous commit reads
CLAUDE_CODE_EXECPATH from process.env to override process.execPath,
but neither launcher was actually setting this variable — making the
patch a no-op.

- Bash launcher: export CLAUDE_CODE_EXECPATH="$(dirname "$0")/claude.orig"
- Windows launcher: set CLAUDE_CODE_EXECPATH=%~dp0claude.orig.exe

Both resolve to the native claude binary in the same directory as the
launcher script.
2026-06-08 11:04:35 +08:00
steponeerror 70511668d3 fix(wrapper): monkey-patch process.execPath for PowerShell wrapper too
Same fix as install.sh — Anthropic's CLI uses process.execPath to locate
the native binary for shell wrappers and subprocess spawning. Under Bun,
this returns the Bun path instead of claude.orig. Apply the identical
monkey-patch in the PS1 wrapper so Windows users get the same fix.
2026-06-08 10:29:50 +08:00
0chencc 099f7c1b78 fix(patcher): remove hardcoded j8() in GrowthBook config overrides
The GrowthBook config overrides replacer injected a hardcoded `j8()`
call to read app state. In minified builds, `j8` is version-specific
and can point to anything — on CC 2.1.167 Windows it resolves to a
React hook (lazy initializer), which breaks hook ordering inside the
render tree and crashes with `null is not an object (evaluating
'yH.memoizedState.inst')`.

The config overrides slot is not needed: GrowthBook env overrides
(the preceding patch) already injects feature flags via
CLAUDE_INTERNAL_FC_OVERRIDES. Return null directly.

Closes #97
2026-06-07 23:40:54 +09:00
TheCjw 0e659f29cd extractor: parse Bun section to extract cli.js + napi in one pass
Replace the body-scan approach (separate JS-string anchors for cli.js +
ELF/Mach-O/PE magic-byte hunt for .node modules) with a strict Bun
section parser adapted from parse-bun/main.js. The new extractor reads
the .bun (PE/ELF) or __BUN,__bun (Mach-O) section, walks the module
record table, and dispatches output by record metadata:

  - entry_point_id  ->  cli.original.js
  - loader=napi     ->  vendor/<name>/<arch>-<os>/<name>.node
  - everything else ->  dropped

This collapses install.sh / install.ps1's two extractor invocations
(--cli-js + default) into one node call, and shrinks the heredoc'd
extractor from 562 lines (mixed body-scan logic) to 358 lines (pure
section/record walker). repatch.mjs is updated in lockstep.

post-process.mjs keeps the leading-@bun-pragma strip so Bun still
recognises the CJS wrapper after byte-exact extraction.

Drive-by: install.sh's --version flag and install.ps1's -Version
parameter were always parsed but never threaded through to npm pack /
fetch.mjs (hardcoded @latest since c5ba3ba). Both now correctly fetch
the requested release; default behaviour ('latest') unchanged.

Tested:
  - 8 reference binaries (linux-x64/arm64 +- musl, darwin-x64/arm64,
    win32-x64/arm64) all extract with the expected arch-os subdir
    naming and napi counts (image-processor + audio-capture on
    linux/win32, plus computer-use-{swift,input} + url-handler on
    darwin)
  - Manual end-to-end on Windows v2.1.163: clawgod TUI launches
    cleanly; vendor napi modules load from the rewritten path
2026-06-07 22:08:23 +08:00
steponeerror f5527edbdf fix(wrapper): monkey-patch process.execPath to fix find/grep/rg under Bun
Under Bun runtime, process.execPath returns the Bun binary path
(e.g. ~/.bun/bin/bun) instead of the Claude native ELF binary.
Anthropic's cli.original.cjs uses process.execPath in
getEnvironmentOverrides() to set CLAUDE_CODE_EXECPATH, which is
then consumed by shell wrapper functions that dispatch find→bfs,
grep→ugrep, and rg via ARGV0 multi-call dispatch.

Because Bun doesn't understand these flags (-S, -G, etc.), every
find and grep invocation in Claude Code's Bash tool fails with
"error: Invalid Argument".

The launcher script already sets CLAUDE_CODE_EXECPATH to claude.orig
(the real native binary) before exec'ing Bun. We preserve that value
by monkey-patching process.execPath before requiring cli.original.cjs.

Impact analysis (all 21 process.execPath usages verified):
- 14 safe/neutral (telemetry, path pattern checks, macOS-only)
- 5 improvements (fixes version locking, cleanup, subprocess spawn)
- 1 bug fix (getEnvironmentOverrides — the root cause)
- 1 safe (embedded rg dispatch — claude.orig supports multi-call)

Closes #100
2026-06-07 10:30:32 +08:00
0chencc c5ba3baa6b fix(patcher): revert env injection, add precise provider helper gate patch
v1.1.9's CLAUDE_CODE_ENABLE_AUTO_MODE=1 env injection caused r88() to
return true for third-party providers, triggering afk-mode beta header
sends to APIs that don't support it — crashing on all platforms.

Replace the env approach with a second regex that strips the
if(!helperFn(q))return!1; provider gate inside the auto-mode function
(identified by lookahead for !=="firstParty" within 300 chars).
r88() now correctly returns false for third-party, so no beta header
is sent. Auto-mode availability is controlled solely by the patched
WdH() model gate — client-side behavior, no server header needed.

Verified on 2.1.150 and 2.1.158: 26 applied, 0 failed.
2026-06-03 17:41:42 +09:00
0chencc 28bdfe61a2 fix(patcher): Auto-mode unlock regex stale on claude 2.1.158
2.1.158 refactored the firstParty/anthropicAws provider gate into a
helper function and appended a model-condition suffix to the remaining
inline gate.  Widen the regex with [^;]* to absorb the optional tail,
and inject CLAUDE_CODE_ENABLE_AUTO_MODE env in the wrapper to bypass
the new helper-based gate.

Closes #92
2026-05-31 01:40:17 +09:00
0chencc 1f2dfde554 fix(patcher): escape $$ in regex replacement strings (silent identifier corruption)
String.prototype.replace(pattern, str) interprets $$ in the replacement
string as a literal $, and $1/$2/$& as backreferences. Minified upstream
identifiers like `a$$` would silently become `a$` whenever they appeared
in a replacer's output — every other caller in the bundle still references
the original name, so they end up calling a function that doesn't exist,
and Claude Code's boot path hangs at the first such call before the trust
dialog ever renders.

Issue #86 surfaced this on CC 2.1.152: the Ultrareview patch replaces
`function a$$(){return V$("tengu_review_bughunter_config",null)}` with a
`function a$${...}` body. After patching the actual file contained
`function a$(){...}`, and OIH/ca/Tm4/gU all silently called the missing
`a$$()`. patcher reported 25 applied / 0 failed because the regex did
match — the corruption happened during write-back, not match.

Fix: use the function form of String.replace (`(m[0], () => replacement)`)
so the replacement is opaque to the parser. Applies to every patch
present and future — not Ultrareview-specific. install.sh and install.ps1
both patched in lockstep.

Also tighten the Ultrareview replacer to preserve the rest of the
config object (`cost_note`, `duration_note`, `model`) instead of returning
a bare `{enabled:!0}`. CC 2.1.152 added OIH/ca/Tm4 helpers that read
those fields; even though they have string fallbacks and the boot-hang
was caused by the $$ bug rather than the missing fields, keeping the
original shape is strictly more correct as upstream adds more fields.

Verified on ssh local-ubuntu:
  - CC 2.1.152 patched: 0 bytes (hang) → 1291 bytes (trust dialog), 0.57s
  - CC 2.1.140 patched (regression): still 25 applied / 0 failed, normal
  - Post-patch `a$$` definition + every OIH/ca/Tm4/gU caller intact

Closes #86
2026-05-29 03:03:58 +09:00
0chencc e7d290e0f4 fix(patcher): redirect shell-integration multitool path to claude.orig
Claude Code's shell-integration generator (iT6 in v2.1.140, was Wa1 earlier)
emits a zsh/bash function that calls the native claude binary with
ARGV0=ugrep|rg|... to trigger the multicall multitool dispatch baked into
the native binary.

After clawgod installs, the baked path points at our shell-script launcher.
Shell scripts CANNOT preserve argv[0] across invocation:
  - kernel shebang re-exec overwrites argv[0] with the interpreter name
  - zsh additionally refuses to export ARGV0 as an env var (special var)
So the launcher has no way to detect the multitool intent and forwards e.g.
`-G --ignore-files ...` straight to bun, which rejects `-G` as unknown.

Fix: patch cli.cjs's path-compute ternary inside the generator function so
the baked path becomes claude.orig[.exe] — the native binary backup clawgod
already creates at install time. The multitool dispatch then reaches a real
binary that honors argv[0] via its own kernel-level exec.

Anchored on the join(...) ternary form unique to the generator. The bare
"claude.exe":"claude" string also appears in u18() (basename helper) but
never inside a path.join(), so the regex hits exactly the right place and
nothing else (verified on 2.1.140: 1 match). Marked optional because the
v2.1.88-era Wa1 computed the path differently — that shape is gone in
current upstream, but optional avoids spurious skipped-becoming-failed
counts for users on very old binaries.

Caveat: existing .zshrc / .bashrc entries already written by Claude Code
before this fix still hold the old launcher path. Affected users need to
re-trigger Claude Code's shell-integration setup so the rewritten function
uses the new claude.orig path, or hand-edit the existing function.

Closes #82
2026-05-29 01:54:10 +09:00
54 changed files with 15150 additions and 1172 deletions
+7
View File
@@ -0,0 +1,7 @@
# Installer sources and generated release artifacts must be byte-reproducible
# on every developer platform. PowerShell's UTF-8 BOM is added by build.js;
# line-ending conversion must not rewrite either generated artifact.
build.js text eol=lf
install.sh text eol=lf
install.ps1 text eol=lf
src/** text eol=lf
+71
View File
@@ -0,0 +1,71 @@
'use strict';
const DAYS = 30;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
function lastReplyAt(issue) {
return Date.parse(issue.comments.nodes.at(-1)?.createdAt ?? issue.createdAt);
}
function inactive(issue, cutoff) {
const last = lastReplyAt(issue);
return !issue.closed && Number.isFinite(last) && last <= cutoff;
}
module.exports = async function closeInactiveIssues({ github, context, core, dryRun, now = Date.now() }) {
const cutoff = now - DAYS * MS_PER_DAY;
const repo = { owner: context.repo.owner, name: context.repo.repo };
const candidates = [];
let cursor = null;
do {
// GraphQL's issues connection excludes pull requests. Order by creation
// so labels, edits, and our own closing actions cannot reshuffle pages.
const result = await github.graphql(`query($owner:String!, $name:String!, $cursor:String) {
repository(owner:$owner, name:$name) {
issues(first:100, after:$cursor, states:OPEN, orderBy:{field:CREATED_AT,direction:ASC}) {
nodes { number title createdAt closed comments(last:1) { nodes { createdAt } } }
pageInfo { hasNextPage endCursor }
}
}
}`, { ...repo, cursor });
const page = result.repository.issues;
candidates.push(...page.nodes.filter((issue) => inactive(issue, cutoff)));
cursor = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null;
} while (cursor);
let closed = 0;
const confirmed = [];
for (const candidate of candidates) {
// A reply may have arrived while we scanned the repository. Re-read the
// issue immediately before closing; comment edits/labels are not replies.
const result = await github.graphql(`query($owner:String!, $name:String!, $number:Int!) {
repository(owner:$owner, name:$name) {
issue(number:$number) {
number title createdAt closed comments(last:1) { nodes { createdAt } }
}
}
}`, { ...repo, number: candidate.number });
const issue = result.repository.issue;
if (!issue || !inactive(issue, cutoff)) {
core.info(`Skip #${candidate.number}: closed or received a newer reply`);
continue;
}
core.info(`${dryRun ? 'Would close' : 'Closing'} #${issue.number}: ${issue.title}`);
confirmed.push(issue.number);
if (!dryRun) {
// Inactivity is not evidence that the reported bug has been fixed.
await github.rest.issues.update({ ...context.repo, issue_number: issue.number,
state: 'closed', state_reason: 'not_planned' });
closed++;
}
}
await core.summary.addHeading('Inactive issues')
.addRaw(`Policy: ${DAYS} days since the last reply, or creation if there are no replies.\n\n`)
.addRaw(`Cutoff: ${new Date(cutoff).toISOString()}\n\n`)
.addRaw(`Mode: ${dryRun ? 'dry run' : 'close'}; eligible: ${confirmed.length}; closed: ${closed}.\n\n`)
.addRaw(confirmed.map((number) => `#${number}`).join(', ') || 'No eligible issues.')
.write();
return { eligible: confirmed, closed };
};
module.exports.lastReplyAt = lastReplyAt;
module.exports.inactive = inactive;
@@ -0,0 +1,42 @@
'use strict';
const assert = require('node:assert/strict');
const closeIssues = require('./close-inactive-issues.cjs');
const now = Date.parse('2026-09-18T12:00:00Z');
const daysAgo = (days) => new Date(now - days * 86400000).toISOString();
const issue = (number, age, replyAge) => ({ number, title: `Issue ${number}`, closed: false,
createdAt: daysAgo(age), comments: { nodes: replyAge === undefined ? [] : [{ createdAt: daysAgo(replyAge) }] } });
const cutoff = now - 30 * 86400000;
assert.equal(closeIssues.inactive(issue(1, 30), cutoff), true, 'exactly 30 days qualifies');
assert.equal(closeIssues.inactive(issue(1, 29.99), cutoff), false);
assert.equal(closeIssues.inactive(issue(1, 90, 1), cutoff), false, 'new reply keeps an old issue open');
assert.equal(closeIssues.inactive({ ...issue(1, 90), updatedAt: daysAgo(0) }, cutoff), true, 'labels/edits do not reset reply age');
assert.equal(closeIssues.inactive({ ...issue(1, 90), createdAt: 'bad date' }, cutoff), false);
async function run(dryRun) {
const changes = [];
const pages = [
[issue(1, 30), issue(2, 90, 31), issue(3, 90, 1)],
[issue(4, 60), issue(5, 10), issue(6, 60, 30)],
];
const fresh = { 1: issue(1, 30), 2: issue(2, 90, 0),
4: { ...issue(4, 60), closed: true }, 6: issue(6, 60, 30) };
const github = {
graphql: async (_query, variables) => variables.number
? { repository: { issue: fresh[variables.number] } }
: { repository: { issues: { nodes: pages[variables.cursor ? 1 : 0],
pageInfo: { hasNextPage: !variables.cursor, endCursor: 'page2' } } } },
rest: { issues: { update: async (args) => changes.push(args) } },
};
const summary = { addHeading() { return this; }, addRaw() { return this; }, async write() {} };
const result = await closeIssues({ github, context: { repo: { owner: 'test', repo: 'repo' } },
core: { info() {}, summary }, now, dryRun });
assert.deepEqual(result.eligible, [1, 6], 'recheck excludes newly replied-to and already closed issues');
assert.equal(result.closed, dryRun ? 0 : 2);
assert.deepEqual(changes.map((x) => x.issue_number), dryRun ? [] : [1, 6]);
for (const change of changes) assert.equal(change.state_reason, 'not_planned');
}
(async () => {
await run(true);
await run(false);
console.log('inactive issue policy: age, replies, pagination, recheck and dry-run passed');
})().catch((error) => { console.error(error); process.exitCode = 1; });
+96
View File
@@ -0,0 +1,96 @@
name: Build clawgod-import
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
jobs:
build:
strategy:
matrix:
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
artifact: clawgod-import-linux-x64
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
artifact: clawgod-import-linux-arm64
cross: true
- target: x86_64-apple-darwin
os: macos-26-intel
artifact: clawgod-import-darwin-x64
- target: aarch64-apple-darwin
os: macos-latest
artifact: clawgod-import-darwin-arm64
- target: x86_64-pc-windows-msvc
os: windows-latest
artifact: clawgod-import-windows-x64.exe
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
- name: Install Rust
run: |
rustup update stable
rustup target add ${{ matrix.target }}
- name: Install cross (Linux ARM64)
if: matrix.cross
run: cargo install cross --git https://github.com/cross-rs/cross
- name: Build
working-directory: tools/provider-import
run: |
if [ "${{ matrix.cross }}" = "true" ]; then
cross build --release --target ${{ matrix.target }}
else
cargo build --release --target ${{ matrix.target }}
fi
shell: bash
- name: Rename artifact
shell: bash
run: |
src="tools/provider-import/target/${{ matrix.target }}/release/clawgod-import"
if [ "${{ runner.os }}" = "Windows" ]; then
src="${src}.exe"
fi
cp "$src" "${{ matrix.artifact }}"
- name: Upload artifact
uses: actions/upload-artifact@v6
with:
name: ${{ matrix.artifact }}
path: ${{ matrix.artifact }}
attach-to-release:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Download all artifacts
uses: actions/download-artifact@v7
with:
path: dist
- name: Upload to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF_NAME}"
for dir in dist/*/; do
for file in "$dir"*; do
echo "Uploading: $file"
gh release upload "$tag" "$file" --clobber --repo "${{ github.repository }}"
done
done
@@ -21,9 +21,6 @@ on:
permissions:
actions: write # required for `gh cache delete`
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
purge:
runs-on: ubuntu-latest
@@ -0,0 +1,36 @@
name: Close inactive issues
on:
schedule:
- cron: '35 7 * * *'
workflow_dispatch:
inputs:
dry_run:
description: 'Preview eligible issues without closing them'
type: boolean
default: true
permissions:
contents: read
issues: write
concurrency:
group: close-inactive-issues
cancel-in-progress: false
jobs:
close:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- name: Check inactivity policy tests
run: node .github/scripts/close-inactive-issues.test.cjs
- name: Close issues without a reply for at least 30 days
uses: actions/github-script@v8
env:
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }}
with:
script: |
const closeInactiveIssues = require('./.github/scripts/close-inactive-issues.cjs');
await closeInactiveIssues({ github, context, core, dryRun: process.env.DRY_RUN === 'true' });
+648 -28
View File
@@ -1,8 +1,8 @@
name: Compat Daily
# Runs install.sh end-to-end on every supported platform against the current
# latest Claude Code from npm. Catches the kind of regression we hit when
# Anthropic bumps the embedded Bun runtime ahead of Bun's stable release.
# Runs the Linux, macOS, and Windows installers end-to-end against the current latest
# Claude Code from npm. Catches upstream compatibility changes as well as
# platform-specific extraction, path-rewriting, and launcher regressions.
on:
schedule:
@@ -11,39 +11,115 @@ on:
push:
branches: [main]
paths:
- build.js
- install.sh
- install.ps1
- src/**
- .github/workflows/compat-daily.yml
- .github/workflows/close-inactive-issues.yml
- .github/scripts/**
pull_request:
paths:
- build.js
- install.sh
- install.ps1
- src/**
- .github/workflows/compat-daily.yml
- .github/workflows/close-inactive-issues.yml
- .github/scripts/**
permissions:
contents: write # write needed to push the version-badge JSON to `badges` branch
issues: write
# Opt every JS-based action (e.g. actions/checkout, actions/cache,
# actions/github-script) onto Node 24, ahead of GitHub forcing it on
# 2026-06-02. Silences the deprecation warning and keeps us aligned
# with the Node version we use to run patch.mjs / extract-natives.mjs.
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
# JavaScript actions use native Node.js 24 releases.
jobs:
smoke:
# Single Linux runner — a Linux failure almost always means cross-platform
# breakage upstream. macOS / Windows runners are 10x / 2x more expensive
# than Linux on shared-tier accounting, so we don't burn them on a daily
# signal.
build-sources:
name: Generated installers are current
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up Node.js 24
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Verify generated installers
run: node build.js --check
- name: Run patch semantic tests
run: node src/shared/patch.test.mjs
- name: Run clean source and repatch regression tests
run: node src/shared/source-backup.test.mjs
- name: Test inactive issue policy
run: node .github/scripts/close-inactive-issues.test.cjs
- name: Run terminal reply regression tests
run: node src/shared/terminal-reply.test.mjs
- name: Run Lean mode regression tests
run: node src/shared/lean.test.mjs
- name: Run updater environment regression tests
run: node src/shared/updater.test.mjs
- name: Run bounded startup verification regression tests
run: node src/shared/startup-check.test.mjs
- name: Run provider auth and proxy effort regression tests
run: node src/shared/provider.test.mjs
- name: Run Chat Completions protocol regression tests
run: node --test src/shared/openai-proxy.test.mjs
- name: Run Bun.ant shim tests
# Claude Code >= 2.1.271 renders through Bun.ant.CellSegmenter, which
# stock Bun lacks; the shim answers it. Pure JS, so it runs on Node.
run: node src/shared/bun-ant-shim.test.mjs
- name: Check embedded JavaScript syntax
shell: bash
run: |
set -euo pipefail
for file in src/shared/*.mjs src/shared/*.cjs src/windows/*.mjs src/windows/*.cjs; do
node --check "$file"
done
smoke:
# Share Unix installer checks across Linux x64 and macOS ARM64. Linux
# remains the badge-publishing baseline; Windows uses a separate job below.
name: ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
- name: Linux smoke (x64)
os: ubuntu-latest
platform: linux
arch: x86_64
- name: macOS smoke (ARM64)
os: macos-26
platform: macos
arch: arm64
runs-on: ${{ matrix.os }}
timeout-minutes: 20
env:
SMOKE_PLATFORM: ${{ matrix.platform }}
SMOKE_RUNNER: ${{ matrix.os }}
EXPECTED_ARCH: ${{ matrix.arch }}
steps:
- uses: actions/checkout@v5
- name: Set up Node.js 24
uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Set up Bun (canary)
# Anthropic ships claude-code with bleeding-edge Bun (e.g. 1.3.14
@@ -57,24 +133,34 @@ jobs:
bun-version: canary
- name: Cache ~/.npm (claude-code-<plat> tarball)
# install.sh runs `npm pack @anthropic-ai/claude-code-linux-x64@latest`,
# install.sh runs `npm pack` for the detected OS and architecture,
# which lands the ~80 MB tarball in ~/.npm/_cacache. Reusing the cache
# lets npm short-circuit the download when @latest hasn't bumped since
# the last run; it still re-validates registry metadata, so we never
# serve a stale binary on a real upgrade day.
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.npm
key: npm-claude-${{ runner.os }}-${{ github.run_id }}
key: npm-claude-${{ runner.os }}-${{ runner.arch }}-${{ github.run_id }}
restore-keys: |
npm-claude-${{ runner.os }}-
npm-claude-${{ runner.os }}-${{ runner.arch }}-
- name: Install ripgrep
- name: Install ripgrep (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
sudo apt-get update -qq && sudo apt-get install -y ripgrep
rg --version | head -1
- name: Install ripgrep (macOS)
if: runner.os == 'macOS'
shell: bash
run: |
if ! command -v rg >/dev/null 2>&1; then
brew install ripgrep
fi
rg --version | head -1
- name: Add ~/.local/bin to PATH (clawgod launcher install target)
shell: bash
run: echo "$HOME/.local/bin" >> "$GITHUB_PATH"
@@ -85,6 +171,13 @@ jobs:
bun --version
node --version
uname -a
[[ "$(uname -m)" == "$EXPECTED_ARCH" ]] || { echo "::error::unexpected runner architecture"; exit 1; }
if [[ "$RUNNER_OS" == "macOS" ]]; then
sw_vers
fi
- name: Configure a provider before fresh installation
run: node src/ci/provider-startup-fixture.cjs seed
- name: Run install.sh (npm-fallback path)
# No official Claude binary pre-installed → install.sh exercises
@@ -102,11 +195,67 @@ jobs:
shell: bash
run: |
ls -la "$HOME/.clawgod/" || true
for f in cli.cjs cli.original.cjs patch.mjs extract-natives.mjs post-process.mjs .source-version; do
for f in cli.cjs cli.original.cjs startup-check.cjs startup-check.log patch.mjs extract-natives.mjs post-process.mjs feature-gates.cjs runtime-helpers.cjs bun-ant-shim.cjs .source-version; do
test -e "$HOME/.clawgod/$f" || { echo "::error::missing ~/.clawgod/$f"; exit 1; }
done
echo "Source: $(cat "$HOME/.clawgod/.source-version")"
- name: Assert renderer runtime was reported
# install.sh names which runtime the TUI renderer needs, so a silent
# regression in the detection (or in the shim wiring) is visible.
shell: bash
run: |
grep -q 'Renderer runtime:' /tmp/install.log \
|| { echo "::error::install.sh did not report the renderer runtime"; exit 1; }
grep 'Renderer runtime:' /tmp/install.log
- name: Verify Bun.ant shim provides the renderer API
# Claude Code >= 2.1.271 builds its Ink renderer on Bun.ant.CellSegmenter
# and throws on every frame without it, which shows up as a TUI that
# never paints. The shim must supply the API under the real Bun runtime
# and round-trip a line through segment/paint.
shell: bash
run: |
bun -e '
require(require("os").homedir() + "/.clawgod/bun-ant-shim.cjs");
if (typeof Bun.ant?.CellSegmenter !== "function") {
console.error("::error::bun-ant-shim did not install Bun.ant.CellSegmenter");
process.exit(1);
}
const seg = new Bun.ant.CellSegmenter({
ambiguousIsNarrow: true,
screen: { widthMask: 3, narrow: 0, wide: 1, spacerTail: 2, spacerHead: 3, emptyCharIndex: 0, spacerCharIndex: 1, emptyWord: 0, tabWidth: 8 },
tabWidth: 8,
});
const cells = new Int32Array(64);
const runs = new Int32Array(64);
if (seg.segment("ab", cells, runs, false) !== 2) {
console.error("::error::segment() mismatch");
process.exit(1);
}
const screen = new Int32Array(64);
const charMap = new Int32Array(seg.graphemes.length);
if (seg.paint(screen, 8, 0, 0, cells, 2, undefined, charMap, new Int32Array(2)) === 0) {
console.error("::error::paint() reported no damage");
process.exit(1);
}
console.log("bun-ant shim: OK");
'
- name: Run Bun.ant shim suite under Bun
# The Node build-sources job covers the fallback width path; this run
# exercises the production Bun.stringWidth branch of the shim.
shell: bash
run: bun src/shared/bun-ant-shim.test.mjs
- name: Run Chat Completions protocol and HTTP integration tests under Bun
shell: bash
run: bun test src/shared/openai-proxy.test.mjs src/shared/openai-proxy.integration.test.mjs
- name: Verify version-only startup with a configured provider
shell: bash
run: CLAWGOD_TEST_BUN="$(command -v bun)" node src/shared/startup-check.test.mjs
- name: Assert all patches applied (parse install log)
# patch.mjs prints "Result: A applied, S skipped, F failed".
# F must be 0 — otherwise upstream has shifted enough that one of
@@ -119,7 +268,69 @@ jobs:
failed=$(echo "$line" | sed -E 's/.*skipped, ([0-9]+) failed.*/\1/')
[[ "$failed" -eq 0 ]] || { echo "::error::patch.mjs reported $failed failed patches"; exit 1; }
- name: Verify repeated no-upgrade installs
shell: bash
run: |
set -euo pipefail
baseline=$(grep -oE 'Result: [0-9]+ applied, [0-9]+ skipped, [0-9]+ failed' /tmp/install.log | tail -1)
for attempt in 1 2; do
log="/tmp/reinstall-$attempt.log"
bash install.sh --no-upgrade >"$log" 2>&1 || { cat "$log"; exit 1; }
summary=$(grep -oE 'Result: [0-9]+ applied, [0-9]+ skipped, [0-9]+ failed' "$log" | tail -1)
echo "$summary"
[[ "$summary" == "$baseline" ]] || { cat "$log"; exit 1; }
grep -q 'Using complete clean-source backup' "$log"
done
node src/ci/provider-startup-fixture.cjs verify /tmp/install.log /tmp/reinstall-1.log /tmp/reinstall-2.log
- name: Verify lean settings applied
shell: bash
run: |
settings="$HOME/.claude/settings.json"
[[ -f "$settings" ]] || { echo "::error::~/.claude/settings.json not created"; exit 1; }
# Check that lean keys are present
for key in disableWorkflows disableClaudeAiConnectors disableArtifact; do
if ! grep -q "\"$key\"" "$settings"; then
echo "::error::lean key $key missing from settings.json"
cat "$settings"
exit 1
fi
done
node -e 'const s=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); if(s.disableRemoteControl===true) throw Error("default Lean must allow Remote Control")' "$settings"
for tool in DesignSync NotebookEdit CronCreate; do
if ! grep -q "\"$tool\"" "$settings"; then
echo "::error::deny tool $tool missing from settings.json"
cat "$settings"
exit 1
fi
done
echo "Lean settings verified:"
cat "$settings"
- name: Verify lean-off / lean-on toggle
shell: bash
run: |
# --lean-off should remove lean keys and create flag
bash install.sh --no-upgrade --lean-off 2>&1 | tail -3
[[ -f "$HOME/.clawgod/.lean-disabled" ]] || { echo "::error::.lean-disabled flag not created"; exit 1; }
settings="$HOME/.claude/settings.json"
if grep -q '"disableWorkflows"' "$settings"; then
echo "::error::lean-off failed to remove disableWorkflows"
cat "$settings"
exit 1
fi
# --lean-on should restore
bash install.sh --no-upgrade --lean-on 2>&1 | tail -3
[[ ! -f "$HOME/.clawgod/.lean-disabled" ]] || { echo "::error::.lean-disabled flag not removed"; exit 1; }
if ! grep -q '"disableWorkflows"' "$settings"; then
echo "::error::lean-on failed to restore disableWorkflows"
cat "$settings"
exit 1
fi
echo "Lean toggle verified"
- name: Smoke test — claude --version
timeout-minutes: 1
# Exercises the wrapper path:
# launcher → bun cli.cjs → require('./cli.original.cjs')
# The Bun CJS-wrapper panic surfaces here, not at install time.
@@ -135,13 +346,61 @@ jobs:
exit 1
fi
[[ $rc -eq 0 ]] || { echo "::error::claude --version exited $rc"; exit 1; }
if echo "$out" | grep -qi 'proxy on port'; then
echo '::error::version query started a provider listener'
exit 1
fi
- name: Functional checks — runtime invariants
# Verify that clawgod's monkey-patches produce correct runtime state.
# These catch issues like #133 (daemon/fork broken because
# Bun.isStandaloneExecutable wasn't patched) before users hit them.
shell: bash
run: |
# The launcher sets CLAUDE_CODE_EXECPATH before running bun cli.cjs.
# Simulate the same env so cli.cjs monkey-patches take effect.
exec_path="$HOME/.local/bin/claude.orig"
export CLAUDE_CODE_EXECPATH="$exec_path"
echo "── Checking fv() patched (Bun.isStandaloneExecutable → true) ──"
# The patcher replaces fv(){return Bun.isStandaloneExecutable===!0}
# with fv(){return!0}. If the original form is still present, the
# patch missed and daemon/fork/multitool will break.
if grep -q 'Bun\.isStandaloneExecutable===!0' "$HOME/.clawgod/cli.original.cjs"; then
echo "::error::fv() not patched — Bun.isStandaloneExecutable===!0 still in source"
exit 1
fi
echo " fv() patched: OK"
echo "── Checking process.execPath monkey-patch in wrapper ──"
grep -q 'CLAUDE_CODE_EXECPATH' "$HOME/.clawgod/cli.cjs" || {
echo "::error::cli.cjs missing CLAUDE_CODE_EXECPATH monkey-patch"
exit 1
}
echo " execPath patch: OK"
echo "── Checking multitool dispatch (ugrep, bfs, rg) ──"
for tool in ugrep bfs rg; do
if ARGV0="$tool" "$exec_path" --version >/dev/null 2>&1; then
echo " $tool dispatch: OK"
else
echo "::error::multitool dispatch failed for $tool via $exec_path"
exit 1
fi
done
echo "── Checking openai-proxy.cjs exists ──"
test -f "$HOME/.clawgod/openai-proxy.cjs" || { echo "::error::openai-proxy.cjs missing"; exit 1; }
echo " openai-proxy.cjs: OK ($(wc -c < "$HOME/.clawgod/openai-proxy.cjs") bytes)"
echo "── All functional checks passed ──"
- name: Publish supported-Claude-version badge
# Write the version we just verified end-to-end to a JSON file on the
# `badges` branch (force-pushed; that branch holds nothing else and is
# never read as source). README links to it via shields.io endpoint.
# PRs from forks can't push, so skip them.
if: success() && github.event_name != 'pull_request'
if: success() && matrix.platform == 'linux' && github.event_name != 'pull_request'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -168,7 +427,7 @@ jobs:
- name: Open / update issue on scheduled failure
if: failure() && github.event_name == 'schedule'
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
@@ -177,7 +436,10 @@ jobs:
const claudeVersion = fs.existsSync(stamp)
? fs.readFileSync(stamp, 'utf8').trim()
: 'unknown';
const title = `compat-daily: broke (claude ${claudeVersion})`;
const prefix = process.env.SMOKE_PLATFORM === 'linux'
? 'compat-daily'
: `compat-daily/${process.env.SMOKE_PLATFORM}`;
const title = `${prefix}: broke (claude ${claudeVersion})`;
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const { data: open } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
@@ -201,7 +463,365 @@ jobs:
title,
labels: ['compat-broken', 'bug'],
body: [
`Daily compatibility run failed on \`ubuntu-latest\`.`,
`Daily compatibility run failed on \`${process.env.SMOKE_RUNNER}\`.`,
``,
`- Claude binary: \`${claudeVersion}\``,
`- Run: ${runUrl}`,
``,
`Auto-opened by \`.github/workflows/compat-daily.yml\`.`,
].join('\n'),
});
}
windows-smoke:
name: Windows smoke (PowerShell 5.1, Claude ${{ matrix.claude }})
runs-on: windows-latest
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
include:
- claude: latest
bun: canary
- claude: 2.1.272
bun: 1.4.2
- claude: 2.1.285
bun: 1.4.2
env:
NPM_CONFIG_CACHE: ${{ github.workspace }}\.npm-cache
CLAWGOD_VERSION: ${{ matrix.claude }}
steps:
- uses: actions/checkout@v5
- name: Set up Node.js 24
uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ matrix.bun }}
- name: Test Windows launcher recovery and repeated installs
shell: powershell
run: ./src/windows/launchers.test.ps1
- name: Test Windows clean source recovery
shell: powershell
run: ./src/windows/source-recovery.test.ps1
- name: Test bounded startup verification and configured-provider version queries
shell: powershell
run: |
$env:CLAWGOD_TEST_BUN = (Get-Command bun).Source
node src/shared/startup-check.test.mjs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
./src/windows/startup-check.test.ps1
- name: Set up Python for ConPTY tests
uses: actions/setup-python@v7
with:
python-version: '3.12'
- name: Install terminal test dependencies
shell: powershell
run: |
python -m pip install -r src/ci/tui-requirements.txt
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Cache npm downloads
uses: actions/cache@v5
with:
path: ${{ github.workspace }}\.npm-cache
key: npm-claude-${{ runner.os }}-${{ github.run_id }}
restore-keys: |
npm-claude-${{ runner.os }}-
- name: Install ripgrep
shell: powershell
run: |
if (-not (Get-Command rg -ErrorAction SilentlyContinue)) {
choco install ripgrep --yes --no-progress
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
# Drain native stdout; Select-Object -First can stop rg early.
rg --version
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Add ClawGod launcher directory to PATH
shell: powershell
run: |
"$env:USERPROFILE\.local\bin" |
Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Print runtime versions
shell: powershell
run: |
Write-Host "PowerShell $($PSVersionTable.PSVersion)"
node --version
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
bun --version
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
rg --version
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Verify generated installers on Windows
shell: powershell
run: node build.js --check
- name: Verify Windows installer encoding
# The documented `irm ... | iex` path passes a decoded string to the
# parser. A BOM then becomes a literal U+FEFF before #Requires. ASCII
# also avoids the Windows PowerShell 5.1 active-code-page fallback.
shell: powershell
run: |
$bytes = [System.IO.File]::ReadAllBytes("$env:GITHUB_WORKSPACE\install.ps1")
$nonAscii = $bytes | Where-Object { $_ -gt 0x7F } | Select-Object -First 1
if ($null -ne $nonAscii) {
Write-Error 'install.ps1 must be BOM-free ASCII for Windows PowerShell 5.1 and irm | iex'
}
$content = [System.Text.Encoding]::ASCII.GetString($bytes)
if (-not $content.StartsWith('#Requires')) {
Write-Error 'install.ps1 must start directly with #Requires'
}
- name: Configure a provider before fresh installation
run: node src/ci/provider-startup-fixture.cjs seed
- name: Run install.ps1 through Invoke-Expression (npm-fallback path)
# Use Windows PowerShell 5.1 and evaluate the installer as a decoded
# string, matching the documented `irm ... | iex` parser path.
# Tee the complete output so a missing/failed patch run cannot be
# mistaken for a successful install.
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
$log = Join-Path $env:RUNNER_TEMP 'install-windows.log'
$installerPath = "$env:GITHUB_WORKSPACE\install.ps1"
$installer = [System.IO.File]::ReadAllText(
$installerPath,
[System.Text.Encoding]::ASCII
)
Invoke-Expression $installer *>&1 |
Tee-Object -FilePath $log
- name: Verify install artifacts
shell: powershell
run: |
$clawDir = Join-Path $env:USERPROFILE '.clawgod'
Get-ChildItem -Force $clawDir
$required = @(
'cli.cjs',
'cli.original.cjs',
'startup-check.cjs',
'startup-check.log',
'patch.mjs',
'extract-natives.mjs',
'post-process.mjs',
'feature-gates.cjs',
'runtime-helpers.cjs',
'bun-ant-shim.cjs',
'.source-version'
)
foreach ($name in $required) {
$path = Join-Path $clawDir $name
if (-not (Test-Path $path)) {
Write-Error "Missing install artifact: $path"
}
}
Write-Host "Source: $((Get-Content (Join-Path $clawDir '.source-version') -Raw).Trim())"
- name: Assert renderer runtime was reported
shell: powershell
run: |
$log = Join-Path $env:RUNNER_TEMP 'install-windows.log'
$line = Select-String -Path $log -Pattern 'Renderer runtime:' | Select-Object -Last 1
if (-not $line) {
Write-Error 'install.ps1 did not report the renderer runtime'
}
Write-Host $line.Line
- name: Verify Bun.ant shim provides the renderer API
# Claude Code >= 2.1.271 throws on every frame without
# Bun.ant.CellSegmenter; the shim must supply it under the real Bun.
shell: powershell
run: |
$check = Join-Path $env:RUNNER_TEMP 'bun-ant-shim-check.js'
$code = @'
require(require("os").homedir() + "/.clawgod/bun-ant-shim.cjs");
if (typeof Bun.ant?.CellSegmenter !== "function") {
console.error("bun-ant-shim did not install Bun.ant.CellSegmenter");
process.exit(1);
}
const seg = new Bun.ant.CellSegmenter({
ambiguousIsNarrow: true,
screen: { widthMask: 3, narrow: 0, wide: 1, spacerTail: 2, spacerHead: 3, emptyCharIndex: 0, spacerCharIndex: 1, emptyWord: 0, tabWidth: 8 },
tabWidth: 8,
});
const cells = new Int32Array(64);
const runs = new Int32Array(64);
if (seg.segment("ab", cells, runs, false) !== 2) {
console.error("segment() mismatch");
process.exit(1);
}
const screen = new Int32Array(64);
const charMap = new Int32Array(seg.graphemes.length);
if (seg.paint(screen, 8, 0, 0, cells, 2, undefined, charMap, new Int32Array(2)) === 0) {
console.error("paint() reported no damage");
process.exit(1);
}
console.log("bun-ant shim: OK");
'@
Set-Content -Path $check -Value $code -Encoding ASCII
bun $check
if ($LASTEXITCODE -ne 0) {
Write-Error 'bun-ant shim check failed'
}
- name: Run Chat Completions protocol and HTTP integration tests under Bun
shell: powershell
run: |
bun test src/shared/openai-proxy.test.mjs src/shared/openai-proxy.integration.test.mjs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Assert all patches applied
shell: powershell
run: |
$log = Join-Path $env:RUNNER_TEMP 'install-windows.log'
$summary = Select-String -Path $log `
-Pattern 'Result: [0-9]+ applied, [0-9]+ skipped, [0-9]+ failed' |
Select-Object -Last 1
if (-not $summary) {
Write-Error 'patch.mjs result line missing from install.ps1 output'
}
Write-Host "Patch summary: $($summary.Line)"
if ($summary.Line -notmatch 'skipped, 0 failed') {
Write-Error "patch.mjs reported failed patches: $($summary.Line)"
}
- name: Verify repeated no-upgrade installs on Windows
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
$pattern = 'Result: [0-9]+ applied, [0-9]+ skipped, [0-9]+ failed'
$baseline = [regex]::Match((Get-Content (Join-Path $env:RUNNER_TEMP 'install-windows.log') -Raw), $pattern).Value
foreach ($attempt in 1..2) {
$log = Join-Path $env:RUNNER_TEMP "reinstall-windows-$attempt.log"
& powershell -NoProfile -ExecutionPolicy Bypass -File ./install.ps1 -NoUpgrade *> $log
if ($LASTEXITCODE -ne 0) { Get-Content $log; throw 'Reinstall failed' }
$output = Get-Content $log -Raw
$summary = [regex]::Match($output, $pattern).Value
Write-Host $summary
if (-not $summary -or $summary -ne $baseline) { throw 'Reinstall changed patch results' }
if ($output -notmatch 'Using complete clean-source backup') { throw 'Clean source was not reused' }
node src/ci/provider-startup-fixture.cjs verify $log
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
node src/ci/provider-startup-fixture.cjs verify (Join-Path $env:RUNNER_TEMP 'install-windows.log')
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Smoke test - clawgod --version
timeout-minutes: 1
# Use the unambiguous clawgod.cmd entry point. This exercises the full
# launcher -> Bun -> cli.cjs -> chunk-graph path on Windows.
shell: powershell
run: |
$launcher = Join-Path $env:USERPROFILE '.local\bin\clawgod.cmd'
if (-not (Test-Path $launcher)) {
Write-Error "Launcher missing: $launcher"
}
# cli.cjs may print a successful update notice to stderr. Windows
# PowerShell wraps native stderr as ErrorRecord objects, so capture
# it without letting ErrorActionPreference abort before we can check
# the real native exit code.
$previousErrorAction = $ErrorActionPreference
try {
$ErrorActionPreference = 'Continue'
$output = & $launcher --version 2>&1 | Out-String
$exitCode = $LASTEXITCODE
} finally {
$ErrorActionPreference = $previousErrorAction
}
Write-Host $output
if ($exitCode -ne 0) {
Write-Error "clawgod --version exited $exitCode"
}
if ($output -match 'proxy on port') { throw 'Version query started a provider listener' }
- name: Interactive renderer regression (Windows ConPTY)
# --version does not paint a frame. Run the installed cli.cjs in a
# real console, type a prompt, and require a visible local mock reply.
# The pinned job also reproduces #182 with only the shim disabled.
shell: powershell
run: |
$clawDir = Join-Path $env:USERPROFILE '.clawgod'
$version = (Get-Content (Join-Path $clawDir '.source-version') -Raw).Trim()
$fixture = Join-Path $env:RUNNER_TEMP 'tui-native'
node src/windows/npm-fetch.mjs "@anthropic-ai/claude-code-win32-x64@$version" $fixture
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$testArgs = @(
'src/ci/tui-smoke.py',
'--cli', (Join-Path $clawDir 'cli.cjs'),
'--bun', (Get-Command bun).Source,
'--native', (Join-Path $fixture 'package\claude.exe'),
'--version', $version,
'--output', (Join-Path $env:RUNNER_TEMP 'tui-artifacts')
)
if ($env:CLAWGOD_VERSION -eq '2.1.272') {
$testArgs += '--expect-missing-without-shim'
}
python @testArgs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Upload terminal regression evidence
if: always()
uses: actions/upload-artifact@v7
with:
name: windows-tui-${{ matrix.claude }}
if-no-files-found: ignore
path: |
${{ runner.temp }}/tui-artifacts/*.log
${{ runner.temp }}/tui-artifacts/*.txt
${{ runner.temp }}/tui-artifacts/*.json
- name: Open or update issue on scheduled failure
if: failure() && github.event_name == 'schedule' && matrix.claude == 'latest'
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
const path = require('path');
const stamp = path.join(process.env.USERPROFILE, '.clawgod', '.source-version');
const claudeVersion = fs.existsSync(stamp)
? fs.readFileSync(stamp, 'utf8').trim()
: 'unknown';
const title = `compat-daily/windows: broke (claude ${claudeVersion})`;
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const { data: open } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'compat-broken',
per_page: 100,
});
const dup = open.find(i => i.title === title);
if (dup) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: dup.number,
body: `Re-failed ${new Date().toISOString().slice(0,10)} - ${runUrl}`,
});
} else {
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
labels: ['compat-broken', 'bug'],
body: [
`Daily compatibility run failed on \`windows-latest\`.`,
``,
`- Claude binary: \`${claudeVersion}\``,
`- Run: ${runUrl}`,
+52
View File
@@ -0,0 +1,52 @@
name: Reject README-only PR
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: read
jobs:
reject-readme-only:
name: Reject README-only changes
runs-on: ubuntu-latest
steps:
- name: Get changed files
id: changed
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
gh api \
--paginate \
-H "Accept: application/vnd.github+json" \
"/repos/$REPO/pulls/$PR_NUMBER/files?per_page=100" \
--jq '.[].filename' > changed_files.txt
echo "Changed files:"
cat changed_files.txt
- name: Reject README-only changes
run: |
FILE_COUNT=$(wc -l < changed_files.txt)
if [ "$FILE_COUNT" -eq 0 ]; then
echo "::error::PR contains no changed files."
exit 1
fi
# Reject PRs that modify nothing but README.md / README_JP.md / README_ZH.md.
# These docs-only PRs carry no value for this project.
while IFS= read -r file; do
case "$file" in
README.md|README_JP.md|README_ZH.md) ;;
*) echo "OK: PR contains changes beyond README docs."; exit 0 ;;
esac
done < changed_files.txt
echo "::error::PRs that only modify README.md / README_JP.md / README_ZH.md are not allowed."
exit 1
+20 -9
View File
@@ -8,21 +8,25 @@ on:
permissions:
contents: write
# Project policy: never run JS-based actions on Node 20 — they're already
# deprecated and forced off after 2026-06-02. Pin every action that runs
# JS (actions/checkout etc.) to Node 24 across all our workflows.
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
# JavaScript actions use native Node.js 24 releases.
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Node.js 24
uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Verify generated installers
run: node build.js --check
- name: Resolve versions
id: versions
run: |
@@ -68,14 +72,21 @@ jobs:
fi
} > release-notes.md
- name: Inject version from tag
run: |
ver="${GITHUB_REF_NAME#v}"
echo "Injecting version: $ver"
sed -i "s/^CLAWGOD_SELF_VERSION=\".*\"/CLAWGOD_SELF_VERSION=\"${ver}\"/" install.sh
sed -i "s/^\\\$ClawSelfVersion = \"[^\"]*\"/\$ClawSelfVersion = \"${ver}\"/" install.ps1
grep 'CLAWGOD_SELF_VERSION=' install.sh | head -1
grep 'ClawSelfVersion' install.ps1 | head -1
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${{ steps.versions.outputs.tag }}"
if gh release view "$tag" >/dev/null 2>&1; then
# Release already exists (e.g. created manually with curated notes).
# Refresh assets only — don't touch notes/title.
gh release upload "$tag" install.sh install.ps1 --clobber
else
gh release create "$tag" install.sh install.ps1 \
+21
View File
@@ -0,0 +1,21 @@
# 项目协作约定
## Windows 测试环境
- 项目已有获授权的私有 Windows 测试机:`ssh 30560@10.1.1.161`。
- 需要 Windows 实测时优先使用该环境,不要重复询问连接地址,也不要把公开 GitHub Actions runner 当作唯一可用的 Windows 环境。
- 已确认该机器为 Windows 10,提供 Windows PowerShell 5.1;测试前检查所需 Node/Bun 路径。
- 私有 API 凭据仅用于获授权的测试,不得写入仓库、此文件、指南、公开 CI 日志或发布附件。测试使用隔离的临时目录,避免改动现有用户配置。
## 维护者自己的提交
- 对于项目维护者 `0Chencc` 自己的改动(包括代理代为完成的改动),不需要创建 PR,也不要默认创建草稿 PR。
- 完成改动和适用的验证后,直接提交并推送到目标分支;用户未指定时,目标分支为 `main`。
- 只有用户明确要求创建 PR 时,才为这些改动创建 PR。
- 此约定不改变外部贡献者 PR 的审核流程,也不省略必要的测试和检查。
## Issue 修复提交
- 明确针对某个 Issue 完成修复时,commit body 必须包含 GitHub 可识别的关闭关键字,统一使用 `Fixes #<issue号>`(例如 `Fixes #186`),让提交合入默认分支后自动关闭对应 Issue。
- 一次修复多个 Issue 时,每个 Issue 单独写一行 `Fixes #<issue号>`,不得遗漏。
- 对于已完成的 Issue 修复,不得仅使用 `Refs #<issue号>`,避免仍需手动关闭;仅分析、关联或尚未完成修复时才使用 `Refs #<issue号>`。
+94 -3
View File
@@ -50,6 +50,7 @@ Green logo = patched. Orange logo = original.
| **Agent Teams** | Multi-agent swarm collaboration, no flags needed |
| **Computer Use** | Screen control without Max/Pro subscription (macOS) |
| **Auto-mode** | Unlocks auto-mode for third-party API users (no firstParty gate) |
| **Classifier tuning** | Tunable auto-mode classifier: timeout / model / retries (`CLAWGOD_CLASSIFIER_TIMEOUT_MS`, `CLAWGOD_CLASSIFIER_MODEL`, `CLAWGOD_CLASSIFIER_RETRIES`) |
| **Ultraplan** | Multi-agent planning via Claude Code Remote |
| **Ultrareview** | Automated bug hunting via Claude Code Remote |
@@ -62,6 +63,14 @@ Green logo = patched. Orange logo = original.
| **Cautious Actions** | Forced confirmation before destructive operations |
| **Login Notice** | "Not logged in" startup reminder |
### Geo-Steganography Neutralization
| Patch | What's neutralized |
|-------|-------------------|
| **Date String (qla)** | System prompt encodes user location via Unicode apostrophe variants (U+0027 / U+2019 / U+02BC / U+02B9) and date separator (`-` vs `/` for CN timezone). Patched to always use ASCII `'` and unmodified date format |
| **Geo-Detection Probe (rdp)** | Client-side three-axis detection: timezone (`Asia/Shanghai` / `Asia/Urumqi`), proxy hostname against XOR-obfuscated 100+ domain blocklist, CN-LLM vendor keywords in base URL. Patched to always return null |
| **Apostrophe Selector (odp)** | Selects one of four Unicode apostrophes based on detection results. Patched to always return ASCII `'` (defense-in-depth) |
### Visual
| Patch | Effect |
@@ -73,9 +82,17 @@ Green logo = patched. Orange logo = original.
| Feature | What it does |
|---------|-------------|
| **Glob/Grep Restore** | Bun compile inlines `EMBEDDED_SEARCH_TOOLS=true`, hiding built-in Glob/Grep tools. Patch un-inlines the env check and adds bfs/ugrep binary availability detection — tools are restored when running under Bun runtime |
| **Renderer Shim (2.1.271+)** | Claude Code 2.1.271 builds its TUI renderer on `Bun.ant.CellSegmenter`, a private API that only Anthropic's bundled Bun exposes. ClawGod loads a JS implementation before the patched bundle, so the TUI paints under stock Bun instead of stalling before the first frame |
| **1h Prompt Cache** | Forces 1h TTL allowlist on (was effectively 5m → much higher cache_creation token usage) |
| **Third-Party Cache Fix** | Auto-disables `x-anthropic-billing-header` when `baseURL` is non-Anthropic. The header's per-request `cch` field breaks prompt-cache hit rate on DeepSeek / OneAPI / Bedrock / vLLM and any other Anthropic-compatible proxy. You no longer need to set `CLAUDE_CODE_ATTRIBUTION_HEADER=0` yourself. |
| **Auto Re-patch** | Detects when the user's native Claude binary has been upgraded; transparently re-extracts and re-patches on next launch |
| **Update Notification** | Checks GitHub releases once per 24h (async, non-blocking). Shows a one-line notice if a newer ClawGod version is available |
| **Lean Settings** | Three-level token optimization for `~/.claude/settings.json`. **on** (default): removes unused tool definitions + disables Workflows/Artifact; Remote Control remains available. **max**: additionally disables Remote Control and removes Plan mode, Agent Teams, bundled skills. **off**: all tools restored |
> **Lean Settings** are non-destructive and persist across updates. Toggle anytime: `claude --lean-on` (default) / `claude --lean-max` (aggressive) / `claude --lean-off` (restore all). To opt out of a single setting, set it yourself (e.g. `"disableArtifact": false`).
Remote Control (`/remote-control`, `/rc`) is allowed in **on/off** and disabled by default only in **max**. Updating in **on** mode or running `claude --lean-on` clears the older Lean Remote Control disable setting. Only **max** defaults `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`; explicit environment restrictions are preserved. Remote Control still requires upstream account, authentication, endpoint, and organization eligibility.
## Commands
@@ -97,12 +114,70 @@ claude.orig # Original unpatched version (auto-backed-up)
"baseURL": "https://api.anthropic.com",
"model": "",
"smallModel": "",
"effort": "",
"timeoutMs": 3000000
}
```
- **`apiKey` set** → ClawGod injects it as `ANTHROPIC_API_KEY` and isolates from `~/.claude/settings.json`. Works with Anthropic, DeepSeek, and OpenAI-compatible gateways. A non-Anthropic `baseURL` also populates `ANTHROPIC_AUTH_TOKEN` for gateway auth.
- **`apiKey` set** → ClawGod injects it as `ANTHROPIC_API_KEY` and isolates from `~/.claude/settings.json`. The default protocol requires an Anthropic Messages-compatible endpoint. For Chat Completions gateways, use the configuration below. A non-Anthropic `baseURL` populates only `ANTHROPIC_AUTH_TOKEN` for gateway auth.
- **`apiKey` empty** → OAuth path. Run `claude auth login` once; `~/.claude` keeps hosting your subagents, skills, and MCP settings.
- **`effort`** → Sets reasoning effort; an existing `CLAUDE_CODE_EFFORT_LEVEL` takes precedence. With `protocol: "openai-chat"`, `type: "grok"` or `"openai-compat"`, the proxy sends `reasoning_effort` even when Claude omits effort for a custom model alias. `low`, `medium`, `high`, and `xhigh` pass through; `max` maps to `xhigh`; `auto` omits the parameter to use the upstream default. Choose a level supported by your upstream model. Empty/unset configuration leaves request-level effort in control and adds no effort parameter when the request has none.
### OpenAI Chat Completions endpoints
For an endpoint that exposes `/v1/chat/completions`, configure:
```json
{
"protocol": "openai-chat",
"apiKey": "sk-...",
"baseURL": "https://example.com/v1",
"model": "your-upstream-model",
"smallModel": "your-upstream-model"
}
```
`baseURL` is the API base (including `/v1` when required), **not** the full `/chat/completions` URL. Set the model names to IDs accepted by your provider. ClawGod starts a loopback proxy inside the launcher process; no external gateway or separate service is required. `timeoutMs` also covers the upstream request and stream; an existing `API_TIMEOUT_MS` takes precedence.
- Omitting `protocol` keeps the existing behavior. `type: "openai-compat"` and `type: "grok"` remain supported; an explicit `protocol` takes precedence. `protocol: "anthropic"` uses Messages directly. Grok defaults to `https://api.x.ai/v1` and retains its settings/environment API key fallback. Other Chat providers require an explicit API base and key.
- Supports text, system prompts, tool calls/results, forced tool selection and parallel-tool controls, streaming, usage, and base64/URL images. Parallel tool arguments are assembled and validated before their content blocks are emitted; text still streams immediately. Missing upstream usage remains zero rather than a fabricated exact count.
- Base64 PDFs are sent as Chat Completions `file` parts and require file support in the upstream model/API. Text documents are sent as text. PDF URLs, document citations, non-text tool results, server tools, and structured output formats are rejected with a clear error. Historical Anthropic thinking/signature blocks are omitted. Model-specific reasoning features are not losslessly translated.
- `/v1/messages/count_tokens` estimates locally without a billed generation request. The `x-clawgod-token-count: estimate` response header marks the result. It uses UTF-8 text bytes / 3 plus message overhead, 1600 tokens per image, and decoded PDF bytes / 3. This is a rough budget, not the model's tokenizer; PDF byte size does not reflect page count and the estimate cannot guarantee context-window fit.
- Chat `stop` maps to `end_turn` (or `tool_use` when the response contains tool calls): the protocol does not distinguish natural completion from a matched stop sequence. `length`, `tool_calls`, and `content_filter` map to `max_tokens`, `tool_use`, and `refusal`. Upstream HTTP errors retain their status and `Retry-After`; malformed or truncated streams produce an error.
- `/v1/responses` and automatic protocol detection are not supported. Use `openai-chat` only for Chat Completions endpoints.
### Feature Toggles
`~/.clawgod/patches.json` (auto-created empty) switches features off persistently — your choices survive updates and reinstalls. Absent key = on.
```json
{ "theme": false, "geo-neutralize": false }
```
| Feature id | Controls |
|------------|----------|
| `agent-teams` | Agent Teams always enabled |
| `computer-use` | Computer Use unlock |
| `ultraplan` | Ultraplan slash command |
| `ultrareview` | Ultrareview slash command |
| `voice-mode` | Voice Mode |
| `auto-mode` | Auto-mode model selection on third-party APIs |
| `classifier-tuning` | Auto-mode classifier overrides: `CLAWGOD_CLASSIFIER_TIMEOUT_MS` (min deadline; unset = 60-120s by context, v2.1.251+), `CLAWGOD_CLASSIFIER_MODEL`, `CLAWGOD_CLASSIFIER_RETRIES` (unset = 4) |
| `theme` | Green brand/logo color scheme |
| `geo-neutralize` | Geo/proxy steganography neutralization in system prompt |
| `cyber-risk` | Removes CYBER_RISK_INSTRUCTION from system prompt |
| `url-restriction` | Removes URL generation restriction from system prompt |
| `cautious-actions` | Removes "Executing actions with care" section from system prompt |
| `not-logged-in` | Removes "Not logged in" notice |
| `message-filter` | Bypasses non-ant message/attachment filters |
| `bun-ant-shim` | `Bun.ant.CellSegmenter` renderer shim for Claude Code 2.1.271+ (see Reliability) |
For a single launch, set an env var instead — feature id upper-cased, dashes to underscores:
```bash
CLAWGOD_FEATURE_THEME=false claude # green theme off, this run only
CLAWGOD_FEATURE_GEO_NEUTRALIZE=true claude # temporarily re-enable one disabled in patches.json
```
## How it works
@@ -112,8 +187,9 @@ Since `@anthropic-ai/claude-code` v2.1.113, the npm package no longer ships `cli
2. Extracts the embedded `cli.js` source from the `__BUN` segment (Mach-O / ELF / PE)
3. Extracts the embedded `.node` native modules (audio-capture, image-processor, computer-use-*, url-handler) into `~/.clawgod/vendor/`
4. Rewrites `/$bunfs/...` virtual paths to point at the extracted modules
5. Applies 23 regex-based patches (version-agnostic — same patches work across many releases)
5. Applies 29 regex-based patches (version-agnostic — same patches work across many releases)
6. The `claude` / `clawgod` launchers run the patched cli.js under the Bun runtime
7. Loads `bun-ant-shim.cjs` before the patched cli.js, supplying the `Bun.ant.CellSegmenter` renderer API that Claude Code 2.1.271+ expects
A `.source-version` stamp in `~/.clawgod/` records which native version was patched. On every launch the wrapper compares it against the latest binary in `versions/`; if the user upgraded Claude Code via the official installer, ClawGod auto-re-patches on the next run.
@@ -121,6 +197,15 @@ A `.source-version` stamp in `~/.clawgod/` records which native version was patc
**Just run `claude update` as usual.** ClawGod patches the command to route through its own installer, which pulls the current Anthropic release from npm (`@anthropic-ai/claude-code-<plat>@latest`), re-extracts cli.js, re-applies patches, and rewrites the launcher. So the upstream update command keeps working the way you expect — you get the latest Claude, with patches still applied, in one step.
Extra options:
```bash
claude update --version 2.1.180 # Pin to a specific Claude Code version
claude update --no-upgrade # Re-patch the installed Claude version
```
`--version` is useful when a new release has issues and you want to stay on a known-good version. `--no-upgrade` re-applies the latest patches to a complete clean-source backup of the installed version. Older installations without that backup download the same Claude version once to recover it; subsequent re-patches reuse the local backup.
If you'd rather invoke the installer directly (same effect, both paths fetch the same upstream release and re-patch):
**macOS / Linux:**
@@ -162,4 +247,10 @@ GPL-3.0 — Not affiliated with Anthropic. Use at your own risk.
## Star History
[![Star History Chart](https://api.star-history.com/chart?repos=0Chencc/clawgod&type=date&legend=top-left)](https://www.star-history.com/?repos=0Chencc%2Fclawgod&type=date&legend=top-left)
<a href="https://www.star-history.com/?repos=0chencc%2Fclawgod&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=0chencc/clawgod&type=date&theme=dark&legend=top-left&sealed_token=ntGY6im49ymMeD9BXSi0OmH_kyhnnTL9pGyfm2rLYBTlzEcTeQf4o6RA6HqXhGVzdD6xXlk20KCFAyk4gWIpEda3TVEm4re4eJ0xoosRcUdYMui5B7Hp6e3YBUAr2tWmCZu2ZkRWVCOEdCOldK9S_h7Jn7NIjGEEgWywl2ZZOq7xpUpT4IkkXKKxGNJi" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=0chencc/clawgod&type=date&legend=top-left&sealed_token=ntGY6im49ymMeD9BXSi0OmH_kyhnnTL9pGyfm2rLYBTlzEcTeQf4o6RA6HqXhGVzdD6xXlk20KCFAyk4gWIpEda3TVEm4re4eJ0xoosRcUdYMui5B7Hp6e3YBUAr2tWmCZu2ZkRWVCOEdCOldK9S_h7Jn7NIjGEEgWywl2ZZOq7xpUpT4IkkXKKxGNJi" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=0chencc/clawgod&type=date&legend=top-left&sealed_token=ntGY6im49ymMeD9BXSi0OmH_kyhnnTL9pGyfm2rLYBTlzEcTeQf4o6RA6HqXhGVzdD6xXlk20KCFAyk4gWIpEda3TVEm4re4eJ0xoosRcUdYMui5B7Hp6e3YBUAr2tWmCZu2ZkRWVCOEdCOldK9S_h7Jn7NIjGEEgWywl2ZZOq7xpUpT4IkkXKKxGNJi" />
</picture>
</a>
+91 -3
View File
@@ -50,6 +50,7 @@ irm https://github.com/0Chencc/clawgod/releases/latest/download/install.ps1 | ie
| **Agent Teams** | マルチエージェント協調、フラグ不要 |
| **Computer Use** | Max/Proサブスク不要で画面操作(macOS) |
| **Auto-mode** | サードパーティ API ユーザー向け auto-mode のロック解除(firstParty 制限を撤去) |
| **Classifier チューニング** | auto-mode 分類器の調整:タイムアウト / モデル / リトライ回数(`CLAWGOD_CLASSIFIER_TIMEOUT_MS`、`CLAWGOD_CLASSIFIER_MODEL`、`CLAWGOD_CLASSIFIER_RETRIES`) |
| **Ultraplan** | Claude Code Remote 経由のマルチエージェント計画 |
| **Ultrareview** | Claude Code Remote 経由の自動バグ検出 |
@@ -62,6 +63,14 @@ irm https://github.com/0Chencc/clawgod/releases/latest/download/install.ps1 | ie
| **慎重操作** | 破壊的操作前の強制確認 |
| **ログイン通知** | 起動時の「未ログイン」リマインダー |
### 地域ステガノグラフィー無効化
| パッチ | 無効化される内容 |
|--------|-----------------|
| **日付文字列 (qla)** | システムプロンプトが Unicode アポストロフィ変体(U+0027 / U+2019 / U+02BC / U+02B9)と日付区切り文字(CN タイムゾーンでは `-` → `/`)でユーザーの地理情報をエンコード。パッチにより常に ASCII `'` と元の日付形式を使用 |
| **地域検出プローブ (rdp)** | クライアント側の3軸検出:タイムゾーン(`Asia/Shanghai` / `Asia/Urumqi`)、プロキシホスト名の XOR 難読化 100+ ドメインブロックリスト照合、ベース URL 内の中国 LLM ベンダーキーワード。パッチにより常に null を返却 |
| **アポストロフィセレクター (odp)** | 検出結果に基づき4種の Unicode アポストロフィから1つを選択。パッチにより常に ASCII `'` を返却(多層防御) |
### ビジュアル
| パッチ | 効果 |
@@ -73,9 +82,16 @@ irm https://github.com/0Chencc/clawgod/releases/latest/download/install.ps1 | ie
| 機能 | 効果 |
|------|------|
| **Glob/Grep 復元** | Bun コンパイル時に `EMBEDDED_SEARCH_TOOLS=true` がリテラルとしてインライン化され、内蔵の Glob/Grep ツールが非表示になります。パッチにより env チェックを復元し、bfs/ugrep バイナリの可用性検出を追加 — Bun ランタイム実行時にツールが自動復元されます |
| **1h Prompt Cache** | 1h TTL allowlist を強制有効化(デフォルトは実質 5m → アイドル後の cache_creation トークン浪費を防止) |
| **サードパーティ Cache 修正** | `baseURL` が Anthropic 以外を指す場合、`x-anthropic-billing-header` を自動的に無効化します。このヘッダーの `cch` フィールドはリクエストごとに変化するため、DeepSeek / OneAPI / Bedrock / vLLM など Anthropic 互換プロキシでは prompt-cache ヒット率がゼロになります。`CLAUDE_CODE_ATTRIBUTION_HEADER=0` を自分で設定する必要はもうありません。 |
| **自動再パッチ** | ユーザーがネイティブ Claude バイナリをアップグレードすると、次回起動時に自動的に再抽出・再パッチ |
| **アップデート通知** | 24時間ごとに GitHub releases を非同期チェック(ノンブロッキング)。新バージョンが利用可能な場合、起動前に1行の通知を表示 |
| **リーン設定** | `~/.claude/settings.json` の3段階トークン最適化。**on**(デフォルト):未使用ツール定義の削除 + Workflows/Artifact 無効化(Remote Control は利用可能)。**max**:Remote Control を無効化し、Plan mode、Agent Teams、内蔵スキルも追加削除。**off**:全ツール復元 |
> **リーン設定**は既存の設定を壊さず、アップデート後も選択が維持されます。いつでも切替:`claude --lean-on`(デフォルト)/ `claude --lean-max`(アグレッシブ)/ `claude --lean-off`(全復元)。個別設定の解除は自分で値を設定(例:`"disableArtifact": false`)。
Remote Control(`/remote-control`、`/rc`)は **on/off** で利用可能、**max** のみデフォルトで無効です。on モードでの更新または `claude --lean-on` で旧 Lean の無効化設定を解除します。`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` のデフォルト設定も **max** のみ適用し、ユーザーが明示した環境変数は維持します。アカウント、認証、接続先、組織ポリシーの利用条件は引き続き適用されます。
## コマンド
@@ -97,12 +113,69 @@ claude.orig # オリジナル未修正版(自動バックアップ)
"baseURL": "https://api.anthropic.com",
"model": "",
"smallModel": "",
"effort": "",
"timeoutMs": 3000000
}
```
- **`apiKey` を設定**:ClawGod が `ANTHROPIC_API_KEY` として注入し、`~/.claude/settings.json` から隔離します。Anthropic / DeepSeek など OpenAI 互換ゲートウェイでも動作。`baseURL` が Anthropic 以外を指す場合、ゲートウェイ認証用に `ANTHROPIC_AUTH_TOKEN` も自動設定されます。
- **`apiKey` を設定**:ClawGod が `ANTHROPIC_API_KEY` として注入し、`~/.claude/settings.json` から隔離します。既定では Anthropic Messages 互換のエンドポイントが必要です。Chat Completions ゲートウェイには以下の設定を使用します。`baseURL` が Anthropic 以外を指す場合、ゲートウェイ認証用に `ANTHROPIC_AUTH_TOKEN` のみが設定されます。
- **`apiKey` 未設定**:OAuth パス。一度 `claude auth login` を実行すれば、`~/.claude` 配下の subagents / skills / MCP はそのまま使えます。
- **`effort`**:推論の強度を設定します。既存の `CLAUDE_CODE_EFFORT_LEVEL` が優先されます。`protocol: "openai-chat"`、`type: "grok"` または `"openai-compat"` では、Claude がカスタムモデルの effort を送信しなくても、プロキシが設定値を `reasoning_effort` として送信します。`low`、`medium`、`high`、`xhigh` はそのまま、`max` は `xhigh` に変換し、`auto` はパラメーターを省略して上流の既定値を使います。上流モデルが対応する値を選んでください。設定が空または未設定ならリクエストの effort を使い、リクエストにもなければ追加しません。
### OpenAI Chat Completions エンドポイント
上流が `/v1/chat/completions` を提供する場合は、次のように設定します。
```json
{
"protocol": "openai-chat",
"apiKey": "sk-...",
"baseURL": "https://example.com/v1",
"model": "上流のモデル名",
"smallModel": "上流のモデル名"
}
```
`baseURL` は API のベース URL(必要なら `/v1` を含む)です。完全な `/chat/completions` パスは指定しません。モデル名には上流が受け付ける ID を指定してください。変換プロキシはランチャーと同じプロセスで動作し、外部ゲートウェイや別サービスは不要です。`timeoutMs` は上流リクエストとストリームにも適用され、既存の `API_TIMEOUT_MS` が優先されます。
- `protocol` を省略すると従来の動作を維持します。`type: "openai-compat"` と `type: "grok"` も引き続き利用でき、明示した `protocol` が優先されます。`protocol: "anthropic"` は Messages を直接使用します。Grok は `https://api.x.ai/v1` が既定で、設定ファイル・環境変数からのキー取得も維持します。他の Chat プロバイダーには API ベース URL とキーが必要です。
- テキスト、system、ツール呼び出しと結果、ツール選択・並列実行の制御、ストリーミング、usage、base64/URL 画像に対応します。並列ツール引数は組み立て・検証後に内容ブロックを送信し、テキストは即時に配信します。上流から usage が届かない場合はゼロのままです。
- base64 PDF は Chat Completions の `file` に変換するため、上流モデル/API のファイル対応が必要です。テキスト文書はテキストへ変換します。PDF URL、文書の引用、非テキストのツール結果、サーバーツール、構造化出力形式には明示的なエラーを返します。過去の Anthropic thinking/signature ブロックは省略され、モデル固有の推論機能は完全には変換できません。
- `/v1/messages/count_tokens` はローカル推定で、課金される生成リクエストを追加しません。応答ヘッダー `x-clawgod-token-count: estimate` で推定と示します。テキストの UTF-8 バイト数 / 3 とメッセージ分の加算、画像ごとに 1600 token、PDF は復号後バイト数 / 3 を使います。モデルの tokenizer ではなく、PDF のバイト数もページ数とは異なるため、コンテキスト上限内に収まる保証はありません。
- Chat の `stop` は自然終了と停止シーケンスを区別しないため、`end_turn` に変換します(ツール呼び出しを含む応答では `tool_use`)。`length`、`tool_calls`、`content_filter` はそれぞれ `max_tokens`、`tool_use`、`refusal` です。HTTP エラーの状態コードと `Retry-After` を保持し、壊れた・途中で切れたストリームはエラーになります。
- `/v1/responses` と自動プロトコル検出は未対応です。`openai-chat` は Chat Completions エンドポイントにのみ使用してください。
### 機能トグル
`~/.clawgod/patches.json`(初回インストール時に自動生成)で機能を恒久的にオフにできます。設定はアップデート・再インストール後も保持されます。記載のない key はデフォルトでオンです。
```json
{ "theme": false, "geo-neutralize": false }
```
| Feature id | 対象 |
|------------|------|
| `agent-teams` | Agent Teams 常時有効 |
| `computer-use` | Computer Use アンロック |
| `ultraplan` | Ultraplan コマンド |
| `ultrareview` | Ultrareview コマンド |
| `voice-mode` | Voice Mode |
| `auto-mode` | サードパーティ API での auto-mode モデル選択 |
| `classifier-tuning` | auto-mode 分類器の上書き:`CLAWGOD_CLASSIFIER_TIMEOUT_MS`(deadline の下限;未設定=コンテキストにより 60-120s、v2.1.251+ 必要)、`CLAWGOD_CLASSIFIER_MODEL`、`CLAWGOD_CLASSIFIER_RETRIES`(未設定=4) |
| `theme` | 緑色ブランド/ロゴ配色 |
| `geo-neutralize` | system prompt の地域/プロキシステガノグラフィ中和 |
| `cyber-risk` | system prompt から CYBER_RISK_INSTRUCTION を削除 |
| `url-restriction` | URL 生成制限を削除 |
| `cautious-actions` | "Executing actions with care" セクションを削除 |
| `not-logged-in` | "Not logged in" 通知を削除 |
| `message-filter` | 非 ant ユーザ向けメッセージ/添付フィルタを回避 |
単一起動のみの指定は環境変数で — feature id を大文字化、ハイフンはアンダースコアに:
```bash
CLAWGOD_FEATURE_THEME=false claude # この起動のみ緑テーマをオフ
CLAWGOD_FEATURE_GEO_NEUTRALIZE=true claude # patches.json でオフにした機能を一時的に戻す
```
## 仕組み
@@ -112,7 +185,7 @@ claude.orig # オリジナル未修正版(自動バックアップ)
2. `__BUN` セグメント(Mach-O / ELF / PE)から埋め込まれた `cli.js` ソースを抽出
3. 埋め込まれた `.node` ネイティブモジュール(audio-capture、image-processor、computer-use-*、url-handler)を `~/.clawgod/vendor/` に抽出
4. `/$bunfs/...` 仮想パスをローカル vendor パスに書き換え
5. 23 個の正規表現パッチを適用(バージョン横断的——同じ regex 群で複数リリースをカバー)
5. 29 個の正規表現パッチを適用(バージョン横断的——同じ regex 群で複数リリースをカバー)
6. `claude` / `clawgod` ランチャが Bun ランタイムでパッチ済み cli.js を実行
`~/.clawgod/.source-version` がパッチ時のバージョンを記録します。起動毎に wrapper がそれと `versions/` の最新バイナリを比較し、ユーザが公式手段で Claude Code をアップグレードした場合は次回起動時に自動再パッチが走ります。
@@ -121,6 +194,15 @@ claude.orig # オリジナル未修正版(自動バックアップ)
**そのまま `claude update` を実行するだけで OK です。** ClawGod はこのコマンドを自身のインストーラへ流すようパッチしており、npm から Anthropic の現行リリース(`@anthropic-ai/claude-code-<plat>@latest`)を取得し、cli.js を再抽出、パッチを再適用、launcher を書き直します。そのため上流の `claude update` コマンドは期待通りに動作します——1 コマンドで最新の Claude を取得し、パッチも適用された状態を保てます。
追加オプション:
```bash
claude update --version 2.1.180 # 特定の Claude Code バージョンに固定
claude update --no-upgrade # インストール済みの Claude バージョンに再パッチ
```
`--version` は新リリースに問題がある場合、動作確認済みバージョンに留まりたいときに便利です。`--no-upgrade` はインストール済みバージョンの未変更ソース全体のバックアップから最新のパッチを再適用します。バックアップのない旧インストールでは、同じ Claude バージョンを一度だけダウンロードして復元します。以降はローカルのバックアップを再利用します。
直接インストーラを実行したい場合(効果は同じで、どちらも同じ上流リリースを取得してパッチを当て直します):
**macOS / Linux:**
@@ -162,4 +244,10 @@ GPL-3.0 — Anthropic とは無関係です。自己責任でご使用くださ
## Star History
[![Star History Chart](https://api.star-history.com/chart?repos=0Chencc/clawgod&type=date&legend=top-left)](https://www.star-history.com/?repos=0Chencc%2Fclawgod&type=date&legend=top-left)
<a href="https://www.star-history.com/?repos=0chencc%2Fclawgod&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=0chencc/clawgod&type=date&theme=dark&legend=top-left&sealed_token=ntGY6im49ymMeD9BXSi0OmH_kyhnnTL9pGyfm2rLYBTlzEcTeQf4o6RA6HqXhGVzdD6xXlk20KCFAyk4gWIpEda3TVEm4re4eJ0xoosRcUdYMui5B7Hp6e3YBUAr2tWmCZu2ZkRWVCOEdCOldK9S_h7Jn7NIjGEEgWywl2ZZOq7xpUpT4IkkXKKxGNJi" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=0chencc/clawgod&type=date&legend=top-left&sealed_token=ntGY6im49ymMeD9BXSi0OmH_kyhnnTL9pGyfm2rLYBTlzEcTeQf4o6RA6HqXhGVzdD6xXlk20KCFAyk4gWIpEda3TVEm4re4eJ0xoosRcUdYMui5B7Hp6e3YBUAr2tWmCZu2ZkRWVCOEdCOldK9S_h7Jn7NIjGEEgWywl2ZZOq7xpUpT4IkkXKKxGNJi" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=0chencc/clawgod&type=date&legend=top-left&sealed_token=ntGY6im49ymMeD9BXSi0OmH_kyhnnTL9pGyfm2rLYBTlzEcTeQf4o6RA6HqXhGVzdD6xXlk20KCFAyk4gWIpEda3TVEm4re4eJ0xoosRcUdYMui5B7Hp6e3YBUAr2tWmCZu2ZkRWVCOEdCOldK9S_h7Jn7NIjGEEgWywl2ZZOq7xpUpT4IkkXKKxGNJi" />
</picture>
</a>
+94 -3
View File
@@ -50,6 +50,7 @@ irm https://github.com/0Chencc/clawgod/releases/latest/download/install.ps1 | ie
| **Agent Teams** | 多智能体协作,无需额外参数 |
| **Computer Use** | 无需 Max/Pro 订阅即可使用屏幕控制(macOS) |
| **Auto-mode** | 解锁第三方 API 用户的 auto-mode(移除 firstParty 限制) |
| **Classifier 调优** | Auto-mode 分类器可调:超时 / 模型 / 重试次数(`CLAWGOD_CLASSIFIER_TIMEOUT_MS`、`CLAWGOD_CLASSIFIER_MODEL`、`CLAWGOD_CLASSIFIER_RETRIES`) |
| **Ultraplan** | 通过 Claude Code Remote 进行多智能体规划 |
| **Ultrareview** | 通过 Claude Code Remote 自动化 Bug 查找 |
@@ -62,6 +63,14 @@ irm https://github.com/0Chencc/clawgod/releases/latest/download/install.ps1 | ie
| **操作审慎** | 破坏性操作前的强制确认 |
| **登录提示** | 启动时的"未登录"提醒 |
### 地区隐写中和
| 补丁 | 中和内容 |
|------|---------|
| **日期字符串 (qla)** | 系统提示词通过 Unicode 撇号变体(U+0027 / U+2019 / U+02BC / U+02B9)和日期分隔符(中国时区 `-` → `/`)编码用户地理位置。Patch 后始终使用 ASCII `'` 和原始日期格式 |
| **地区检测探针 (rdp)** | 客户端三维检测:时区(`Asia/Shanghai` / `Asia/Urumqi`)、代理域名与 XOR 混淆的 100+ 域名黑名单比对、base URL 中的国产大模型关键词。Patch 后始终返回 null |
| **撇号选择器 (odp)** | 根据检测结果选择四种 Unicode 撇号之一。Patch 后始终返回 ASCII `'`(纵深防御) |
### 视觉
| 补丁 | 效果 |
@@ -73,9 +82,17 @@ irm https://github.com/0Chencc/clawgod/releases/latest/download/install.ps1 | ie
| 功能 | 作用 |
|------|------|
| **Glob/Grep 恢复** | Bun 编译时将 `EMBEDDED_SEARCH_TOOLS=true` 内联为字面量,导致内置 Glob/Grep 工具被隐藏。Patch 还原 env 检查并加入 bfs/ugrep 可用性检测——在 Bun runtime 下运行时工具自动恢复 |
| **渲染器 Shim(2.1.271+)** | Claude Code 2.1.271 的 TUI 渲染器依赖 `Bun.ant.CellSegmenter`,这是 Anthropic 自带 Bun 才有的私有 API。ClawGod 在 patched bundle 前加载一份 JS 实现,让 TUI 在标准 Bun 上正常绘制,而不是在第一帧前卡住 |
| **1h Prompt Cache** | 强制启用 1h TTL allowlist(默认实际是 5m → 空闲后导致大量 cache_creation token 浪费) |
| **第三方 Cache 修复** | 当 `baseURL` 指向非 Anthropic 域名时自动关闭 `x-anthropic-billing-header`。该 header 里的 `cch` 字段每请求都变,会让 DeepSeek / OneAPI / Bedrock / vLLM 以及所有 Anthropic 协议代理的 prompt-cache 命中率归零。不需要再自行配置 `CLAUDE_CODE_ATTRIBUTION_HEADER=0`。 |
| **自动重打补丁** | 检测到用户官方升级了 native Claude binary 时,下次启动自动重新抽取 + 重新 patch |
| **更新通知** | 每 24h 异步检查 GitHub releases(非阻塞),发现新版本时启动前显示一行提示 |
| **精简设置** | 三级 token 优化,作用于 `~/.claude/settings.json`。**on**(默认):移除未使用工具定义 + 禁用 Workflows/Artifact,保留 Remote Control。**max**:额外禁用 Remote Control,并移除 Plan mode、Agent Teams、内置 skills。**off**:恢复全部工具 |
> **精简设置**不会破坏现有配置,更新时保持用户选择。随时切换:`claude --lean-on`(默认)/ `claude --lean-max`(激进)/ `claude --lean-off`(恢复全部)。取消单项设置只需自行修改(如 `"disableArtifact": false`)。
Remote Control(`/remote-control`、`/rc`)在 **on/off** 下允许使用,仅在 **max** 下默认禁用。默认 on 模式更新安装或执行 `claude --lean-on` 会清除旧版 Lean 遗留的远程控制禁用项。仅 **max** 默认设置 `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`;用户显式设置的环境变量限制保持不变。远程控制仍需满足上游账号、认证、端点及组织策略要求。
## 使用
@@ -97,12 +114,70 @@ claude.orig # 原版未修改版本(自动备份)
"baseURL": "https://api.anthropic.com",
"model": "",
"smallModel": "",
"effort": "",
"timeoutMs": 3000000
}
```
- **填写 `apiKey`**:ClawGod 注入 `ANTHROPIC_API_KEY` 并与 `~/.claude/settings.json` 隔离。可用于 Anthropic 官方、DeepSeek,以及任何 OpenAI-compatible 网关;`baseURL` 指向非 Anthropic 域名时,还会自动注入 `ANTHROPIC_AUTH_TOKEN` 以适配网关鉴权。
- **填写 `apiKey`**:ClawGod 注入 `ANTHROPIC_API_KEY` 并与 `~/.claude/settings.json` 隔离。默认要求上游兼容 Anthropic Messages 协议;仅支持 Chat Completions 的网关请使用下方配置。`baseURL` 指向非 Anthropic 域名时,仅注入 `ANTHROPIC_AUTH_TOKEN` 以适配网关鉴权。
- **留空 `apiKey`**:走 OAuth 路径,执行一次 `claude auth login`,`~/.claude` 下的 subagents / skills / MCP 配置继续有效。
- **`effort`**:设置推理强度,已有的 `CLAUDE_CODE_EFFORT_LEVEL` 优先。使用 `protocol: "openai-chat"`、`type: "grok"` 或 `"openai-compat"` 时,即使 Claude 没有为自定义模型发送 effort,代理也会发送配置对应的 `reasoning_effort`。`low`、`medium`、`high`、`xhigh` 原样传递,`max` 转为 `xhigh`,`auto` 则省略该参数、使用上游默认值。请选用上游模型支持的档位。配置为空或未设置时,沿用请求中的 effort;请求也未指定时不添加该参数。
### OpenAI Chat Completions 端点
如果上游提供 `/v1/chat/completions`,请配置:
```json
{
"protocol": "openai-chat",
"apiKey": "sk-...",
"baseURL": "https://example.com/v1",
"model": "上游模型名称",
"smallModel": "上游模型名称"
}
```
`baseURL` 填 API 基础地址(按上游要求包含 `/v1`),**不要**填写完整的 `/chat/completions` 路径。模型名称必须是上游支持的 ID。ClawGod 会在启动器进程内启动本地转换代理,无需部署外部网关或独立服务。`timeoutMs` 同时约束上游请求和响应流;已有的 `API_TIMEOUT_MS` 优先。
- 不填 `protocol` 时保持原有行为。继续兼容 `type: "openai-compat"` 和 `type: "grok"`;显式 `protocol` 优先。`protocol: "anthropic"` 直接使用 Messages 协议。Grok 默认地址为 `https://api.x.ai/v1`,继续支持从 Grok 配置或环境变量读取密钥;其他 Chat 提供商必须填写 API 基础地址和密钥。
- 支持文本、system、工具调用/结果、强制工具选择、并行工具控制、流式响应、usage,以及 base64/URL 图片。并行工具参数会在组装完整并验证后输出内容块;文本仍实时输出。上游缺少 usage 时保持为零,不伪造精确计数。
- base64 PDF 转为 Chat Completions 的 `file` 块,要求上游模型/API 支持文件;文本型文档转为文本。PDF URL、文档引用、非文本工具结果、服务端工具和结构化输出格式会明确报错。历史 Anthropic thinking/signature 块会省略,模型专属推理能力无法无损转换。
- `/v1/messages/count_tokens` 使用本地估算,不额外调用计费接口。响应头 `x-clawgod-token-count: estimate` 标记估算结果。算法为文本 UTF-8 字节数 / 3 加消息开销,每张图片预算 1600 token,PDF 按解码字节数 / 3 估算。这不是模型 tokenizer;PDF 字节数不等于页数,估算不能保证上下文窗口一定容纳得下。
- Chat 的 `stop` 映射为 `end_turn`(响应包含工具调用时映射为 `tool_use`),因为该协议不区分自然结束与命中停止序列。`length`、`tool_calls`、`content_filter` 分别映射为 `max_tokens`、`tool_use`、`refusal`。上游 HTTP 错误保留状态码和 `Retry-After`;损坏或提前截断的流会报错。
- 暂不支持 `/v1/responses` 和自动协议探测;`openai-chat` 仅用于 Chat Completions 端点。
### 功能开关
`~/.clawgod/patches.json`(首次安装自动创建)可持久关闭指定功能,配置在更新和重装后保留。未列出的 key 默认开启。
```json
{ "theme": false, "geo-neutralize": false }
```
| Feature id | 控制内容 |
|------------|----------|
| `agent-teams` | Agent Teams 恒开 |
| `computer-use` | Computer Use 解锁 |
| `ultraplan` | Ultraplan 命令 |
| `ultrareview` | Ultrareview 命令 |
| `voice-mode` | Voice Mode |
| `auto-mode` | 第三方 API 的 auto-mode 模型选择 |
| `classifier-tuning` | Auto-mode 分类器覆盖:`CLAWGOD_CLASSIFIER_TIMEOUT_MS`(deadline 下限;未设置=按上下文 60-120s,需 v2.1.251+)、`CLAWGOD_CLASSIFIER_MODEL`、`CLAWGOD_CLASSIFIER_RETRIES`(未设置=4) |
| `theme` | 绿色品牌/Logo 配色 |
| `geo-neutralize` | 中和 system prompt 中的地区/代理隐写 |
| `cyber-risk` | 移除 system prompt 中的 CYBER_RISK_INSTRUCTION |
| `url-restriction` | 移除 URL 生成限制 |
| `cautious-actions` | 移除 "Executing actions with care" 段落 |
| `not-logged-in` | 移除 "Not logged in" 提示 |
| `message-filter` | 绕过非 ant 用户的消息/附件过滤 |
| `bun-ant-shim` | Claude Code 2.1.271+ 的 `Bun.ant.CellSegmenter` 渲染器 shim(见「可靠性」) |
仅对单次启动生效时用环境变量——feature id 大写、连字符转下划线:
```bash
CLAWGOD_FEATURE_THEME=false claude # 本次运行关闭绿色主题
CLAWGOD_FEATURE_GEO_NEUTRALIZE=true claude # 临时恢复 patches.json 里关闭的功能
```
## 工作原理
@@ -112,8 +187,9 @@ claude.orig # 原版未修改版本(自动备份)
2. 从 `__BUN` segment(Mach-O / ELF / PE)抽出嵌入的 `cli.js` 源码
3. 抽出嵌入的 `.node` 原生模块(audio-capture、image-processor、computer-use-*、url-handler)放到 `~/.clawgod/vendor/`
4. 把 `/$bunfs/...` 虚拟路径重写到本地 vendor 路径
5. 应用 23 条正则 patch(跨版本兼容,同一组 regex 覆盖多个 release)
5. 应用 29 条正则 patch(跨版本兼容,同一组 regex 覆盖多个 release)
6. `claude` / `clawgod` launcher 在 Bun runtime 下跑 patched cli.js
7. 在 patched cli.js 之前加载 `bun-ant-shim.cjs`,补上 Claude Code 2.1.271+ 需要的 `Bun.ant.CellSegmenter` 渲染接口
`~/.clawgod/.source-version` 标记当时被 patch 的版本号。每次启动 wrapper 比对它和 `versions/` 里最新二进制;如果用户走官方途径升级了 Claude Code,下次启动会自动重打补丁。
@@ -121,6 +197,15 @@ claude.orig # 原版未修改版本(自动备份)
**直接照常跑 `claude update` 即可。** ClawGod 把这条命令 patch 成走自己的 installer——从 npm 拉 Anthropic 当前发布(`@anthropic-ai/claude-code-<plat>@latest`)、重新提取 cli.js、重新打补丁、重写 launcher。所以上游 `claude update` 命令对用户依然如常工作——一条命令拿到最新 Claude + 补丁仍然生效。
额外选项:
```bash
claude update --version 2.1.180 # 锁定到指定 Claude Code 版本
claude update --no-upgrade # 不下载新版,只用最新 patcher 重新打补丁
```
`--version` 适用于新版有问题、想停留在已知稳定版本时。`--no-upgrade` 使用当前版本的完整干净源码备份重新应用最新补丁。旧安装若缺少这份备份,会下载一次相同版本来恢复源码,不会升级 Claude;之后重复打补丁直接复用本地备份。
如果你想直接调 installer(效果一样,两条路径都会拉同一个上游 release 并重新 patch):
**macOS / Linux:**
@@ -162,4 +247,10 @@ GPL-3.0 — 与 Anthropic 无关,风险自负。
## Star History
[![Star History Chart](https://api.star-history.com/chart?repos=0Chencc/clawgod&type=date&legend=top-left)](https://www.star-history.com/?repos=0Chencc%2Fclawgod&type=date&legend=top-left)
<a href="https://www.star-history.com/?repos=0chencc%2Fclawgod&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=0chencc/clawgod&type=date&theme=dark&legend=top-left&sealed_token=ntGY6im49ymMeD9BXSi0OmH_kyhnnTL9pGyfm2rLYBTlzEcTeQf4o6RA6HqXhGVzdD6xXlk20KCFAyk4gWIpEda3TVEm4re4eJ0xoosRcUdYMui5B7Hp6e3YBUAr2tWmCZu2ZkRWVCOEdCOldK9S_h7Jn7NIjGEEgWywl2ZZOq7xpUpT4IkkXKKxGNJi" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=0chencc/clawgod&type=date&legend=top-left&sealed_token=ntGY6im49ymMeD9BXSi0OmH_kyhnnTL9pGyfm2rLYBTlzEcTeQf4o6RA6HqXhGVzdD6xXlk20KCFAyk4gWIpEda3TVEm4re4eJ0xoosRcUdYMui5B7Hp6e3YBUAr2tWmCZu2ZkRWVCOEdCOldK9S_h7Jn7NIjGEEgWywl2ZZOq7xpUpT4IkkXKKxGNJi" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=0chencc/clawgod&type=date&legend=top-left&sealed_token=ntGY6im49ymMeD9BXSi0OmH_kyhnnTL9pGyfm2rLYBTlzEcTeQf4o6RA6HqXhGVzdD6xXlk20KCFAyk4gWIpEda3TVEm4re4eJ0xoosRcUdYMui5B7Hp6e3YBUAr2tWmCZu2ZkRWVCOEdCOldK9S_h7Jn7NIjGEEgWywl2ZZOq7xpUpT4IkkXKKxGNJi" />
</picture>
</a>
Executable
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env node
const {
chmodSync,
existsSync,
readFileSync,
writeFileSync,
} = require('node:fs');
const { join } = require('node:path');
const { spawnSync } = require('node:child_process');
const ROOT = __dirname;
const SOURCE_ROOT = join(ROOT, 'src');
const PLACEHOLDER_RE = /{{CLAWGOD:[^}]+}}/g;
const identity = (content) => content;
// Windows PowerShell 5.1 treats UTF-8 without a BOM as the active ANSI code
// page, while `irm ... | iex` exposes a UTF-8 BOM as a literal U+FEFF token.
// Keep the generated installer ASCII-only and encode Unicode in embedded
// JavaScript/JSON as UTF-16 escape sequences, which both grammars understand.
const escapeNonAscii = (content) => content.replace(
/[^\x00-\x7F]/g,
(character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`,
);
// PowerShell's single-quoted here-string cannot safely embed the annotated
// proxy source used by install.sh. The existing Windows artifact intentionally
// omits its header comments and blank separator lines; derive that compact form
// from the shared implementation instead of maintaining a second proxy.
const compactProxyForPowerShell = (content) => content
.split('\n')
.filter((line) => line !== '')
.filter((line) => !line.startsWith('// Anthropic Messages API'))
.filter((line) => !line.startsWith('// Allows Claude Code'))
.join('\n');
const compactAndEscapeProxyForPowerShell = (content) => escapeNonAscii(
compactProxyForPowerShell(content),
);
// Feature registry compiled into the wrappers. `patch.mjs --dump-features`
// is the single source of truth (FEATURES in patch.mjs); the wrapper gets a
// machine-generated META constant (patch id → owning feature ids) that its
// startup block uses to compute per-patch gates from patches.json +
// CLAWGOD_FEATURE_* env overrides. Marker line marks the weave point.
const FEATURES_META_MARKER = '// {{CLAWGOD:FEATURES_META}}';
function featuresMeta() {
const result = spawnSync(process.execPath, [join(SOURCE_ROOT, 'shared/patch.mjs'), '--dump-features'], {
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(`patch.mjs --dump-features failed: ${result.stderr}`);
}
return JSON.parse(result.stdout);
}
function weaveFeaturesMeta(content) {
if (!content.includes(FEATURES_META_MARKER)) {
throw new Error(`wrapper source missing ${FEATURES_META_MARKER} marker`);
}
const meta = featuresMeta();
const rendered = `const CLAWGOD_FEATURES_META = ${JSON.stringify(meta, null, 2)};`;
return content.replace(FEATURES_META_MARKER, () => rendered);
}
const TARGETS = [
{
name: 'install.sh',
template: 'templates/install.sh',
mode: 0o755,
sources: {
'extract-natives.mjs': ['shared/extract-natives.mjs', identity],
'post-process.mjs': ['shared/post-process.mjs', identity],
'repatch.mjs': ['shared/repatch.mjs', identity],
'openai-proxy.cjs': ['shared/openai-proxy.cjs', identity],
'cli.cjs': ['shared/cli.cjs', identity],
'startup-check.cjs': ['shared/startup-check.cjs', identity],
'runtime-helpers.cjs': ['shared/runtime-helpers.cjs', identity],
'bun-ant-shim.cjs': ['shared/bun-ant-shim.cjs', identity],
'feature-gates.cjs': ['shared/feature-gates.cjs', weaveFeaturesMeta],
'patch.mjs': ['shared/patch.mjs', identity],
'features.json': ['shared/features.json', identity],
},
},
{
name: 'install.ps1',
template: 'templates/install.ps1',
asciiOnly: true,
sources: {
'npm-fetch.mjs': ['windows/npm-fetch.mjs', escapeNonAscii],
'extract-natives.mjs': ['shared/extract-natives.mjs', escapeNonAscii],
'post-process.mjs': ['shared/post-process.mjs', escapeNonAscii],
'repatch.mjs': ['shared/repatch.mjs', escapeNonAscii],
'openai-proxy.cjs': ['shared/openai-proxy.cjs', compactAndEscapeProxyForPowerShell],
'cli.cjs': ['shared/cli.cjs', escapeNonAscii],
'startup-check.cjs': ['shared/startup-check.cjs', escapeNonAscii],
'runtime-helpers.cjs': ['shared/runtime-helpers.cjs', escapeNonAscii],
'bun-ant-shim.cjs': ['shared/bun-ant-shim.cjs', escapeNonAscii],
'feature-gates.cjs': ['shared/feature-gates.cjs', (content) => escapeNonAscii(weaveFeaturesMeta(content))],
'patch.mjs': ['shared/patch.mjs', escapeNonAscii],
'features.json': ['shared/features.json', escapeNonAscii],
'lean-remove.cjs': ['windows/lean-remove.cjs', escapeNonAscii],
'lean-apply.cjs': ['windows/lean-apply.cjs', escapeNonAscii],
},
},
];
function readSource(relativePath) {
const path = join(SOURCE_ROOT, relativePath);
if (!existsSync(path)) throw new Error(`Missing build source: ${relativePath}`);
const content = readFileSync(path, 'utf8');
if (!content.endsWith('\n')) {
throw new Error(`Build source must end with a newline: ${relativePath}`);
}
return content.slice(0, -1);
}
function render(target) {
const templatePath = join(SOURCE_ROOT, target.template);
if (!existsSync(templatePath)) throw new Error(`Missing template: ${target.template}`);
let output = readFileSync(templatePath, 'utf8').replace(/^\uFEFF/, '');
for (const [name, [sourcePath, transform]] of Object.entries(target.sources)) {
const placeholder = `{{CLAWGOD:${name}}}`;
const matches = output.split(placeholder).length - 1;
if (matches !== 1) {
throw new Error(`${target.template}: expected one ${placeholder}, found ${matches}`);
}
const source = transform(readSource(sourcePath));
output = output.replace(placeholder, () => source);
}
const unresolved = output.match(PLACEHOLDER_RE);
if (unresolved) {
throw new Error(`${target.template}: unresolved placeholders: ${[...new Set(unresolved)].join(', ')}`);
}
if (target.asciiOnly) {
const nonAsciiIndex = output.search(/[^\x00-\x7F]/);
if (nonAsciiIndex !== -1) {
const codePoint = output.codePointAt(nonAsciiIndex).toString(16).toUpperCase();
throw new Error(`${target.template}: non-ASCII U+${codePoint} at character ${nonAsciiIndex}`);
}
}
return output;
}
function firstDifference(actual, expected) {
const length = Math.min(actual.length, expected.length);
for (let index = 0; index < length; index++) {
if (actual[index] !== expected[index]) return index;
}
return actual.length === expected.length ? -1 : length;
}
function checkTarget(target, expected) {
const outputPath = join(ROOT, target.name);
if (!existsSync(outputPath)) {
console.error(`out of date: ${target.name} is missing`);
return false;
}
const actual = readFileSync(outputPath, 'utf8');
if (actual === expected) {
console.log(`ok: ${target.name}`);
return true;
}
const offset = firstDifference(actual, expected);
console.error(`out of date: ${target.name} (first difference at character ${offset})`);
return false;
}
function main() {
const check = process.argv.includes('--check');
const unknown = process.argv.slice(2).filter((arg) => arg !== '--check');
if (unknown.length > 0) {
console.error(`Unknown argument(s): ${unknown.join(' ')}`);
process.exit(2);
}
let success = true;
for (const target of TARGETS) {
const content = render(target);
if (check) {
success = checkTarget(target, content) && success;
continue;
}
const outputPath = join(ROOT, target.name);
writeFileSync(outputPath, content, 'utf8');
if (target.mode) chmodSync(outputPath, target.mode);
console.log(`built: ${target.name} (${Buffer.byteLength(content)} bytes)`);
}
if (!success) process.exit(1);
}
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
+31 -3
View File
@@ -131,7 +131,19 @@
<section class="section">
<header class="section-head">
<h2><span class="num">03</span>Visual</h2>
<h2><span class="num">03</span>Geo-steganography neutralization</h2>
<p>Client-side location fingerprinting removed at patch time.</p>
</header>
<div class="patches-grid">
<article class="patch-card"><span class="badge remove">Remove</span><div class="name">Date string stego (qla)</div><div class="desc">Unicode apostrophe variants + date separator encode user location into the system prompt. Patched to always use ASCII.</div></article>
<article class="patch-card"><span class="badge remove">Remove</span><div class="name">Geo-detection probe (rdp)</div><div class="desc">Timezone check (Asia/Shanghai), XOR-obfuscated 100+ domain blocklist, CN-LLM vendor keywords. Forced to return null.</div></article>
<article class="patch-card"><span class="badge remove">Remove</span><div class="name">Apostrophe selector (odp)</div><div class="desc">Four Unicode apostrophes (U+0027/2019/02BC/02B9) encode proxy detection state. Forced to ASCII. Defense-in-depth.</div></article>
</div>
</section>
<section class="section">
<header class="section-head">
<h2><span class="num">04</span>Visual</h2>
<p>A single signal that you are running the patched build.</p>
</header>
<div class="patches-grid">
@@ -142,11 +154,27 @@
<section class="section">
<header class="section-head">
<h2><span class="num">04</span>Routing</h2>
<h2><span class="num">05</span>Reliability</h2>
<p>Token savings, cache optimization, and self-maintenance.</p>
</header>
<div class="patches-grid">
<article class="patch-card"><span class="badge route">Optimize</span><div class="name">Glob/Grep restore</div><div class="desc">Bun compile inlines <code>EMBEDDED_SEARCH_TOOLS=true</code>, hiding built-in tools. Patch un-inlines the env check + adds bfs/ugrep detection.</div></article>
<article class="patch-card"><span class="badge route">Optimize</span><div class="name">1h prompt cache</div><div class="desc">Forces 1h TTL allowlist — default 5m wastes tokens on cache_creation after idle.</div></article>
<article class="patch-card"><span class="badge route">Optimize</span><div class="name">Third-party cache fix</div><div class="desc">Auto-disables <code>x-anthropic-billing-header</code> on non-Anthropic proxies. Prevents zero cache-hit rate on DeepSeek / OneAPI / vLLM.</div></article>
<article class="patch-card"><span class="badge route">Optimize</span><div class="name">Lean settings (3-level)</div><div class="desc">Token optimization for <code>settings.json</code>. <strong>on</strong> (default): removes unused tools + disables Workflows/Artifact; keeps Remote Control available. <strong>max</strong>: also disables Remote Control and removes Plan mode, Agent Teams, bundled skills. <strong>off</strong>: all restored. Toggle: <code>claude --lean-on</code> / <code>--lean-max</code> / <code>--lean-off</code>.</div></article>
<article class="patch-card"><span class="badge route">Optimize</span><div class="name">Update notification</div><div class="desc">Checks GitHub releases once per 24h (async, non-blocking). One-line notice on startup if a newer version is available.</div></article>
<article class="patch-card"><span class="badge route">Optimize</span><div class="name">Auto re-patch</div><div class="desc">Detects when the native Claude binary has been upgraded; transparently re-extracts and re-patches on next launch.</div></article>
</div>
</section>
<section class="section">
<header class="section-head">
<h2><span class="num">06</span>Routing</h2>
<p>Upgrade flow that survives Anthropic's bun-runtime swaps.</p>
</header>
<div class="patches-grid">
<article class="patch-card"><span class="badge route">Routing</span><div class="name">claude update redirect</div><div class="desc"><code>claude update</code> routes through clawgod's installer — pulls latest Anthropic release + re-patches in one step.</div></article>
<article class="patch-card"><span class="badge route">Routing</span><div class="name">claude update redirect</div><div class="desc"><code>claude update</code> routes through clawgod's installer — pulls latest Anthropic release + re-patches in one step. Supports <code>--version</code> and <code>--no-upgrade</code>.</div></article>
<article class="patch-card"><span class="badge route">Routing</span><div class="name">Shell integration fix</div><div class="desc">Redirects multitool dispatch (find/grep/rg) to <code>claude.orig</code> binary — shell scripts can't preserve argv[0].</div></article>
</div>
</section>
+2871 -587
View File
File diff suppressed because it is too large Load Diff
+2711 -533
View File
File diff suppressed because it is too large Load Diff
+161
View File
@@ -0,0 +1,161 @@
# Installer build sources
`install.sh` and `install.ps1` are generated, committed release artifacts.
Edit the files under this directory, then rebuild from the repository root:
```bash
node build.js
node build.js --check
```
The build must remain byte-for-byte reproducible. CI runs `--check` and fails
when either generated installer is missing or differs from its sources.
`.gitattributes` pins the complete build graph to LF on every platform.
## Testing
`src/shared/patch.test.mjs` tests the classifier-timeout helper in
`runtime-helpers.cjs` and runs the patcher on Ultraplan fixtures for legacy
bundles and chunk graphs. It checks runtime toggles, metadata preservation,
and rejection of unsupported shapes without changing neighboring commands.
No Claude bundle is needed. Run locally with Node:
```bash
node src/shared/patch.test.mjs
```
`src/shared/bun-ant-shim.test.mjs` tests the `Bun.ant.CellSegmenter`
implementation in `bun-ant-shim.cjs`: escape scanning, grapheme widths, style
runs, hyperlink runs, capacity reporting, and the cell/damage packing that the
patched bundle reads back. It runs under plain Node as well, because the shim
falls back to a local width table when `Bun.stringWidth` is unavailable:
```bash
node src/shared/bun-ant-shim.test.mjs
```
`src/shared/terminal-reply.test.mjs` checks the incomplete DA1 reply timer,
normal keyboard/paste fallback, its two-second timeout, and the actual patch
composition for legacy bundles and chunk graphs:
```bash
node src/shared/terminal-reply.test.mjs
```
While a terminal probe is pending, a lone Escape or Alt+[ may wait up to two
seconds for a reply continuation before the original parser handles it. With
no outstanding probe, the original input timing is unchanged.
`src/shared/lean.test.mjs` exercises the full launcher with isolated settings,
plus the Unix and Windows installer settings scripts. It covers on/max/off
transitions, old Remote Control settings migration, PowerShell Boolean casing,
provider parity, and preservation of explicit network environment settings:
```bash
node src/shared/lean.test.mjs
```
`src/shared/provider.test.mjs` runs the launcher and proxy request handler with
isolated configuration and mocked HTTP transport. It checks provider authentication,
blank-token fallback, effort translation and environment precedence, including
streaming requests and custom model aliases that omit `output_config`:
```bash
node src/shared/provider.test.mjs
```
`src/shared/openai-proxy.test.mjs` exercises request/response translation,
including inline system messages alongside the top-level system prompt,
tool selection and parallel calls, images/PDFs and unsupported content errors,
local token estimates, HTTP failures, SSE framing and fragmented UTF-8,
usage trailers, incomplete streams, cancellation, and timeouts. The provider
suite also checks protocol selection, legacy aliases, and configuration errors.
```bash
node --test src/shared/openai-proxy.test.mjs
bun test src/shared/openai-proxy.test.mjs src/shared/openai-proxy.integration.test.mjs
```
The integration suite runs a real Bun proxy and loopback HTTP upstream. It
verifies a complete tool call/result round trip, streaming token accounting,
error status/retry headers, local counting without network calls, and stream
timeouts. It is skipped under Node, which cannot run `Bun.serve`. CI runs the
protocol suite under Node and both suites under Bun on Unix and Windows.
These fixtures do not certify compatibility with every third-party model.
CI runs the JavaScript suites in the `build-sources` job, then loads the shim under Bun in the
smoke jobs (`compat-daily.yml`).
`src/ci/tui-smoke.py` tests an installed CLI in a POSIX PTY or Windows ConPTY.
It uses isolated configuration and a local mock API, types a prompt, and
checks the rendered screen for the reply. Install its Python dependencies
from `src/ci/tui-requirements.txt`. Windows CI runs it against both the latest
Claude/Bun canary and Claude 2.1.272/Bun 1.4.2; the pinned case must also
reproduce the missing CellSegmenter error with the shim disabled. Terminal
logs, final screens and results are uploaded as CI artifacts.
`src/shared/updater.test.mjs` checks that every Lean mode disables native
background updates before loading Claude, including inherited false values.
`src/shared/startup-check.test.mjs` checks the installer's bounded startup
probe, version output, errors, closed stdin, paths with spaces/non-ASCII text,
process-tree timeout cleanup, logs, and standalone version-query side effects.
Version detection uses stdout independently of stderr and the truncated log
tail, including split writes and interleaved/noisy diagnostics.
It also runs the real Bun launcher when Bun is on PATH or specified through
`CLAWGOD_TEST_BUN`. `src/windows/startup-check.test.ps1` exercises the actual
PowerShell installer check with successful, failing and hanging child processes.
The smoke jobs preconfigure a dummy Chat provider before the real installation,
retain it through repeated `--no-upgrade` runs and launcher version queries,
and reject any probe that starts a proxy listener. Windows also pins Claude
2.1.285, the version reported in #203, alongside the existing compatibility cases.
Both installers use `startup-check.cjs` (under Node) to run Bun's
`cli.cjs --version` with a 30-second deadline. `CLAWGOD_STARTUP_TIMEOUT_MS`
can raise that deadline on slow machines. Failures preserve the output in
`~/.clawgod/startup-check.log` and abort before replacing launchers; printing a
version without exiting is still a failure. A standalone `--version` or `-v`
loads the patched bundle and runtime helpers without initializing the provider,
migrating settings, or starting the background release check.
`src/windows/launchers.test.ps1` runs the installer's launcher section on real
Windows files, including repeated installs, restored official executables,
running binaries, deletion/rename locks, recovery, and original backups.
Run them with Node and Windows PowerShell 5.1 respectively; CI runs both.
## Layout
`source-backup.json` in the installed directory stores the complete clean
JavaScript source (entry and graph chunks) after extraction/post-processing.
The patcher reads it before applying patches, and writes runtime source only
if no patch failed. Fresh installs replace the snapshot; `--no-upgrade` reuses
it. Older installations without a complete snapshot fetch the exact installed
version once to recover clean source. `--capture-clean-source` is an installer
operation and must only run against freshly extracted, unpatched source.
`node src/shared/source-backup.test.mjs` covers repeated patching, graph and
legacy backups, version/file-set validation, revert, and failure without source
writes, plus Unix migration and failure propagation. The Windows preflight is
covered by `src/windows/source-recovery.test.ps1`. CI also repeats the actual
Unix and Windows installers and compares their patch summaries with the first
install.
- `shared/` contains payloads embedded identically in both installers,
including `cli.cjs` (the launcher/patcher bootstrap shared by Unix and
Windows). `feature-gates.cjs` carries a `{{CLAWGOD:FEATURES_META}}` marker
that build.js replaces with the inverted FEATURES registry from patch.mjs.
`bun-ant-shim.cjs` re-implements the `Bun.ant.CellSegmenter` API that Claude
Code 2.1.271+ renders through, since stock Bun has no `Bun.ant` namespace;
`cli.cjs` loads it before `cli.original.cjs`.
- `windows/` contains genuinely platform-specific payloads (the PowerShell
build applies `escapeNonAscii` per file in build.js).
- `templates/` contain the shell around those payloads and use
`{{CLAWGOD:<installed-name>}}` placeholders.
The PowerShell template and generated installer are intentionally BOM-free and
ASCII-only. This keeps both direct Windows PowerShell 5.1 execution and the
documented `irm ... | iex` path independent of the machine's active code page.
`build.js` escapes Unicode in embedded JavaScript/JSON payloads as `\uXXXX`.
Do not edit the generated installers directly. If an emergency fix starts in a
generated file, port it to the corresponding source/template immediately and
run the build before committing.
+29
View File
@@ -0,0 +1,29 @@
'use strict';
// CI-only fixture: exercise the actual installed Claude graph with a configured
// provider across fresh installs, --no-upgrade and version queries. No API calls
// are needed, and port 9 deliberately cannot serve a completion request.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const { join } = require('node:path');
const { homedir } = require('node:os');
if (process.env.CI !== 'true') throw new Error('This fixture may only modify a disposable CI runner profile');
const dir = join(homedir(), '.clawgod');
const path = join(dir, 'provider.json');
const provider = {
...(process.env.CLAWGOD_VERSION === '2.1.272' ? { type: 'openai-compat' } : { protocol: 'openai-chat' }),
apiKey: 'ci-fixture-not-a-real-key', baseURL: 'http://127.0.0.1:9/v1', model: 'ci-fixture',
};
if (process.argv[2] === 'seed') {
fs.mkdirSync(dir, { recursive: true });
assert.ok(!fs.existsSync(path), 'Never overwrite an existing provider with the CI fixture');
fs.writeFileSync(path, JSON.stringify(provider, null, 2) + '\n');
} else if (process.argv[2] === 'verify') {
assert.deepEqual(JSON.parse(fs.readFileSync(path, 'utf8')), provider, 'Installer must preserve provider settings');
const logs = [join(dir, 'startup-check.log'), ...process.argv.slice(3)];
for (const log of logs) {
const output = fs.readFileSync(log, 'utf8');
assert.doesNotMatch(output, /proxy on port/i, 'Version probes must not start a provider listener');
}
assert.match(fs.readFileSync(logs[0], 'utf8'), /^\d+\.\d+\.\d+[^\r\n]*\(Claude Code\)\s*$/m);
console.log('Installed Claude version check passed with configured provider; settings preserved');
} else throw new Error('Usage: provider-startup-fixture.cjs seed|verify [installer logs...]');
+2
View File
@@ -0,0 +1,2 @@
pyte==0.8.2
pywinpty==3.0.5; sys_platform == 'win32'
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""Exercise an installed CLI in a POSIX PTY or Windows ConPTY, with a local API."""
import argparse
import codecs
import json
import os
from pathlib import Path
import queue
import re
import signal
import subprocess
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pyte
PROMPT = "clawgod-smoke-182"
REPLY = "CLAWGOD_TUI_OK_182"
MISSING_API = "has no Bun.ant.CellSegmenter"
class Terminal:
def __init__(self, command, cwd, env):
self.output = queue.Queue()
if os.name == "nt":
from winpty import PtyProcess
from winpty.enums import Backend
self.process = PtyProcess.spawn(command, cwd=str(cwd), env=env,
dimensions=(36, 120), backend=Backend.ConPTY)
else:
import fcntl
import pty
import struct
import termios
self.fd, slave = pty.openpty()
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 36, 120, 0, 0))
try:
self.process = subprocess.Popen(command, cwd=cwd, env=env, stdin=slave,
stdout=slave, stderr=slave, start_new_session=True)
finally:
os.close(slave)
threading.Thread(target=self._read, daemon=True).start()
def _read(self):
decoder = codecs.getincrementaldecoder("utf-8")("replace")
try:
while True:
if os.name == "nt":
text = self.process.read(65536)
else:
data = os.read(self.fd, 65536)
if not data:
break
text = decoder.decode(data)
if text:
self.output.put(text)
except (EOFError, OSError):
pass
finally:
self.output.put(None)
def write(self, text):
if os.name == "nt":
self.process.write(text)
else:
os.write(self.fd, text.encode())
def close(self):
if os.name == "nt":
# Include the console child if a future launcher adds a process.
subprocess.run(["taskkill", "/PID", str(self.process.pid), "/T", "/F"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
self.process.close(force=True)
else:
try:
os.killpg(self.process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
self.process.wait(timeout=5)
os.close(self.fd)
def run_case(args, name, command, shim, expect_error=False):
home = args.output / (name + "-home")
cwd = home / "project"
cwd.mkdir(parents=True, exist_ok=True)
(home / ".claude.json").write_text(json.dumps({
"hasCompletedOnboarding": True, "lastOnboardingVersion": args.version,
"theme": "dark", "customApiKeyResponses": {"approved": ["ci-local-only"]},
"projects": {path: {"hasTrustDialogAccepted": True, "projectOnboardingSeenCount": 1}
for path in {str(cwd), cwd.as_posix()}},
}), encoding="utf-8")
requests = []
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_args):
pass
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers.get("content-length", 0))))
saw_prompt = PROMPT in json.dumps(body.get("messages", []))
requests.append({"path": self.path, "saw_prompt": saw_prompt})
message = {"id": "msg_ci", "type": "message", "role": "assistant",
"model": body.get("model", "ci-model"),
"content": [{"type": "text", "text": REPLY}],
"stop_reason": "end_turn", "stop_sequence": None,
"usage": {"input_tokens": 10, "output_tokens": 8}}
self.send_response(200)
if "count_tokens" in self.path:
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"input_tokens":10}')
elif body.get("stream"):
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
events = [
{"type": "message_start", "message": {**message, "content": [], "stop_reason": None}},
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": REPLY}},
{"type": "content_block_stop", "index": 0},
{"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None},
"usage": {"output_tokens": 8}},
{"type": "message_stop"},
]
for event in events:
self.wfile.write((f"event: {event['type']}\ndata: {json.dumps(event)}\n\n").encode())
self.wfile.flush()
else:
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(message).encode())
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
# Do not inherit developer credentials, providers, proxy settings or feature toggles.
env = {k: v for k, v in os.environ.items() if k.upper() in {
"PATH", "SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT", "TEMP", "TMP", "LANG",
}}
env.update({
"HOME": str(home), "USERPROFILE": str(home), "TERM": "xterm-256color",
"COLORTERM": "truecolor", "ANTHROPIC_API_KEY": "ci-local-only",
"ANTHROPIC_BASE_URL": f"http://127.0.0.1:{server.server_port}",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", "DISABLE_AUTOUPDATER": "1",
"DISABLE_TELEMETRY": "1", "CLAWGOD_FEATURE_BUN_ANT_SHIM": str(shim).lower(),
})
if args.native:
env["CLAUDE_CODE_EXECPATH"] = str(args.native)
if os.name == "nt":
for key, directory in [("APPDATA", home / "AppData" / "Roaming"),
("LOCALAPPDATA", home / "AppData" / "Local")]:
directory.mkdir(parents=True, exist_ok=True)
env[key] = str(directory)
terminal = None
raw, seen_queries, seen_cursor_queries, typed_at = "", 0, 0, None
submitted, header_seen, passed = False, False, False
screen = pyte.Screen(120, 36)
stream = pyte.Stream(screen)
started = time.monotonic()
reason = "timeout waiting for interactive TUI"
try:
terminal = Terminal(command, cwd, env)
while time.monotonic() - started < args.timeout:
now = time.monotonic()
if typed_at is not None and not submitted and now - typed_at >= 0.2:
terminal.write("\r")
submitted = True
try:
text = terminal.output.get(timeout=0.05)
except queue.Empty:
continue
if text is None:
reason = "process/PTY exited before the expected result"
break
raw += text
stream.feed(text)
visible = "\n".join(screen.display)
if MISSING_API in raw:
passed = expect_error
reason = "expected missing CellSegmenter error" if passed else "renderer API is missing"
break
if "Uncaught exception" in raw:
reason = "unexpected uncaught exception"
break
# Simulate a basic terminal's DA1 reply; ConPTY may answer queries itself.
queries = len(re.findall(r"\x1b\[(?:0)?c", raw))
for _ in range(queries - seen_queries):
terminal.write("\x1b[?61;4;6;22;23;24;28;32;42;52c")
seen_queries = queries
cursor_queries = list(re.finditer(r"\x1b\[(\?)?6n", raw))
for query in cursor_queries[seen_cursor_queries:]:
private = "?" if query.group(1) else ""
terminal.write(f"\x1b[{private}{screen.cursor.y + 1};{screen.cursor.x + 1}R")
seen_cursor_queries = len(cursor_queries)
header_seen |= "Claude Code" in visible and args.version in visible
if not expect_error and header_seen and "❯" in visible and typed_at is None:
terminal.write(PROMPT)
typed_at = now
if not expect_error and submitted and REPLY in visible and any(r["saw_prompt"] for r in requests):
passed, reason = True, "header, keyboard input, local API request and rendered reply verified"
break
finally:
try:
if terminal:
terminal.close()
finally:
server.shutdown()
server.server_close()
(args.output / f"{name}.raw.log").write_text(raw, encoding="utf-8")
(args.output / f"{name}.screen.txt").write_text("\n".join(screen.display), encoding="utf-8")
result = {"case": name, "passed": passed, "reason": reason, "header_seen": header_seen,
"submitted": submitted, "requests": requests, "seconds": round(time.monotonic() - started, 2)}
(args.output / f"{name}.json").write_text(json.dumps(result, indent=2), encoding="utf-8")
print(json.dumps(result), flush=True)
if not passed:
raise RuntimeError(f"{name}: {reason}; see {args.output}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--cli", required=True, type=Path)
parser.add_argument("--bun", required=True, type=Path)
parser.add_argument("--native", type=Path)
parser.add_argument("--version", required=True)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--timeout", type=float, default=60)
parser.add_argument("--expect-missing-without-shim", action="store_true")
args = parser.parse_args()
for key in ["cli", "bun", "native", "output"]:
value = getattr(args, key)
if value is not None:
setattr(args, key, value.resolve())
args.output.mkdir(parents=True, exist_ok=True)
if args.native:
run_case(args, "native", [str(args.native)], False)
if args.expect_missing_without_shim:
run_case(args, "shim-off", [str(args.bun), str(args.cli)], False, expect_error=True)
run_case(args, "shim-on", [str(args.bun), str(args.cli)], True)
if __name__ == "__main__":
main()
+429
View File
@@ -0,0 +1,429 @@
'use strict';
/**
* clawgod — `Bun.ant` runtime shim.
*
* Claude Code 2.1.271 moved its Ink renderer onto `Bun.ant.CellSegmenter`, an
* Anthropic-private Bun API that ships only inside the bundled native binary.
* clawgod runs the extracted JS bundle on the user's own Bun, which has no
* `Bun.ant` namespace, so the renderer threw before the first frame: the TUI
* never painted while the process stayed alive and looked hung (issue #183).
* The native binary (`claude.orig`) was unaffected.
*
* This module implements the API surface the renderer consumes. cli.cjs loads
* it before cli.original.cjs, and it no-ops when the real API is present.
*
* Contract (reverse-engineered from the 2.1.271+ bundle, pinned by
* bun-ant-shim.test.mjs):
*
* new CellSegmenter({ ambiguousIsNarrow, substitute, screen, tabWidth })
* .graphemes / .sgrKeys / .sgrCloseKeys / .uris per-run tables, strings
* .segment(text, cellsOut, runsOut, reordered) -> cell count
* cellsOut: 2 int32 per cell — [graphemeIndex, (run << 10) | tab | width]
* runsOut: 2 int32 per run — [styleId, uriIndex]
* Negative return = required cell capacity (caller reallocates).
* .paint(targetCells, targetWidth, x, y, cells, count, _unused, charMap, words)
* .setCell(targetCells, targetWidth, x, y, charId, packedStyle)
* Both return end * 2^36 + first * 2^20 + end for a nonempty span.
* I0() decodes the upper fields as damage bounds; j0()/xC() return the
* low field as the absolute ending column for soft-wrap metadata.
*
* The `substitute` option (bidi control ranges) needs no handling: the controls
* are zero width already, which is what the option asks for.
*
* The tables are mutated in place: the bundle caches the four arrays once per
* renderer instance. SGR state persists across segment() calls, so a style
* spanning a wrapped line survives.
*
* Toggle: patches.json {"bun-ant-shim": false} or CLAWGOD_FEATURE_BUN_ANT_SHIM=false
*/
const SCREEN_DEFAULTS = {
widthMask: 3, narrow: 0, wide: 1, spacerTail: 2, spacerHead: 3,
emptyCharIndex: 0, spacerCharIndex: 1, tabWidth: 8,
};
const STYLE_SHIFT = 17;
const LINK_SHIFT = 2;
const LINK_MASK = 32767;
const WIDTH_MASK = 255;
const TAB_FLAG = 256;
const RUN_SHIFT = 10;
const DAMAGE_SPAN = 1048576; // 2^20 — field holding the first column
const DAMAGE_END = 68719476736; // 2^36 — field holding the end column
// SGR attributes that occupy one style slot, as param -> [slot, closeParam].
// The bundle's style pool only accepts single-parameter codes, so combined
// sequences are split below.
const SGR_ATTRS = new Map([
[1, ['bold', 22]],
[2, ['dim', 22]],
[3, ['italic', 23]],
[4, ['underline', 24]],
[5, ['blink', 25]],
[6, ['blink-fast', 25]],
[7, ['inverse', 27]],
[8, ['hidden', 28]],
[9, ['strike', 29]],
[21, ['underline-double', 24]],
[53, ['overline', 55]],
]);
// Closing params, as closeParam -> slots it clears (22 and 24 each cover the
// two attributes they close).
const SGR_CLOSES = new Map([
[22, ['bold', 'dim']],
[23, ['italic']],
[24, ['underline', 'underline-double']],
[25, ['blink', 'blink-fast']],
[27, ['inverse']],
[28, ['hidden']],
[29, ['strike']],
[39, ['fg']],
[49, ['bg']],
[55, ['overline']],
[59, ['underline-color']],
]);
// Extended colours (38/48/58) and the 30-37/90-97 fg, 40-47/100-107 bg ranges.
const SGR_EXTENDED = new Map([
[38, ['fg', 39]],
[48, ['bg', 49]],
[58, ['underline-color', 59]],
]);
function slotFor(param) {
const attr = SGR_ATTRS.get(param);
if (attr) return attr;
const extended = SGR_EXTENDED.get(param);
if (extended) return extended;
if ((param >= 30 && param <= 37) || (param >= 90 && param <= 97)) return ['fg', 39];
if ((param >= 40 && param <= 47) || (param >= 100 && param <= 107)) return ['bg', 49];
return null;
}
function sgrCode(params) {
return '\x1b[' + params + 'm';
}
// ─── Grapheme segmentation & width ───────────────────────────
let graphemeSegmenter;
let graphemeSegmenterResolved = false;
function graphemeIterator() {
if (!graphemeSegmenterResolved) {
graphemeSegmenterResolved = true;
try {
graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
} catch {
graphemeSegmenter = null;
}
}
return graphemeSegmenter;
}
// Approximate East Asian Wide/Fullwidth + emoji ranges, used only when
// Bun.stringWidth is unavailable (the CI unit test runs under plain Node).
const FALLBACK_WIDE = /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA000-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1F64F}\u{1F900}-\u{1F9FF}\u{20000}-\u{2FFFD}\u{30000}-\u{3FFFD}]/u;
function fallbackWidth(grapheme) {
if (/^\p{M}+$/u.test(grapheme)) return 0;
for (const ch of grapheme) if (FALLBACK_WIDE.test(ch)) return 2;
return 1;
}
function graphemeWidth(grapheme, ambiguousIsNarrow) {
const bun = typeof Bun === 'object' && Bun !== null ? Bun : undefined;
if (bun && typeof bun.stringWidth === 'function') {
try {
const width = bun.stringWidth(grapheme, { ambiguousIsNarrow: ambiguousIsNarrow === true });
if (Number.isFinite(width) && width >= 0) return width | 0;
} catch {}
}
return fallbackWidth(grapheme);
}
// ─── Escape scanner ─────────────────────────────────────────
// Text, SGR, OSC-8 hyperlinks, and any other escape (dropped — a `write` op
// only carries styling).
const ESCAPE_SCAN = /\x1b\[([0-9;]*)m|\x1b\]8;([^;\x07\x1b]*);([^\x07\x1b]*)(?:\x07|\x1b\\)|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9;?]*[@-~]|\x1b[@-Z\\-_]/g;
function scanChunks(text) {
const chunks = [];
let last = 0;
ESCAPE_SCAN.lastIndex = 0;
for (let match; (match = ESCAPE_SCAN.exec(text)) !== null; ) {
if (match.index > last) chunks.push({ kind: 'text', value: text.slice(last, match.index) });
if (match[1] !== undefined) chunks.push({ kind: 'sgr', value: match[1] });
else if (match[3] !== undefined) chunks.push({ kind: 'osc8', value: match[3] });
last = match.index + match[0].length;
}
if (last < text.length) chunks.push({ kind: 'text', value: text.slice(last) });
return chunks;
}
// ─── CellSegmenter ──────────────────────────────────────────
class CellSegmenter {
constructor(options) {
const opts = options || {};
const screen = opts.screen || {};
this.ambiguousIsNarrow = opts.ambiguousIsNarrow !== false;
this.tabWidth = Number.isFinite(opts.tabWidth) && opts.tabWidth > 0 ? opts.tabWidth | 0 : SCREEN_DEFAULTS.tabWidth;
const kind = (key) => screen[key] ?? SCREEN_DEFAULTS[key];
this.kinds = {
narrow: kind('narrow'), wide: kind('wide'),
spacerTail: kind('spacerTail'), spacerHead: kind('spacerHead'),
};
this.emptyCharIndex = kind('emptyCharIndex');
this.spacerCharIndex = kind('spacerCharIndex');
// Mutated in place — the renderer caches these arrays per instance.
this.graphemes = [' ', ''];
this.sgrKeys = [''];
this.sgrCloseKeys = [''];
this.uris = [''];
this._graphemeIndex = new Map([[' ', 0], ['', 1]]);
this._styleIndex = new Map();
this._uriIndex = new Map();
this._style = new Map();
}
_uriIndexFor(uri) {
let id = this._uriIndex.get(uri);
if (id === undefined) {
id = this.uris.length;
this.uris.push(uri);
this._uriIndex.set(uri, id);
}
return id;
}
_applySgr(params) {
const parts = params.split(';');
for (let i = 0; i < parts.length; i += 1) {
const raw = parts[i];
const param = raw === '' ? 0 : Number(raw);
if (!Number.isFinite(param)) continue;
if (param === 0) {
this._style.clear();
continue;
}
const closes = SGR_CLOSES.get(param);
if (closes) {
for (const slot of closes) this._style.delete(slot);
continue;
}
const extended = SGR_EXTENDED.get(param);
if (extended) {
// 38/48/58 take "5;n" or "2;r;g;b" operands. Consume them here so the
// loop does not read the operands as attribute params.
const mode = parts[i + 1];
let code;
if (mode === '5' && parts[i + 2] !== undefined) {
code = sgrCode(param + ';5;' + parts[i + 2]);
i += 2;
} else if (mode === '2' && parts[i + 4] !== undefined) {
code = sgrCode(param + ';2;' + parts[i + 2] + ';' + parts[i + 3] + ';' + parts[i + 4]);
i += 4;
} else {
code = sgrCode(param);
}
this._style.set(extended[0], { code, close: sgrCode(extended[1]) });
continue;
}
const slotEntry = slotFor(param);
if (slotEntry) {
this._style.set(slotEntry[0], { code: sgrCode(param), close: sgrCode(slotEntry[1]) });
} else {
// Unknown attribute: keep it as its own slot so the transition to the
// next style still closes it where the terminal understands the code.
this._style.set('param-' + param, { code: sgrCode(param), close: '' });
}
}
}
_styleId() {
if (this._style.size === 0) return 0;
const pairs = [];
for (const entry of this._style.values()) pairs.push(entry.code + '\u0000' + entry.close);
// Sort so the same attribute set reached via different SGR orderings
// (e.g. "1;7" vs "7;1") shares one style id instead of bloating the table.
pairs.sort();
const codes = pairs.map((pair) => pair.slice(0, pair.indexOf('\u0000')));
const closes = pairs.map((pair) => pair.slice(pair.indexOf('\u0000') + 1));
const key = codes.length + '|' + codes.join('\u0000') + '|' + closes.join('\u0000');
let id = this._styleIndex.get(key);
if (id === undefined) {
// IDs must remain stable for this instance: the renderer caches their
// resolved styles, and the current segment() may already reference them.
// The caller owns capacity/generation checks and rebuilds the segmenter
// together with its caches; never truncate these tables independently.
id = this.sgrKeys.length;
this.sgrKeys.push(codes.join('\u0000'));
this.sgrCloseKeys.push(closes.join('\u0000'));
this._styleIndex.set(key, id);
}
return id;
}
_graphemeId(grapheme) {
let id = this._graphemeIndex.get(grapheme);
if (id === undefined) {
id = this.graphemes.length;
this.graphemes.push(grapheme);
this._graphemeIndex.set(grapheme, id);
}
return id;
}
segment(text, cells, runs /* , reordered */) {
const source = typeof text === 'string' ? text : text == null ? '' : String(text);
const items = [];
let link = 0;
// The bundle re-runs segment() with grown arrays when this returns a
// negative count. _applySgr advances the persistent _style map, so a
// capacity-fail run must roll it back or the retry would re-apply the SGR
// codes on top of the already-advanced state and mis-style the leading
// text. _applySgr only set()s fresh objects / delete()s / clear()s, so a
// shallow copy is a faithful snapshot.
const styleSnapshot = new Map(this._style);
const push = (grapheme) => {
const isTab = grapheme === '\t';
items.push({
graphemeIndex: this._graphemeId(grapheme),
tab: isTab,
width: isTab ? 0 : graphemeWidth(grapheme, this.ambiguousIsNarrow),
style: this._styleId(),
link,
});
};
const segmenter = graphemeIterator();
for (const chunk of scanChunks(source)) {
if (chunk.kind === 'sgr') {
this._applySgr(chunk.value);
continue;
}
if (chunk.kind === 'osc8') {
link = chunk.value ? this._uriIndexFor(chunk.value) : 0;
continue;
}
if (segmenter) {
for (const entry of segmenter.segment(chunk.value)) push(entry.segment);
} else {
push(chunk.value);
}
}
const count = items.length;
if (cells.length < count * 2 || runs.length < count * 2) {
this._style = styleSnapshot;
return -count;
}
let runCount = 0;
let previous = null;
for (let i = 0; i < count; i += 1) {
const item = items[i];
if (!previous || previous.style !== item.style || previous.link !== item.link) {
runs[runCount * 2] = item.style;
runs[runCount * 2 + 1] = item.link;
runCount += 1;
previous = item;
}
item.run = runCount - 1;
cells[i * 2] = item.graphemeIndex;
cells[i * 2 + 1] = (item.run << RUN_SHIFT) | (item.tab ? TAB_FLAG : 0) | (item.width & WIDTH_MASK);
}
return count;
}
paint(target, targetWidth, x, y, cells, count, _unused, charMap, words) {
const columns = targetWidth | 0;
const emptyCharId = charMap ? charMap[this.emptyCharIndex] | 0 : this.emptyCharIndex;
const spacerCharId = charMap ? charMap[this.spacerCharIndex] | 0 : this.spacerCharIndex;
const rowBase = (y | 0) * columns;
let column = x | 0;
const first = column;
for (let i = 0; i < (count | 0); i += 1) {
const packed = cells[i * 2 + 1];
const run = packed >>> RUN_SHIFT;
const word = (words ? words[run] : 0) | 0;
const style = (word >>> STYLE_SHIFT) << STYLE_SHIFT;
// runWords() has already removed its cache's +1 sentinel offset.
const link = (word >>> LINK_SHIFT) & LINK_MASK;
const styleBits = style | (link << LINK_SHIFT);
if ((packed & TAB_FLAG) !== 0) {
const stop = this.tabWidth - (((column % this.tabWidth) + this.tabWidth) % this.tabWidth);
for (let k = 0; k < stop; k += 1) this._put(target, rowBase, columns, column + k, emptyCharId, styleBits | this.kinds.narrow);
column += stop;
continue;
}
const width = packed & WIDTH_MASK;
if (width >= 2) {
const charId = charMap ? charMap[cells[i * 2]] | 0 : cells[i * 2] | 0;
this._put(target, rowBase, columns, column, charId, styleBits | this.kinds.wide);
this._put(target, rowBase, columns, column + 1, spacerCharId, styleBits | this.kinds.spacerTail);
column += 2;
} else if (width === 1) {
const charId = charMap ? charMap[cells[i * 2]] | 0 : cells[i * 2] | 0;
this._put(target, rowBase, columns, column, charId, styleBits | this.kinds.narrow);
column += 1;
}
// width 0 (combining mark, bidi control): no cell of its own.
}
return packDamage(first, column);
}
setCell(target, targetWidth, x, y, charId, packedStyle) {
const columns = targetWidth | 0;
const column = x | 0;
const row = y | 0;
if (column < 0 || column >= columns || row < 0) return 0;
this._put(target, row * columns, columns, column, charId, packedStyle);
return packDamage(column, column + 1);
}
_put(target, rowBase, columns, column, charId, packed) {
if (column < 0 || column >= columns) return;
const index = (rowBase + column) << 1;
target[index] = charId;
target[index + 1] = packed;
}
}
function packDamage(first, end) {
if (end <= first) return 0;
return end * DAMAGE_END + first * DAMAGE_SPAN + end;
}
// ─── Installation ───────────────────────────────────────────
function install() {
if (typeof Bun !== 'object' || Bun === null) return false;
if (typeof Bun.ant === 'object' && Bun.ant !== null && typeof Bun.ant.CellSegmenter === 'function') return false;
if (globalThis.__clawgodPatches && globalThis.__clawgodPatches['bun-ant-shim'] === false) return false;
try {
if (typeof Bun.ant !== 'object' || Bun.ant === null) Bun.ant = {};
Bun.ant.CellSegmenter = CellSegmenter;
} catch {
return false;
}
return true;
}
module.exports = { CellSegmenter, install, packDamage, scanChunks };
if (install() && process.env.CLAWGOD_BUN_ANT_SHIM_DEBUG === '1') {
// stderr only (stdout belongs to the TUI renderer), opt-in: the install log
// already names the renderer runtime path on every (re)install.
process.stderr.write('[clawgod] Bun.ant shim active (Claude Code >= 2.1.271 renderer on stock Bun)\n');
}
+465
View File
@@ -0,0 +1,465 @@
#!/usr/bin/env node
// Unit tests for src/shared/bun-ant-shim.cjs — the JS implementation of the
// Bun.ant.CellSegmenter API that Claude Code >= 2.1.271 renders through.
//
// Runs under plain Node (the shim's width lookup falls back when Bun is
// absent), so it needs no Bun and no Claude bundle. Run with:
// node src/shared/bun-ant-shim.test.mjs
import { createRequire } from 'node:module';
import assert from 'node:assert/strict';
const require = createRequire(import.meta.url);
const { CellSegmenter, install, scanChunks } = require('./bun-ant-shim.cjs');
// Constants mirrored from the bundle (see the shim header).
const RUN_SHIFT = 10;
const TAB_FLAG = 256;
const WIDTH_MASK = 255;
const DAMAGE_SPAN = 1048576;
const DAMAGE_END = 68719476736;
const STYLE_SHIFT = 17;
const LINK_SHIFT = 2;
const LINK_MASK = 32767;
const OPTIONS = {
ambiguousIsNarrow: true,
substitute: [[1564, 1564]],
screen: {
widthMask: 3, narrow: 0, wide: 1, spacerTail: 2, spacerHead: 3,
emptyCharIndex: 0, spacerCharIndex: 1, emptyWord: 0, tabWidth: 8,
},
tabWidth: 8,
};
const makeCells = (n = 512) => new Int32Array(n);
const makeRuns = (n = 512) => new Int32Array(n);
const cellWidth = (packed) => (packed & TAB_FLAG) !== 0 ? 'tab' : packed & WIDTH_MASK;
const cellRun = (packed) => packed >>> RUN_SHIFT;
const tests = [];
const test = (name, fn) => tests.push([name, fn]);
// ─── scanChunks ─────────────────────────────────────────────
test('scanChunks splits text, SGR and OSC-8 hyperlinks', () => {
const chunks = scanChunks('a\x1b[1;31mb\x1b]8;;https://example.com\x07c\x1b]8;;\x07d');
assert.deepEqual(chunks, [
{ kind: 'text', value: 'a' },
{ kind: 'sgr', value: '1;31' },
{ kind: 'text', value: 'b' },
{ kind: 'osc8', value: 'https://example.com' },
{ kind: 'text', value: 'c' },
{ kind: 'osc8', value: '' },
{ kind: 'text', value: 'd' },
]);
});
test('scanChunks drops non-SGR escapes and keeps text around them', () => {
const chunks = scanChunks('x\x1b[2Ky\x1b]0;title\x07z');
assert.deepEqual(chunks.map((c) => c.kind), ['text', 'text', 'text']);
assert.equal(chunks.map((c) => c.value).join(''), 'xyz');
});
// ─── segment ────────────────────────────────────────────────
test('segment reports one cell per grapheme with plain widths', () => {
const segmenter = new CellSegmenter(OPTIONS);
const cells = makeCells();
const runs = makeRuns();
const count = segmenter.segment('abc', cells, runs, false);
assert.equal(count, 3);
assert.deepEqual(Array.from(cells.slice(0, 6)), [2, 1, 3, 1, 4, 1]); // grapheme ids, width 1
assert.equal(runs[0], 0); // default style
assert.equal(runs[1], 0); // no hyperlink
assert.deepEqual(segmenter.graphemes, [' ', '', 'a', 'b', 'c']);
assert.deepEqual(segmenter.sgrKeys, ['']);
});
test('segment measures wide, ambiguous and combining graphemes', () => {
const segmenter = new CellSegmenter(OPTIONS);
const cells = makeCells();
segmenter.segment('日▐a\u0301', cells, makeRuns(), false);
const widths = [cells[1], cells[3], cells[5]];
assert.equal(widths[0] & WIDTH_MASK, 2, 'CJK is double width');
assert.equal(widths[1] & WIDTH_MASK, 1, 'ambiguous width counts as narrow');
assert.equal(widths[2] & WIDTH_MASK, 1, 'combining mark merges into the base grapheme');
assert.deepEqual(segmenter.graphemes.slice(2), ['日', '▐', 'a\u0301']);
});
test('segment measures a ZWJ family emoji as one double-width cluster', () => {
const segmenter = new CellSegmenter(OPTIONS);
const cells = makeCells();
const family = '\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}';
segmenter.segment(family, cells, makeRuns(), false);
assert.equal(cells[1] & WIDTH_MASK, 2, 'family emoji is one wide cluster, not eleven columns');
});
test('segment widths agree with Bun.stringWidth when it is available', () => {
const bun = typeof globalThis.Bun === 'object' && globalThis.Bun !== null ? globalThis.Bun : undefined;
if (!bun || typeof bun.stringWidth !== 'function') return; // Node CI exercises the fallback instead
const segmenter = new CellSegmenter(OPTIONS);
const cells = makeCells();
const count = segmenter.segment('a日\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}', cells, makeRuns(), false);
assert.equal(count, 3);
const widths = [cells[1] & WIDTH_MASK, cells[3] & WIDTH_MASK, cells[5] & WIDTH_MASK];
assert.deepEqual(widths, [1, 2, 2], 'a = 1, CJK = 2, family emoji = 2');
});
test('segment marks tab cells with the tab flag', () => {
const segmenter = new CellSegmenter(OPTIONS);
const cells = makeCells();
const count = segmenter.segment('a\tb', cells, makeRuns(), false);
assert.equal(count, 3);
assert.equal(cells[1] & WIDTH_MASK, 1);
assert.equal(cells[3] & TAB_FLAG, TAB_FLAG);
assert.equal(cells[5] & WIDTH_MASK, 1);
});
test('segment assigns one style id per SGR run and dedupes repeats', () => {
const segmenter = new CellSegmenter(OPTIONS);
const cells = makeCells();
const runs = makeRuns();
// Balanced styling: the trailing \x1b[22m returns the SGR state to default,
// so re-segmenting the same text reuses the same style ids.
const text = 'a\x1b[7mb\x1b[27mc\x1b[1md\x1b[22m';
const count = segmenter.segment(text, cells, runs, false);
assert.equal(count, 4);
assert.deepEqual(segmenter.sgrKeys, ['', '\x1b[7m', '\x1b[1m']);
assert.deepEqual(segmenter.sgrCloseKeys, ['', '\x1b[27m', '\x1b[22m']);
const styles = [];
for (let i = 0; i < count; i++) styles.push(runs[cellRun(cells[i * 2 + 1]) * 2]);
assert.deepEqual(styles, [0, 1, 0, 2]);
segmenter.segment(text, makeCells(), makeRuns(), false);
assert.equal(segmenter.sgrKeys.length, 3, 'repeated styling reuses the style table');
});
test('segment keeps SGR state across calls so wrapped spans stay styled', () => {
const segmenter = new CellSegmenter(OPTIONS);
// Line 1 opens dim without closing it; the renderer emits each screen line
// as its own write op, so the continuation line must inherit the state.
segmenter.segment('a\x1b[2m', makeCells(), makeRuns(), false);
const runs = makeRuns();
segmenter.segment('b', makeCells(), runs, false);
assert.deepEqual(segmenter.sgrKeys, ['', '\x1b[2m']);
assert.equal(runs[0], 1, 'second call keeps the active style');
});
test('segment splits combined and 256-colour SGR into single parameters', () => {
const segmenter = new CellSegmenter(OPTIONS);
segmenter.segment('\x1b[1;31;48;5;17mX', makeCells(), makeRuns(), false);
assert.deepEqual(segmenter.sgrKeys[1].split('\u0000'), ['\x1b[1m', '\x1b[31m', '\x1b[48;5;17m']);
assert.deepEqual(segmenter.sgrCloseKeys[1].split('\u0000'), ['\x1b[22m', '\x1b[39m', '\x1b[49m']);
const truecolor = new CellSegmenter(OPTIONS);
truecolor.segment('\x1b[38;2;10;20;30mX', makeCells(), makeRuns(), false);
assert.equal(truecolor.sgrKeys[1], '\x1b[38;2;10;20;30m');
assert.equal(truecolor.sgrCloseKeys[1], '\x1b[39m');
});
test('segment tracks OSC-8 hyperlinks in runs', () => {
const segmenter = new CellSegmenter(OPTIONS);
const cells = makeCells();
const runs = makeRuns();
segmenter.segment('a\x1b]8;;https://a.test\x07bc\x1b]8;;\x07d', cells, runs, false);
assert.deepEqual(segmenter.uris, ['', 'https://a.test']);
assert.equal(runs[1], 0, 'leading text carries no link');
assert.equal(runs[3], 1, 'linked run points at uris[1]');
assert.equal(runs[5], 0, 'closing the link ends the run');
});
test('segment returns negative capacity when the output arrays are too small', () => {
const segmenter = new CellSegmenter(OPTIONS);
const cells = new Int32Array(4);
const runs = new Int32Array(4);
const count = segmenter.segment('abcdef', cells, runs, false);
assert.equal(count, -6);
const grown = new Int32Array(12);
const count2 = segmenter.segment('abcdef', grown, new Int32Array(12), false);
assert.equal(count2, 6);
});
test('segment rolls back SGR state when a capacity retry is needed', () => {
const segmenter = new CellSegmenter(OPTIONS);
const text = 'aaa\x1b[1mbbb';
const tiny = new Int32Array(4);
assert.equal(segmenter.segment(text, tiny, new Int32Array(4), false), -6, 'first pass overflows');
// The bundle re-runs segment() on the same text with grown arrays. The
// leading cells must stay in the default style — a leaked {bold} state from
// the failed pass would mis-style "aaa".
const cells = new Int32Array(16);
const runs = new Int32Array(16);
const count = segmenter.segment(text, cells, runs, false);
assert.equal(count, 6);
const styles = [];
for (let i = 0; i < count; i++) styles.push(runs[cellRun(cells[i * 2 + 1]) * 2]);
assert.deepEqual(styles, [0, 0, 0, 1, 1, 1]);
});
test('segment mutates its output tables in place', () => {
const segmenter = new CellSegmenter(OPTIONS);
const graphemes = segmenter.graphemes;
const sgrKeys = segmenter.sgrKeys;
segmenter.segment('abc', makeCells(), makeRuns(), false);
segmenter.segment('\x1b[7mz\x1b[27m', makeCells(), makeRuns(), false);
assert.equal(segmenter.graphemes, graphemes, 'graphemes array identity is stable');
assert.equal(segmenter.sgrKeys, sgrKeys, 'sgrKeys array identity is stable');
assert.deepEqual(graphemes.slice(0, 2), [' ', '']);
});
// The 2.1.274 renderer caches resolved styles by segmenter ID until it
// rebuilds the segmenter or invalidates the whole cache on a generation change.
function cachedStyle(segmenter, cache, id) {
if (!cache.has(id)) {
cache.set(id, [segmenter.sgrKeys[id].split('\u0000'), segmenter.sgrCloseKeys[id].split('\u0000')]);
}
return cache.get(id);
}
const colorCode = (i) => `\x1b[38;2;0;${i >>> 8};${i & 255}m`;
test('segment preserves caller-cached style IDs beyond 2048 styles', () => {
const segmenter = new CellSegmenter(OPTIONS);
const cache = new Map();
const cells = makeCells();
const runs = makeRuns();
for (let i = 0; i < 2050; i++) {
segmenter.segment(`${colorCode(i)}X\x1b[0m`, cells, runs, false);
assert.deepEqual(cachedStyle(segmenter, cache, runs[0]), [[colorCode(i)], ['\x1b[39m']], `style ${i}`);
}
segmenter.segment(`${colorCode(0)}X\x1b[0m`, cells, runs, false);
assert.deepEqual(cachedStyle(segmenter, cache, runs[0]), [[colorCode(0)], ['\x1b[39m']]);
assert.equal(segmenter.sgrKeys.length, 2051, 'old IDs remain valid and reusable');
});
test('segment retains every style in a long multicolour line across capacity retry', () => {
const segmenter = new CellSegmenter(OPTIONS);
const count = 2048;
const text = Array.from({ length: count }, (_, i) => `${colorCode(i)}X`).join('') + '\x1b[0m';
assert.equal(segmenter.segment(text, makeCells(), makeRuns(), false), -count);
const cells = makeCells(count * 2);
const runs = makeRuns(count * 2);
assert.equal(segmenter.segment(text, cells, runs, false), count);
const cache = new Map();
for (let i = 0; i < count; i++) {
const styleId = runs[cellRun(cells[i * 2 + 1]) * 2];
assert.deepEqual(cachedStyle(segmenter, cache, styleId), [[colorCode(i)], ['\x1b[39m']], `cell ${i}`);
}
});
// ─── paint / setCell ────────────────────────────────────────
function paintLine(segmenter, text, x, y, columns = 20) {
const lineCells = makeCells();
const runs = makeRuns();
const count = segmenter.segment(text, lineCells, runs, false);
const charMap = new Int32Array(segmenter.graphemes.length);
for (let i = 0; i < segmenter.graphemes.length; i++) charMap[i] = 100 + i;
const words = new Int32Array(16);
for (let i = 0; i < 16; i++) words[i] = runs[i * 2] << STYLE_SHIFT;
const screen = new Int32Array(columns * 4 * 2);
const damage = segmenter.paint(screen, columns, x, y, lineCells, count, undefined, charMap, words);
return { screen, damage, count, columns };
}
const glyphAt = (screen, columns, x, y) => screen[((y * columns + x) << 1)];
test('paint writes narrow glyphs and reports the damaged span', () => {
const segmenter = new CellSegmenter(OPTIONS);
const { screen, damage, count, columns } = paintLine(segmenter, 'abc', 2, 1);
assert.equal(count, 3);
assert.equal(glyphAt(screen, columns, 2, 1), 102);
assert.equal(glyphAt(screen, columns, 4, 1), 104);
assert.equal(screen[((1 * columns + 2) << 1) + 1] & 3, 0, 'narrow cell keeps kind 0');
assert.equal(Math.floor(damage / DAMAGE_SPAN) % 65536, 2, 'damage starts at x');
assert.equal(Math.floor(damage / DAMAGE_END), 5, 'damage ends after the last column');
assert.equal(damage % DAMAGE_SPAN, 5, 'low field is the ending column, not the span');
});
test('paint returns an absolute ending column for indented soft wraps', () => {
const segmenter = new CellSegmenter(OPTIONS);
const start = 5;
const { damage } = paintLine(segmenter, 'abc', start, 0);
// The 2.1.274 layout consumes paint's low field as xC()'s return value,
// then packs it into the next row's softWrap entry via Qf(end, start).
const end = damage % DAMAGE_SPAN;
const softWrap = (end << 16) | start;
assert.equal(softWrap >>> 16, 8, 'previous row ends at column 8');
assert.equal(softWrap & 32767, 5, 'continuation starts at column 5');
assert.equal((softWrap >>> 16) - start, 3, 'selection retains all three columns');
});
test('paint emits a spacer cell for double-width glyphs', () => {
const segmenter = new CellSegmenter(OPTIONS);
const { screen, columns } = paintLine(segmenter, '日x', 0, 0);
assert.equal(glyphAt(screen, columns, 0, 0), 102, 'wide glyph cell');
assert.equal(screen[1] & 3, 1, 'wide kind');
assert.equal(glyphAt(screen, columns, 1, 0), 101, 'spacer carries charMap[spacerCharIndex]');
assert.equal(screen[3] & 3, 2, 'spacerTail kind');
assert.equal(glyphAt(screen, columns, 2, 0), 103, 'following glyph shifts right');
});
test('paint decodes the hyperlink id from the words field', () => {
const segmenter = new CellSegmenter(OPTIONS);
const lineCells = makeCells();
const runs = makeRuns();
const count = segmenter.segment('ab', lineCells, runs, false);
const charMap = new Int32Array(segmenter.graphemes.length);
for (let i = 0; i < segmenter.graphemes.length; i++) charMap[i] = 100 + i;
// runWords() caches poolId + 1 internally, but subtracts that sentinel
// offset before packing words. The word already contains the actual pool ID.
const poolId = 7;
const words = new Int32Array(4);
words[0] = (runs[0] << STYLE_SHIFT) | (poolId << LINK_SHIFT);
const screen = new Int32Array(40);
segmenter.paint(screen, 20, 0, 0, lineCells, count, undefined, charMap, words);
assert.equal((screen[1] >>> LINK_SHIFT) & LINK_MASK, poolId, 'screen stores the pool id');
const words0 = new Int32Array(4);
words0[0] = runs[0] << STYLE_SHIFT;
const screen0 = new Int32Array(40);
segmenter.paint(screen0, 20, 0, 0, lineCells, count, undefined, charMap, words0);
assert.equal((screen0[1] >>> LINK_SHIFT) & LINK_MASK, 0, 'zero link field stays zero');
});
test('paint preserves the first and subsequent OSC-8 hyperlink targets', () => {
const segmenter = new CellSegmenter(OPTIONS);
const cells = makeCells();
const runs = makeRuns();
const count = segmenter.segment('\x1b[1m\x1b]8;;https://a.test\x07A\x1b]8;;https://b.test\x07B\x1b]8;;\x07C\x1b[0m', cells, runs, false);
const pool = ['', 'https://a.test', 'https://b.test'];
const charMap = Int32Array.from(segmenter.graphemes, (_, i) => i);
const words = new Int32Array(count);
const linkIds = new Map();
for (let run = 0; run < count; run++) {
const uriId = runs[run * 2 + 1];
let poolId = 0;
if (uriId !== 0) {
// Model the caller's cache sentinel and conversion to a packed word.
if (!linkIds.has(uriId)) linkIds.set(uriId, pool.indexOf(segmenter.uris[uriId]) + 1);
poolId = linkIds.get(uriId) - 1;
}
words[run] = (7 << STYLE_SHIFT) | (poolId << LINK_SHIFT);
}
const screen = new Int32Array(count * 2);
segmenter.paint(screen, count, 0, 0, cells, count, undefined, charMap, words);
const targets = Array.from({ length: count }, (_, i) => pool[(screen[i * 2 + 1] >>> LINK_SHIFT) & LINK_MASK]);
assert.deepEqual(targets, ['https://a.test', 'https://b.test', '']);
for (let i = 0; i < count; i++) assert.equal(screen[i * 2 + 1] >>> STYLE_SHIFT, 7, 'style bits survive');
});
test('paint expands tabs to the next tab stop', () => {
const segmenter = new CellSegmenter(OPTIONS);
const { screen, columns } = paintLine(segmenter, 'a\tb', 0, 0);
assert.equal(glyphAt(screen, columns, 0, 0), 102);
for (let x = 1; x < 8; x++) assert.equal(glyphAt(screen, columns, x, 0), 100, `blank at ${x}`);
assert.equal(glyphAt(screen, columns, 8, 0), 104, 'text resumes at the tab stop');
});
test('paint clips writes to the target width and stays damage-free when empty', () => {
const segmenter = new CellSegmenter(OPTIONS);
const narrow = paintLine(segmenter, 'abcdef', 2, 0, 4);
assert.equal(narrow.damage % DAMAGE_SPAN, 8, 'low field keeps the intended ending column');
const lineCells = makeCells();
const count = segmenter.segment('\u0301', lineCells, makeRuns(), false);
assert.equal(count, 1);
assert.equal(lineCells[1] & WIDTH_MASK, 0, 'combining-only grapheme occupies no column');
const screen = new Int32Array(40);
const damage = segmenter.paint(screen, 20, 0, 0, lineCells, count, undefined, new Int32Array(4), new Int32Array(4));
assert.equal(damage, 0);
assert.equal(screen[0], 0);
});
test('setCell writes a single cell and reports a one-column damage rect', () => {
const segmenter = new CellSegmenter(OPTIONS);
const screen = new Int32Array(40);
const packed = (5 << STYLE_SHIFT) | 1;
const damage = segmenter.setCell(screen, 10, 3, 1, 77, packed);
assert.equal(screen[((1 * 10 + 3) << 1)], 77);
assert.equal(screen[((1 * 10 + 3) << 1) + 1], packed);
assert.equal(Math.floor(damage / DAMAGE_SPAN) % 65536, 3);
assert.equal(Math.floor(damage / DAMAGE_END), 4);
assert.equal(damage % DAMAGE_SPAN, 4);
assert.equal(segmenter.setCell(screen, 10, 42, 0, 1, 1), 0, 'out-of-range writes report nothing');
});
// ─── install ────────────────────────────────────────────────
// Bun's global Bun binding is read-only, so tests stub a writable namespace
// only when the real one is missing (plain Node).
function targetBun() {
if (typeof globalThis.Bun === 'object' && globalThis.Bun !== null) return globalThis.Bun;
Object.defineProperty(globalThis, 'Bun', { value: {}, configurable: true, writable: true });
return globalThis.Bun;
}
test('install fills in a missing Bun.ant namespace', () => {
const bun = targetBun();
const saved = bun.ant;
try {
delete bun.ant;
assert.equal(install(), true);
assert.equal(typeof bun.ant.CellSegmenter, 'function');
assert.equal(install(), false, 'second call is a no-op');
} finally {
if (saved === undefined) delete bun.ant; else bun.ant = saved;
}
});
test('install never shadows a native CellSegmenter', () => {
const bun = targetBun();
const saved = bun.ant;
const native = function NativeCellSegmenter() {};
try {
bun.ant = { CellSegmenter: native };
assert.equal(install(), false);
assert.equal(bun.ant.CellSegmenter, native);
} finally {
if (saved === undefined) delete bun.ant; else bun.ant = saved;
}
});
test('install respects the bun-ant-shim feature toggle', () => {
const bun = targetBun();
const saved = bun.ant;
try {
delete bun.ant;
globalThis.__clawgodPatches = { 'bun-ant-shim': false };
assert.equal(install(), false);
assert.equal(bun.ant, undefined, 'disabled shim installs nothing');
} finally {
delete globalThis.__clawgodPatches;
if (saved === undefined) delete bun.ant; else bun.ant = saved;
}
});
// ─── runner ─────────────────────────────────────────────────
let failed = 0;
for (const [name, fn] of tests) {
try {
fn();
console.log(` ok ${name}`);
} catch (error) {
failed++;
console.error(` FAIL ${name}`);
console.error(` ${error.message.split('\n').join('\n ')}`);
}
}
console.log(`\n${tests.length - failed}/${tests.length} passed`);
if (failed > 0) process.exit(1);
+267
View File
@@ -0,0 +1,267 @@
#!/usr/bin/env bun
const { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSync, renameSync } = require('fs');
const { join, basename } = require('path');
const { homedir } = require('os');
const { spawnSync } = require('child_process');
const clawgodDir = join(homedir(), '.clawgod');
// Version queries (including installer verification) must not start provider
// servers or background update requests that keep the process alive (#203).
// Match only a standalone flag, never a prompt/subcommand containing it.
const versionOnly = process.argv.length === 3 && ['--version', '-v'].includes(process.argv[2]);
// Note: there used to be a "drift detection" block here that scanned
// ~/.local/share/claude/versions/ for a newer binary and silently re-patched.
// Retained native versions (including on Windows) may be older than the
// version our installer pulled from npm. Scanning them could roll back a
// fresh install and cause a re-patch loop. Upgrades instead go through the
// patched `claude update` redirect; native background updates are disabled
// below so they cannot restore an official launcher over ours.
// One-time migration: earlier wrapper versions set CLAUDE_CONFIG_DIR=~/.clawgod,
// which made Claude Code read/write ~/.clawgod/.claude.json instead of the
// native ~/.claude.json (the file holding MCP config, project history, session
// index). Move it back transparently on first run after upgrade.
const nativeClaudeJson = join(homedir(), '.claude.json');
const strayClaudeJson = join(clawgodDir, '.claude.json');
if (!versionOnly && existsSync(strayClaudeJson) && !existsSync(nativeClaudeJson)) {
try { renameSync(strayClaudeJson, nativeClaudeJson); } catch {}
}
const providerDir = clawgodDir;
const configFile = join(providerDir, 'provider.json');
const defaultConfig = {
apiKey: '',
baseURL: 'https://api.anthropic.com',
model: '',
smallModel: '',
effort: '',
timeoutMs: 3000000,
};
let config = { ...defaultConfig };
if (!versionOnly && existsSync(configFile)) {
try {
const raw = JSON.parse(readFileSync(configFile, 'utf8'));
config = { ...defaultConfig, ...raw };
} catch {}
} else if (!versionOnly) {
mkdirSync(providerDir, { recursive: true });
writeFileSync(configFile, JSON.stringify(defaultConfig, null, 2) + '\n');
}
// Explicit protocol takes precedence over legacy provider types.
if (config.protocol !== undefined && !['anthropic', 'openai-chat'].includes(config.protocol)) {
throw new Error('[clawgod] Unsupported provider protocol: ' + config.protocol + '. Use anthropic or openai-chat; auto and Responses are not supported.');
}
const _useProxy = config.protocol === 'openai-chat' ||
(config.protocol === undefined && ['grok', 'openai-compat'].includes(config.type));
if (_useProxy) {
let _proxyKey = config.apiKey || '';
if (!_proxyKey && config.type === 'grok') {
try {
const _gs = JSON.parse(readFileSync(join(homedir(), '.grok', 'user-settings.json'), 'utf8'));
_proxyKey = _gs.apiKey || '';
} catch {}
if (!_proxyKey) _proxyKey = process.env.GROK_API_KEY || '';
}
// The generic Anthropic default must never become a Chat Completions URL.
if (config.baseURL === defaultConfig.baseURL || !config.baseURL) {
if (config.type === 'grok') config.baseURL = 'https://api.x.ai/v1';
else throw new Error('[clawgod] openai-chat requires an explicit baseURL, for example https://example.com/v1');
}
if (_proxyKey) {
const { startProxy } = require('./openai-proxy.cjs');
const _proxy = startProxy({
apiKey: _proxyKey,
baseURL: config.baseURL || (config.type === 'grok' ? 'https://api.x.ai/v1' : ''),
model: config.model || '',
effort: process.env.CLAUDE_CODE_EFFORT_LEVEL ?? config.effort,
timeoutMs: process.env.API_TIMEOUT_MS ?? config.timeoutMs,
});
delete process.env.ANTHROPIC_API_KEY;
process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:' + _proxy.port;
process.env.ANTHROPIC_AUTH_TOKEN = 'proxy-passthrough';
if (config.model) process.env.ANTHROPIC_MODEL = config.model;
if (config.smallModel) process.env.ANTHROPIC_SMALL_FAST_MODEL = config.smallModel;
process.env.CLAUDE_CODE_ATTRIBUTION_HEADER = '0';
process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS ??= '1';
process.on('exit', function () { try { _proxy.stop(); } catch {} });
process.stderr.write('[clawgod] OpenAI Chat Completions proxy on port ' + _proxy.port + '\n');
config = { ...config, apiKey: '', baseURL: '', model: '', smallModel: '' }; // prevent fallthrough to apiKey/baseURL injection below
} else {
throw new Error('[clawgod] openai-chat requires an API key (grok also accepts GROK_API_KEY or ~/.grok/user-settings.json)');
}
}
const hasProviderApiKey = !!config.apiKey;
if (hasProviderApiKey) {
if (config.baseURL) process.env.ANTHROPIC_BASE_URL = config.baseURL;
if (config.model) process.env.ANTHROPIC_MODEL = config.model;
if (config.smallModel) process.env.ANTHROPIC_SMALL_FAST_MODEL = config.smallModel;
if (config.baseURL && !/anthropic\.com/i.test(config.baseURL)) {
delete process.env.ANTHROPIC_API_KEY;
const existingToken = (process.env.ANTHROPIC_AUTH_TOKEN || '').trim();
process.env.ANTHROPIC_AUTH_TOKEN = existingToken || config.apiKey;
} else {
delete process.env.ANTHROPIC_AUTH_TOKEN;
process.env.ANTHROPIC_API_KEY = config.apiKey;
}
} else if (config.baseURL && config.baseURL !== defaultConfig.baseURL) {
process.env.ANTHROPIC_BASE_URL ??= config.baseURL;
}
// Third-party Anthropic-compatible proxies (DeepSeek / OneAPI / Bedrock /
// vLLM / etc.) don't share Anthropic's server-side handling of
// x-anthropic-billing-header. That header carries a per-request `cch` field
// which Anthropic's own server excludes from prompt-cache key calculation
// (via cacheScope:null), but third-party proxies fold into the prefix hash —
// so the cached prefix changes every request and cache hit rate drops to
// zero. Auto-disable the header whenever baseURL points away from Anthropic.
// Users can force re-enable with CLAUDE_CODE_ATTRIBUTION_HEADER=1 if needed.
if (config.baseURL && !/anthropic\.com/i.test(config.baseURL)) {
process.env.CLAUDE_CODE_ATTRIBUTION_HEADER ??= '0';
}
if (config.effort) {
process.env.CLAUDE_CODE_EFFORT_LEVEL ??= config.effort;
}
if (config.timeoutMs) {
process.env.API_TIMEOUT_MS ??= String(config.timeoutMs);
}
// Remote Control needs GrowthBook evaluation. Restrict network traffic by
// default only in max mode; on/off retain upstream eligibility checks.
// Explicit user environment settings still take precedence.
if (existsSync(join(clawgodDir, '.lean-max')) && !existsSync(join(clawgodDir, '.lean-disabled'))) {
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC ??= '1';
}
process.env.DISABLE_INSTALLATION_CHECKS ??= '1';
// Updates belong to clawgod's `claude update` redirect. The native background
// updater can repair a missing Windows claude.exe even at the same version,
// shadowing our claude.cmd. Keep it disabled for every clawgod process (#200).
process.env.DISABLE_AUTOUPDATER = '1';
// Use system ripgrep (extracted vendor rg path was build-time-baked; system
// rg is the most reliable fallback under Bun runtime).
process.env.USE_BUILTIN_RIPGREP ??= '1';
const featuresFile = join(providerDir, 'features.json');
if (!process.env.CLAUDE_INTERNAL_FC_OVERRIDES && existsSync(featuresFile)) {
try {
const raw = readFileSync(featuresFile, 'utf8');
JSON.parse(raw);
process.env.CLAUDE_INTERNAL_FC_OVERRIDES = raw;
} catch {}
}
// Monkey-patch process.execPath: Anthropic's CLI uses process.execPath to
// locate the native binary for shell wrappers (find→bfs, grep→ugrep, rg) and
// subprocess spawning. Under Bun, process.execPath returns the Bun runtime
// path, not the Claude native binary. The launcher script sets
// CLAUDE_CODE_EXECPATH to claude.orig (the real native binary) before exec'ing
// Bun, so we use that as the source of truth. See issue #100.
const _realExecPath = process.env.CLAUDE_CODE_EXECPATH || process.execPath;
if (_realExecPath !== process.execPath) {
Object.defineProperty(process, 'execPath', {
value: _realExecPath,
configurable: true,
});
}
// Lean mode toggle — --lean-off / --lean-on / --lean-max
if (process.argv.includes('--lean-off') || process.argv.includes('--lean-on') || process.argv.includes('--lean-max')) {
const _leanOff = join(clawgodDir, '.lean-disabled');
const _leanMax = join(clawgodDir, '.lean-max');
const _leanSettings = join(homedir(), '.claude', 'settings.json');
const _baseDeny = ['DesignSync','NotebookEdit','PushNotification','RemoteTrigger','CronCreate','CronDelete','CronList'];
const _maxDeny = ['EnterPlanMode','ExitPlanMode','SendMessage','ScheduleWakeup','AskUserQuestion','ReportFindings'];
const _baseFlags = ['disableWorkflows','disableClaudeAiConnectors','disableArtifact'];
const _maxFlags = ['disableBundledSkills','disableRemoteControl'];
const _allDeny = new Set([..._baseDeny, ..._maxDeny]);
const _allFlags = [..._baseFlags, ..._maxFlags];
const _unlink = function(p) { try { require('fs').unlinkSync(p); } catch {} };
if (process.argv.includes('--lean-off')) {
writeFileSync(_leanOff, '');
_unlink(_leanMax);
try {
const _s = JSON.parse(readFileSync(_leanSettings, 'utf8'));
for (const _k of _allFlags) delete _s[_k];
if (Array.isArray(_s.permissions?.deny)) _s.permissions.deny = _s.permissions.deny.filter(function(t) { return !_allDeny.has(t); });
writeFileSync(_leanSettings, JSON.stringify(_s, null, 2) + '\n');
} catch {}
process.stderr.write('[clawgod] Lean mode disabled. All tools restored.\n');
} else {
const _isMax = process.argv.includes('--lean-max');
_unlink(_leanOff);
if (_isMax) writeFileSync(_leanMax, ''); else _unlink(_leanMax);
const _deny = _isMax ? [..._baseDeny, ..._maxDeny] : _baseDeny;
const _flags = _isMax ? _allFlags : _baseFlags;
try {
let _s = {};
try { _s = JSON.parse(readFileSync(_leanSettings, 'utf8')); } catch {}
let _ch = false;
for (const _k of _flags) { if (!(_k in _s)) { _s[_k] = true; _ch = true; } }
// If downgrading from max to on, remove max-only keys
if (!_isMax) { for (const _k of _maxFlags) { if (_k in _s) { delete _s[_k]; _ch = true; } } }
if (!_s.permissions) _s.permissions = {};
if (!Array.isArray(_s.permissions.deny)) _s.permissions.deny = [];
const _ex = new Set(_s.permissions.deny);
for (const _t of _deny) { if (!_ex.has(_t)) { _s.permissions.deny.push(_t); _ch = true; } }
// If downgrading from max to on, remove max-only deny entries
if (!_isMax) {
const _maxSet = new Set(_maxDeny);
const _before = _s.permissions.deny.length;
_s.permissions.deny = _s.permissions.deny.filter(function(t) { return !_maxSet.has(t); });
if (_s.permissions.deny.length !== _before) _ch = true;
}
if (_ch) writeFileSync(_leanSettings, JSON.stringify(_s, null, 2) + '\n');
} catch {}
process.stderr.write('[clawgod] Lean mode: ' + (_isMax ? 'max' : 'on') + '. Settings updated.\n');
}
process.exit(0);
}
// Update check — cached, non-blocking, 24h interval
try {
const _ucFile = join(clawgodDir, '.update-check');
const _verFile = join(clawgodDir, '.clawgod-version');
if (!versionOnly && existsSync(_verFile)) {
const _localVer = readFileSync(_verFile, 'utf8').trim();
let _uc = null;
try { if (existsSync(_ucFile)) _uc = JSON.parse(readFileSync(_ucFile, 'utf8')); } catch {}
var _semGt = function(a, b) { var x = a.split('.'), y = b.split('.'); for (var i = 0; i < 3; i++) { var d = (parseInt(x[i]||0)) - (parseInt(y[i]||0)); if (d) return d > 0; } return false; };
if (_uc && _uc.v && _semGt(_uc.v, _localVer)) {
process.stderr.write('[clawgod] v' + _uc.v + ' available (installed: v' + _localVer + ") — run 'claude update' to upgrade\n");
}
if (!_uc || Date.now() - (_uc.t || 0) > 86400000) {
fetch('https://api.github.com/repos/0Chencc/clawgod/releases/latest', {
headers: { 'User-Agent': 'clawgod' },
signal: AbortSignal.timeout(5000),
}).then(function(r) { return r.json(); }).then(function(d) {
var v = (d.tag_name || '').replace(/^v/, '');
if (v) writeFileSync(_ucFile, JSON.stringify({ t: Date.now(), v: v }));
}).catch(function() {});
}
}
} catch {}
// Patch feature gates (~/.clawgod/patches.json + CLAWGOD_FEATURE_* env) —
// must run before the patched cli loads so gated patches see
// globalThis.__clawgodPatches.
require('./feature-gates.cjs');
// Runtime helpers shared by injected patches (globalThis.__clawgodHelpers,
// see runtime-helpers.cjs). cli.original.cjs is a separate module scope, so
// the patched bundle reaches helpers through globalThis only.
require('./runtime-helpers.cjs');
// Claude Code 2.1.271+ renders through Bun.ant.CellSegmenter, an
// Anthropic-private Bun API that stock Bun does not ship. Without it the
// renderer throws before the first frame and the TUI looks hung, so the shim
// re-implements the API in JS. No-op when the real API exists or when the
// bun-ant-shim feature is off (patches.json / CLAWGOD_FEATURE_BUN_ANT_SHIM).
require('./bun-ant-shim.cjs');
require('./cli.original.cjs');
+419
View File
@@ -0,0 +1,419 @@
#!/usr/bin/env node
/**
* ClawGod Bun section extractor
*
* Parses the .bun (PE/ELF) or __BUN,__bun (Mach-O) section embedded in a
* Bun standalone executable, walks the module graph, and extracts:
* - the entry-point module → <out>/cli.original.js
* - every loader=napi module → <out>/vendor/<name>/<arch>-<os>/<name>.node
*
* Everything else is dropped (e.g. auto-generated *.js napi shims aren't
* needed because cli.js already inlines the require('/$bunfs/root/X.node')
* calls that post-process.mjs rewrites to the vendor lookup).
*
* Adapted from /home/kaiju/code/python/parse-bun/main.js (which itself
* implements the format documented in docs/bun-section-format.md). Lazy
* Bun.file reads were replaced with readFileSync so the script runs under
* the existing `node` invocation in install.sh / install.ps1.
*
* Usage:
* node extract-natives.mjs <binary-path> <output-dir>
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { join, basename } from 'node:path';
// ─── Format constants ────────────────────────────────────────────────
const TRAILER = Buffer.from('\n---- Bun! ----\n');
const BUN_SECTION_NAME = '.bun';
const OFFSET_STRUCT_SIZE = 32;
const MODULE_RECORD_SIZE = 52;
// loader id → name (subset; only `napi` is acted on, rest informational)
const LOADERS = {
0:'jsx', 1:'js', 2:'ts', 3:'tsx', 4:'css', 5:'file', 6:'json', 7:'jsonc',
8:'toml', 9:'wasm', 10:'napi', 11:'base64', 12:'dataurl', 13:'text',
14:'bunsh', 15:'sqlite', 16:'sqlite_embedded', 17:'html', 18:'yaml',
19:'json5', 20:'md',
};
// ELF
const ELF_MAGIC_LE = 0x464c457f; // "\x7fELF" LE u32
const ELF_EI_CLASS = 0x04;
const ELF_EI_DATA = 0x05;
const ELF_CLASS_64 = 0x02;
const ELF_DATA_LE = 0x01;
const ELF_E_MACHINE = 0x12; // u16
const ELF_EHDR_SIZE = 0x40;
const ELF64_E_SHOFF = 0x28;
const ELF64_E_SHENTSIZE = 0x3a;
const ELF64_E_SHNUM = 0x3c;
const ELF64_E_SHSTRNDX = 0x3e;
const ELF64_SH_NAME = 0x00;
const ELF64_SH_OFFSET = 0x18;
const ELF64_SH_SIZE = 0x20;
const EM_X86_64 = 0x3e;
const EM_AARCH64 = 0xb7;
// Mach-O (thin LE 64-bit; fat / 32-bit / BE rejected with clear message)
const MH_MAGIC_64 = 0xfeedfacf;
const MH_CIGAM_64 = 0xcffaedfe;
const MH_MAGIC = 0xfeedface;
const MH_CIGAM = 0xcefaedfe;
const MACH_CPUTYPE_OFF = 0x04; // u32
const MACH_NCMDS_OFF = 0x10;
const MACH_SIZEOFCMDS_OFF = 0x14;
const MACH_HDR_SIZE_64 = 0x20;
const LC_SEGMENT_64 = 0x19;
const LC_CMDSIZE_OFF = 0x04;
const LC_SEGNAME_OFF = 0x08;
const LC_SEGNAME_LEN = 0x10;
const SEG64_NSECTS_OFF = 0x40;
const SEG64_SECTS_OFF = 0x48;
const SECT64_ENTRY_SIZE = 0x50;
const SECT64_SIZE_OFF = 0x28;
const SECT64_OFFSET_OFF = 0x30;
const CPU_TYPE_X86_64 = 0x01000007;
const CPU_TYPE_ARM64 = 0x0100000c;
// PE
const PE_OFFSET_PTR = 0x3c;
const PE_MACHINE_OFF = 0x04; // relative to PE sig
const PE_NUM_SECTIONS_OFF = 0x06;
const PE_OPT_HDR_SIZE_OFF = 0x14;
const PE_COFF_HDR_SIZE = 0x18;
const PE_OPT_MAGIC_OFF = 0x18;
const PE_OPT_MAGIC_PE32P = 0x20b;
const PE_SECTION_ENTRY_SIZE = 0x28;
const PE_SECT_RAW_SIZE_OFF = 0x10;
const PE_SECT_RAW_OFF_OFF = 0x14;
const PE_SECT_NAME_LEN = 0x08;
const IMAGE_MACHINE_AMD64 = 0x8664;
const IMAGE_MACHINE_ARM64 = 0xaa64;
// ─── Helpers ─────────────────────────────────────────────────────────
function die(msg) { throw new Error(`error: ${msg}`); }
function readU64LE(buf, off, what) {
const v = buf.readBigUInt64LE(off);
if (v > BigInt(Number.MAX_SAFE_INTEGER)) die(`${what} exceeds JS safe integer: ${v}`);
return Number(v);
}
function checkedSlice(buf, off, size, what) {
if (off < 0 || size < 0 || off + size > buf.length) {
die(`${what} out of bounds: offset=${off} size=${size} buf=${buf.length}`);
}
return buf.subarray(off, off + size);
}
function decodeName(buf) {
return buf.toString('utf8').replace(/\u0000+$/u, '');
}
// ─── Section locators (per format) ───────────────────────────────────
function findSectionElf(buf) {
if (buf.length < ELF_EHDR_SIZE) die('ELF too small');
if (buf[ELF_EI_CLASS] !== ELF_CLASS_64) die('ELF: only 64-bit supported');
if (buf[ELF_EI_DATA] !== ELF_DATA_LE) die('ELF: only little-endian supported');
const eMachine = buf.readUInt16LE(ELF_E_MACHINE);
const arch = eMachine === EM_X86_64 ? 'x64'
: eMachine === EM_AARCH64 ? 'arm64'
: die(`ELF: unsupported e_machine 0x${eMachine.toString(16)}`);
const shoff = readU64LE(buf, ELF64_E_SHOFF, 'ELF e_shoff');
const shentsize = buf.readUInt16LE(ELF64_E_SHENTSIZE);
const shnum = buf.readUInt16LE(ELF64_E_SHNUM);
const shstrndx = buf.readUInt16LE(ELF64_E_SHSTRNDX);
if (shstrndx >= shnum) die('ELF e_shstrndx out of range');
const shstrEntry = buf.subarray(shoff + shstrndx * shentsize, shoff + (shstrndx + 1) * shentsize);
const shstrOffset = readU64LE(shstrEntry, ELF64_SH_OFFSET, 'shstrtab offset');
const shstrSize = readU64LE(shstrEntry, ELF64_SH_SIZE, 'shstrtab size');
const shstr = checkedSlice(buf, shstrOffset, shstrSize, 'shstrtab');
let match = null;
for (let i = 0; i < shnum; i++) {
const entry = buf.subarray(shoff + i * shentsize, shoff + (i + 1) * shentsize);
const nameIdx = entry.readUInt32LE(ELF64_SH_NAME);
if (nameIdx >= shstr.length) continue;
let nameEnd = nameIdx;
while (nameEnd < shstr.length && shstr[nameEnd] !== 0) nameEnd++;
if (shstr.toString('ascii', nameIdx, nameEnd) !== BUN_SECTION_NAME) continue;
if (match) die('ELF has multiple .bun sections');
const rawOffset = readU64LE(entry, ELF64_SH_OFFSET, '.bun sh_offset');
const rawSize = readU64LE(entry, ELF64_SH_SIZE, '.bun sh_size');
if (rawOffset + rawSize > buf.length) die('.bun out of file bounds');
match = { format: 'ELF', os: 'linux', arch, rawOffset, rawSize };
}
if (!match) die('ELF has no .bun section');
return match;
}
function findSectionMacho(buf) {
if (buf.length < MACH_HDR_SIZE_64) die('Mach-O too small');
const cputype = buf.readUInt32LE(MACH_CPUTYPE_OFF);
const arch = cputype === CPU_TYPE_X86_64 ? 'x64'
: cputype === CPU_TYPE_ARM64 ? 'arm64'
: die(`Mach-O: unsupported cputype 0x${cputype.toString(16)}`);
const ncmds = buf.readUInt32LE(MACH_NCMDS_OFF);
const sizeofcmds = buf.readUInt32LE(MACH_SIZEOFCMDS_OFF);
if (sizeofcmds === 0 || MACH_HDR_SIZE_64 + sizeofcmds > buf.length) die('Mach-O sizeofcmds invalid');
const cmds = buf.subarray(MACH_HDR_SIZE_64, MACH_HDR_SIZE_64 + sizeofcmds);
let match = null;
let off = 0;
for (let i = 0; i < ncmds; i++) {
if (off + 8 > sizeofcmds) die(`Mach-O LC ${i} truncated`);
const cmd = cmds.readUInt32LE(off);
const cmdsize = cmds.readUInt32LE(off + LC_CMDSIZE_OFF);
if (cmdsize < 8 || off + cmdsize > sizeofcmds) die(`Mach-O LC ${i} cmdsize invalid: ${cmdsize}`);
if (cmd === LC_SEGMENT_64) {
const segname = cmds.toString('ascii', off + LC_SEGNAME_OFF, off + LC_SEGNAME_OFF + LC_SEGNAME_LEN).replace(/\0+$/, '');
if (segname === '__BUN') {
const nsects = cmds.readUInt32LE(off + SEG64_NSECTS_OFF);
if (SEG64_SECTS_OFF + nsects * SECT64_ENTRY_SIZE > cmdsize) die(`Mach-O LC_SEGMENT_64(__BUN) sections exceed cmdsize`);
for (let j = 0; j < nsects; j++) {
const s = off + SEG64_SECTS_OFF + j * SECT64_ENTRY_SIZE;
const sectname = cmds.toString('ascii', s, s + LC_SEGNAME_LEN).replace(/\0+$/, '');
if (sectname === '__bun') {
const rawSize = readU64LE(cmds, s + SECT64_SIZE_OFF, '__bun size');
const rawOffset = cmds.readUInt32LE(s + SECT64_OFFSET_OFF);
if (rawOffset + rawSize > buf.length) die('__bun out of file bounds');
if (match) die('Mach-O has multiple __BUN,__bun sections');
match = { format: 'Mach-O', os: 'darwin', arch, rawOffset, rawSize };
}
}
}
}
off += cmdsize;
}
if (!match) die('Mach-O has no __BUN,__bun section');
return match;
}
function findSectionPe(buf) {
if (buf.length < 0x40) die('PE too small');
if (buf.toString('ascii', 0, 2) !== 'MZ') die('PE missing MZ header');
const peOff = buf.readUInt32LE(PE_OFFSET_PTR);
if (buf.toString('ascii', peOff, peOff + 4) !== 'PE\0\0') die('PE missing PE signature');
const machine = buf.readUInt16LE(peOff + PE_MACHINE_OFF);
const arch = machine === IMAGE_MACHINE_AMD64 ? 'x64'
: machine === IMAGE_MACHINE_ARM64 ? 'arm64'
: die(`PE: unsupported machine 0x${machine.toString(16)}`);
const optMagic = buf.readUInt16LE(peOff + PE_OPT_MAGIC_OFF);
if (optMagic !== PE_OPT_MAGIC_PE32P) die(`PE: only 64-bit (PE32+) supported, got 0x${optMagic.toString(16)}`);
const numSect = buf.readUInt16LE(peOff + PE_NUM_SECTIONS_OFF);
const optHdrSize = buf.readUInt16LE(peOff + PE_OPT_HDR_SIZE_OFF);
const sectTable = peOff + PE_COFF_HDR_SIZE + optHdrSize;
let match = null;
for (let i = 0; i < numSect; i++) {
const entry = sectTable + i * PE_SECTION_ENTRY_SIZE;
const rawNm = buf.subarray(entry, entry + PE_SECT_NAME_LEN);
const nul = rawNm.indexOf(0);
const name = rawNm.subarray(0, nul === -1 ? rawNm.length : nul).toString('ascii');
if (name !== BUN_SECTION_NAME) continue;
if (match) die('PE has multiple .bun sections');
const rawSize = buf.readUInt32LE(entry + PE_SECT_RAW_SIZE_OFF);
const rawOffset = buf.readUInt32LE(entry + PE_SECT_RAW_OFF_OFF);
if (rawOffset + rawSize > buf.length) die('.bun out of file bounds');
match = { format: 'PE', os: 'win32', arch, rawOffset, rawSize };
}
if (!match) die('PE has no .bun section');
return match;
}
function findBunSection(buf) {
if (buf.length < 4) die('file too small');
const magic = buf.readUInt32LE(0);
if (magic === ELF_MAGIC_LE) return findSectionElf(buf);
if (magic === MH_MAGIC_64) return findSectionMacho(buf);
if (magic === MH_CIGAM_64 || magic === MH_CIGAM) die('Mach-O: only little-endian supported');
if (magic === MH_MAGIC) die('Mach-O: only 64-bit supported');
return findSectionPe(buf);
}
// ─── Payload + module records ────────────────────────────────────────
function parsePayload(sectionData) {
if (sectionData.length < 8) die('.bun too small for length prefix');
const payloadSize = readU64LE(sectionData, 0, '.bun payload length');
if (payloadSize + 8 > sectionData.length) die('.bun payload exceeds raw section');
const payload = sectionData.subarray(8, 8 + payloadSize);
if (payload.length < OFFSET_STRUCT_SIZE + TRAILER.length) die('.bun payload too small');
if (!payload.subarray(payload.length - TRAILER.length).equals(TRAILER)) die('.bun trailer mismatch');
return payload;
}
function parseOffsets(payload) {
const start = payload.length - TRAILER.length - OFFSET_STRUCT_SIZE;
return {
modules_offset: payload.readUInt32LE(start + 8),
modules_size: payload.readUInt32LE(start + 12),
entry_point_id: payload.readUInt32LE(start + 16),
};
}
function parseModules(payload, offsets) {
if (offsets.modules_size % MODULE_RECORD_SIZE !== 0) {
die(`modules table size not a multiple of ${MODULE_RECORD_SIZE}: ${offsets.modules_size}`);
}
const count = offsets.modules_size / MODULE_RECORD_SIZE;
if (offsets.entry_point_id >= count) die(`entry_point_id ${offsets.entry_point_id} >= ${count}`);
const table = checkedSlice(payload, offsets.modules_offset, offsets.modules_size, 'modules table');
const out = [];
for (let i = 0; i < count; i++) {
const rec = table.subarray(i * MODULE_RECORD_SIZE, (i + 1) * MODULE_RECORD_SIZE);
const nameOff = rec.readUInt32LE(0);
const nameSize = rec.readUInt32LE(4);
const contentOff = rec.readUInt32LE(8);
const contentSize= rec.readUInt32LE(12);
const loaderId = rec.readUInt8(49);
const name = decodeName(checkedSlice(payload, nameOff, nameSize, `module[${i}].name`));
const content = checkedSlice(payload, contentOff, contentSize, `module[${i}].content`);
out.push({
index: i,
entry: i === offsets.entry_point_id,
name,
content,
loader: LOADERS[loaderId] ?? `unknown(${loaderId})`,
});
}
return out;
}
// ─── Output dispatch ─────────────────────────────────────────────────
function napiBasename(name) {
// Bun records may use either '/' (POSIX builds) or '\\' (PE) as separator;
// always normalize so basename grabs the right tail.
const flat = name.replaceAll('\\', '/');
const tail = flat.split('/').pop() ?? '';
return tail.replace(/\.node$/i, '');
}
// ─── Main ────────────────────────────────────────────────────────────
function main() {
const [,, binaryPath, outputDir] = process.argv;
if (!binaryPath || !outputDir) {
console.error('Usage: extract-natives.mjs <binary-path> <output-dir>');
process.exit(1);
}
if (!existsSync(binaryPath)) {
console.error(`Binary not found: ${binaryPath}`);
process.exit(1);
}
const buf = readFileSync(binaryPath);
console.log(`Size: ${(buf.length / 1024 / 1024).toFixed(1)} MB`);
const section = findBunSection(buf);
console.log(`Format: ${section.format} (${section.arch}-${section.os})`);
const sectionData = checkedSlice(buf, section.rawOffset, section.rawSize, '.bun section');
const payload = parsePayload(sectionData);
const offsets = parseOffsets(payload);
const modules = parseModules(payload, offsets);
console.log(`Modules: ${modules.length} (entry id=${offsets.entry_point_id})`);
mkdirSync(outputDir, { recursive: true });
const entryMod = modules.find((m) => m.entry);
const entryText = entryMod ? entryMod.content.toString('utf8') : '';
// v2.1.245+ splits the app into an ESM chunk graph: the entry is a small
// ~20KB ESM module static-importing sibling chunk modules + a handful of
// boot modules, with lazy import() of chunk-*.js. The ~1400 remaining js
// records and ~170 text/file assets must all be kept on disk as flat
// siblings, or the entry throws "Cannot find module".
// Bun mounts the app bundle at "/$bunfs/root" on POSIX builds and at
// "B:/~BUN/root" on single-drive Windows builds (both are virtual
// in-bundle roots, differ only by the drive-letter prefix).
const BUN_MOUNT_RE = /^(?:\/\$bunfs\/root|[A-Za-z]:\/~BUN\/root)\//;
const bunSub = (name) => {
const n = name.replaceAll('\\', '/');
const mm = n.match(BUN_MOUNT_RE);
return mm ? n.slice(mm[0].length) : null;
};
const isChunked = !!entryMod &&
!entryText.includes('(function(exports, require, module') &&
(entryText.includes('import{') || BUN_MOUNT_RE.test(entryText));
// When chunked, also extract every non-napi module to a flat dir so the
// ESM graph resolves. We flatten <mount>/sub/path → sub__path and keep
// napi under vendor/. We postpone path rewriting to post-process.mjs
// (which knows the final install dir).
const platDir = `${section.arch}-${section.os}`;
const graphDir = join(outputDir, 'bunfs');
let cliCount = 0, napiCount = 0, dropped = 0;
const napiNames = new Set();
for (const m of modules) {
if (m.entry) {
const out = join(outputDir, 'cli.original.js');
writeFileSync(out, m.content);
console.log(` cli.js ${(m.content.length / 1024 / 1024).toFixed(2)} MB → ${out} (${m.name})`);
cliCount++;
} else if (m.loader === 'napi') {
const base = napiBasename(m.name);
if (!base) { console.warn(` skip napi ${m.name}: empty basename`); dropped++; continue; }
const dir = join(outputDir, 'vendor', base, platDir);
mkdirSync(dir, { recursive: true });
const out = join(dir, `${base}.node`);
writeFileSync(out, m.content);
console.log(` napi ${(m.content.length / 1024).toFixed(0).padStart(5)} KB → ${out}`);
napiCount++;
napiNames.add(m.name.replaceAll('\\', '/'));
} else if (isChunked && bunSub(m.name)) {
// js chunk / text asset / file asset: write to flat graph dir so Bun
// can resolve the rewritten /$bunfs/root/ (POSIX) or B:/~BUN/root/
// (Windows single-drive) specifiers.
const sub = bunSub(m.name);
if (!sub) { dropped++; continue; }
const flat = sub.replace(/\//g, '__');
mkdirSync(graphDir, { recursive: true });
writeFileSync(join(graphDir, flat), m.content);
console.log(` chunk ${(m.content.length / 1024).toFixed(0).padStart(6)} KB → bunfs/${flat}`);
dropped++; // count as dropped-from-entry (informational)
} else {
dropped++;
}
}
console.log(`Extracted: ${cliCount} cli.js + ${napiCount} napi + ${isChunked ? 'chunk-graph' : 'dropped'} (${dropped} other)`);
console.log(` bunfs graph: ${isChunked ? 'yes (' + platDir + ')' : 'no (legacy single-bundle)'}`);
if (cliCount !== 1) {
console.error(`error: expected exactly 1 entry-point, got ${cliCount}`);
process.exit(2);
}
if (isChunked) {
// Write a path-map JSON so post-process.mjs can rewrite every
// /$bunfs/root/X or B:/~BUN/root/X string literal to the on-disk
// absolute path.
const pathMap = {};
for (const m of modules) {
const normName = m.name.replaceAll('\\', '/');
if (!BUN_MOUNT_RE.test(normName)) continue;
const sub = normName.replace(BUN_MOUNT_RE, '');
if (!sub) continue;
if (m.loader === 'napi') {
const base = napiBasename(m.name);
pathMap[normName] = `vendor/${base}/${platDir}/${base}.node`;
} else {
pathMap[normName] = `bunfs/${sub.replace(/\//g, '__')}`;
}
}
writeFileSync(join(outputDir, 'pathmap.json'), JSON.stringify(pathMap, null, 0) + '\n');
console.log(`Graph chunks: ${modules.filter((m) => m.loader === 'js').length} js + napi in vendor/`);
}
}
main();
+56
View File
@@ -0,0 +1,56 @@
'use strict';
// Patch feature gates — computes globalThis.__clawgodPatches before the
// patched cli loads. Config: user-edited ~/.clawgod/patches.json
// ({"<feature>": false}, absent key = on). Per-feature env overrides for a
// single launch: CLAWGOD_FEATURE_<NAME>=false (feature id upper-cased,
// dashes → underscores, e.g. CLAWGOD_FEATURE_GEO_NEUTRALIZE=false; "true"
// restores a feature disabled in patches.json). CLAWGOD_FEATURES_META
// below is machine-generated by build.js from the FEATURES registry in
// patch.mjs (single source of truth) — do not hand-edit. Each gated patch in
// cli.original.cjs checks its own entry:
// globalThis.__clawgodPatches?.["<patchId>"] !== false
// Absent/failed config → gate table absent → all gates default ON, which is
// exactly the pre-toggle behavior.
// {{CLAWGOD:FEATURES_META}}
// Features realized by the wrapper itself rather than by a baked patch id:
// they read the same patches.json / CLAWGOD_FEATURE_* inputs, but their
// consumer is cli.cjs (or a module it loads) instead of a runtime gate in
// cli.original.cjs. Their gate lands in the same __clawgodPatches table.
var CLAWGOD_RUNTIME_FEATURES = ['bun-ant-shim'];
var clawgodDir = require('path').join(require('os').homedir(), '.clawgod');
var _cfg = {};
try { _cfg = JSON.parse(require('fs').readFileSync(require('path').join(clawgodDir, 'patches.json'), 'utf8')); } catch {}
for (var _name in process.env) {
if (_name.indexOf('CLAWGOD_FEATURE_') !== 0) continue;
var _val = process.env[_name];
if (_val !== 'true' && _val !== 'false') continue;
_cfg[_name.slice('CLAWGOD_FEATURE_'.length).toLowerCase().replace(/_/g, '-')] = _val === 'true';
}
// META keys are patch ids; a feature id is "known" when some patch lists it
// or when the wrapper owns it (CLAWGOD_RUNTIME_FEATURES).
// Unknown keys are residue (renamed/removed features).
for (var _k in _cfg) {
var _known = CLAWGOD_RUNTIME_FEATURES.indexOf(_k) >= 0;
for (var _f in CLAWGOD_FEATURES_META) {
if (CLAWGOD_FEATURES_META[_f].indexOf(_k) >= 0) { _known = true; break; }
}
if (!_known) process.stderr.write('[clawgod] warning: unknown feature "' + _k + '" in patches.json\n');
}
var _gate = {};
for (var _pid in CLAWGOD_FEATURES_META) {
var _feats = CLAWGOD_FEATURES_META[_pid];
var _on = false;
for (var _j = 0; _j < _feats.length; _j++) {
if (_cfg[_feats[_j]] !== false) { _on = true; break; }
}
_gate[_pid] = _on;
}
for (var _rfi = 0; _rfi < CLAWGOD_RUNTIME_FEATURES.length; _rfi++) {
_gate[CLAWGOD_RUNTIME_FEATURES[_rfi]] = _cfg[CLAWGOD_RUNTIME_FEATURES[_rfi]] !== false;
}
globalThis.__clawgodPatches = _gate;
+13
View File
@@ -0,0 +1,13 @@
{
"tengu_harbor": true,
"tengu_session_memory": true,
"tengu_amber_flint": true,
"tengu_auto_background_agents": true,
"tengu_destructive_command_warning": true,
"tengu_immediate_model_command": true,
"tengu_desktop_upsell": false,
"tengu_malort_pedway": {"enabled": true},
"tengu_amber_quartz_disabled": false,
"tengu_prompt_cache_1h_config": {"allowlist": ["*"]},
"tengu_amber_redwood3": "enabled"
}
+121
View File
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict';
import * as fs from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createRequire } from 'node:module';
import { runInNewContext } from 'node:vm';
const require = createRequire(import.meta.url);
const read = path => fs.readFileSync(new URL(path, import.meta.url), 'utf8');
const wrapper = read('./cli.cjs');
const unix = read('../templates/install.sh');
// Stay inside one shell single-quoted argument. Other node -e blocks (such
// as version recovery) can start with the same fs import as the Lean scripts.
const unixApply = unix.match(/node -e '\n([^']*)\n' "\$CLAUDE_SETTINGS" "\$LEAN_IS_MAX"/)?.[1];
const unixRemove = unix.match(/node -e '\n(const fs=require\("fs"\),p=process.argv\[1\];[^']*)\n' "\$CLAUDE_SETTINGS"/)?.[1];
assert.ok(unixApply, 'Lean apply script must be a single node -e argument');
assert.ok(unixRemove, 'Lean removal script must be a single node -e argument');
const home = fs.mkdtempSync(join(tmpdir(), 'clawgod-lean-test-'));
const dir = join(home, '.clawgod');
const settings = join(home, '.claude', 'settings.json');
const trafficKey = 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC';
const originalSettings = { theme: 'dark', permissions: { allow: ['Read'], deny: ['Bash(custom)'] } };
const loadSettings = () => JSON.parse(fs.readFileSync(settings, 'utf8'));
const saveSettings = s => fs.writeFileSync(settings, JSON.stringify(s));
function reset() {
fs.rmSync(dir, { recursive: true, force: true });
fs.mkdirSync(dir);
saveSettings(originalSettings);
}
// Execute the entire launcher with only HOME and its final bundle replaced.
// No credentials, user settings, network, or Claude installation are needed.
function launch(args = [], env = {}) {
const exit = Symbol('exit');
let loaded = false;
const proc = { argv: ['node', 'cli.cjs', ...args], env: { ...env }, execPath: '/test/bun',
stderr: { write() {} }, exit(code) { assert.equal(code, 0); throw exit; } };
try {
runInNewContext(wrapper, {
process: proc,
require(name) {
if (name === 'os') return { homedir: () => home };
if (name === './cli.original.cjs') { loaded = true; return {}; }
if (['./feature-gates.cjs', './runtime-helpers.cjs', './bun-ant-shim.cjs'].includes(name)) return {};
return require(name);
},
});
} catch (error) {
if (error !== exit) throw error;
}
assert.equal(loaded, args.length === 0);
return proc.env;
}
function checkMode(mode) {
const s = loadSettings();
assert.equal(s.disableRemoteControl, mode === 'max' ? true : undefined, mode);
assert.equal(s.disableBundledSkills, mode === 'max' ? true : undefined, mode);
assert.equal(s.disableWorkflows, mode === 'off' ? undefined : true, mode);
assert.equal(s.permissions.deny.includes('EnterPlanMode'), mode === 'max', mode);
assert.equal(s.permissions.deny.includes('CronCreate'), mode !== 'off', mode);
assert.equal(s.theme, 'dark');
assert.deepEqual(s.permissions.allow, ['Read']);
assert.ok(s.permissions.deny.includes('Bash(custom)'));
}
try {
fs.mkdirSync(join(home, '.claude'));
for (const [platform, apply, remove, maxArg] of [
['Unix', unixApply, unixRemove, 'true'],
// PowerShell interpolates its Boolean as "True", not "true".
['Windows', read('../windows/lean-apply.cjs'), read('../windows/lean-remove.cjs'), 'True'],
]) {
const run = (code, arg) => runInNewContext(code, { require, process: { argv: ['node', settings, arg] } });
reset();
run(apply, 'false');
checkMode('on');
// Upgrade an existing default installation from before #186.
saveSettings({ ...loadSettings(), disableRemoteControl: true });
run(apply, 'false');
checkMode('on');
run(apply, maxArg);
checkMode('max');
run(apply, maxArg);
checkMode('max');
run(apply, 'false');
checkMode('on');
run(apply, maxArg);
run(remove);
checkMode('off');
console.log(`[lean.test] ${platform} install, legacy migration, max/on/off transitions ok`);
}
for (const baseURL of ['https://api.anthropic.com', 'https://example.invalid']) {
reset();
fs.writeFileSync(join(dir, 'provider.json'), JSON.stringify({ baseURL }));
// Default startup must not disable the feature-flag service.
assert.equal(launch()[trafficKey], undefined);
saveSettings({ ...loadSettings(), disableRemoteControl: true });
for (const mode of ['on', 'max', 'on', 'max', 'off', 'on']) {
launch([`--lean-${mode}`]);
checkMode(mode);
assert.equal(fs.existsSync(join(dir, '.lean-max')), mode === 'max');
assert.equal(fs.existsSync(join(dir, '.lean-disabled')), mode === 'off');
assert.equal(launch()[trafficKey], mode === 'max' ? '1' : undefined);
checkMode(mode); // A proxy launch must not silently undo max settings.
for (const explicit of ['1', '0', '']) {
assert.equal(launch([], { [trafficKey]: explicit })[trafficKey], explicit);
}
assert.equal(launch([], { DISABLE_TELEMETRY: '1' }).DISABLE_TELEMETRY, '1');
}
// Existing per-setting opt-outs keep the documented absent-only behavior.
saveSettings({ ...loadSettings(), disableRemoteControl: false });
launch(['--lean-max']);
assert.equal(loadSettings().disableRemoteControl, false);
}
console.log('[lean.test] launcher modes, provider parity, and explicit environment settings ok');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
+343
View File
@@ -0,0 +1,343 @@
'use strict';
// Anthropic Messages API <-> OpenAI Chat Completions API translation proxy
// Allows Claude Code to use xAI/Grok and other OpenAI-compatible APIs
function textParts(content, context) {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) throw new Error(context + ' must be text or content blocks');
return content.map(function (block) {
if (block.type !== 'text') throw new Error(context + ': unsupported block ' + block.type);
return textValue(block.text);
}).join('\n');
}
function textValue(value) {
if (typeof value !== 'string') throw new Error('Text content must be a string');
return value;
}
function userPart(block) {
if (block.type === 'text') return { type: 'text', text: textValue(block.text) };
var source = block.source || {};
if (block.type === 'image') {
if (source.type === 'base64' && source.data && /^image\//.test(source.media_type))
return { type: 'image_url', image_url: { url: 'data:' + source.media_type + ';base64,' + source.data } };
if (source.type === 'url' && source.url) return { type: 'image_url', image_url: { url: source.url } };
throw new Error('Unsupported image source');
}
if (block.type === 'document') {
if (block.citations && block.citations.enabled) throw new Error('Document citations are not supported by openai-chat');
if (source.type === 'text') return { type: 'text', text: textValue(source.data) };
if (source.type === 'base64' && source.media_type === 'application/pdf' && source.data)
return { type: 'file', file: { filename: block.title || 'document.pdf', file_data: 'data:application/pdf;base64,' + source.data } };
throw new Error('Unsupported document source; use base64 PDF (requires upstream file support) or text');
}
throw new Error('Unsupported user content block: ' + block.type);
}
function translateMessages(msgs) {
if (!Array.isArray(msgs)) throw new Error('messages must be an array');
var out = [];
for (var msg of msgs) {
// Some clients supply system instructions inside the conversation rather
// than only in body.system. Chat Completions supports these directly;
// retain their position and never silently discard instruction content.
if (msg.role === 'system') {
out.push({ role: 'system', content: textParts(msg.content, 'System message') });
continue;
}
if (msg.role !== 'user' && msg.role !== 'assistant') throw new Error('Unsupported message role: ' + msg.role);
if (typeof msg.content === 'string') { out.push({ role: msg.role, content: msg.content }); continue; }
if (!Array.isArray(msg.content)) throw new Error('Message content must be text or content blocks');
if (msg.role === 'user') {
var parts = [];
for (var block of msg.content) {
if (block.type === 'tool_result') {
var text = block.content === undefined ? '' : textParts(block.content, 'Tool result');
out.push({ role: 'tool', tool_call_id: block.tool_use_id, content: (block.is_error ? '[ERROR] ' : '') + text });
} else parts.push(userPart(block));
}
// All tool results must precede the next user turn (including parallel calls).
if (parts.length) out.push({ role: 'user', content: parts.length === 1 && parts[0].type === 'text' ? parts[0].text : parts });
} else {
var textContent = '', toolCalls = [];
for (var b of msg.content) {
if (b.type === 'text') textContent += textValue(b.text);
else if (b.type === 'tool_use') toolCalls.push({ id: b.id, type: 'function', function: { name: b.name, arguments: typeof b.input === 'string' ? b.input : JSON.stringify(b.input) } });
// Anthropic thinking signatures cannot be replayed to Chat Completions.
else if (b.type !== 'thinking' && b.type !== 'redacted_thinking') throw new Error('Unsupported assistant content block: ' + b.type);
}
var assistantMsg = { role: 'assistant', content: textContent || null };
if (toolCalls.length) assistantMsg.tool_calls = toolCalls;
out.push(assistantMsg);
}
}
return out;
}
function translateRequest(body, configuredEffort) {
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Request must be an object');
var messages = body.system ? [{ role: 'system', content: textParts(body.system, 'System prompt') }] : [];
var result = { model: body.model, messages: messages.concat(translateMessages(body.messages || [])), stream: !!body.stream };
if (body.max_tokens !== undefined) result.max_tokens = body.max_tokens;
if (body.temperature !== undefined) result.temperature = body.temperature;
if (body.top_p !== undefined) result.top_p = body.top_p;
if (body.stop_sequences) result.stop = body.stop_sequences;
var effort = configuredEffort || (body.output_config && body.output_config.effort);
if (effort && effort !== 'auto') result.reasoning_effort = effort === 'max' ? 'xhigh' : effort;
if (body.tools && body.tools.length) result.tools = body.tools.map(function (tool) {
if (tool.type && tool.type !== 'custom') throw new Error('Unsupported tool type: ' + tool.type);
return { type: 'function', function: { name: tool.name, description: tool.description || '', parameters: tool.input_schema || { type: 'object', properties: {} } } };
});
if (body.tool_choice) {
var choice = body.tool_choice;
if (choice.type === 'tool' && choice.name) result.tool_choice = { type: 'function', function: { name: choice.name } };
else if (choice.type === 'any') result.tool_choice = 'required';
else if (choice.type === 'auto' || choice.type === 'none') result.tool_choice = choice.type;
else throw new Error('Unsupported tool_choice');
if (choice.disable_parallel_tool_use !== undefined) result.parallel_tool_calls = !choice.disable_parallel_tool_use;
}
if (body.output_config && body.output_config.format) throw new Error('Structured output format is not supported by openai-chat');
if (body.stream) result.stream_options = { include_usage: true };
// Build only protocol fields; never recursively strip keys from user tool data/schema.
return result;
}
// Deliberately local and approximate: text UTF-8 bytes / 3, image budget 1600,
// PDF decoded bytes / 3. Binary size is not a tokenizer or a PDF page count.
function estimateTokens(request) {
var bytes = 0, mediaTokens = 0;
function visit(value) {
if (typeof value === 'string') bytes += new TextEncoder().encode(value).length;
else if (Array.isArray(value)) value.forEach(visit);
else if (value && typeof value === 'object') {
if (value.type === 'image_url' && value.image_url) { mediaTokens += 1600; return; }
if (value.type === 'file' && value.file && typeof value.file.file_data === 'string' && value.file.file_data.startsWith('data:application/pdf;base64,')) {
mediaTokens += Math.ceil(value.file.file_data.split(',')[1].length / 4); return;
}
for (var key of Object.keys(value)) { bytes += key.length; visit(value[key]); }
}
}
visit(request.messages);
visit(request.tools);
return Math.max(1, Math.ceil(bytes / 3) + mediaTokens + request.messages.length * 4);
}
function mapFinishReason(reason, hasToolCalls) {
// Some compatible providers use stop even when they return tool_calls.
// Keep truncation/refusal distinct, but do not hide a completed tool turn.
if (reason === 'tool_calls' || (reason === 'stop' && hasToolCalls)) return 'tool_use';
if (reason === 'length') return 'max_tokens';
if (reason === 'content_filter') return 'refusal';
// Chat's stop does not distinguish natural stops from stop sequences.
if (reason === 'stop') return 'end_turn';
throw new Error('Unsupported upstream finish_reason: ' + reason);
}
function toolInput(argumentsText) {
var input = JSON.parse(argumentsText || '{}');
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Tool arguments must be a JSON object');
return input;
}
function usageOf(usage) {
return { input_tokens: (usage && usage.prompt_tokens) || 0, output_tokens: (usage && usage.completion_tokens) || 0 };
}
function translateResponse(response, model) {
if (response.error) throw new Error(response.error.message || 'Upstream API error');
var choice = response.choices && response.choices[0];
if (!choice || !choice.message || typeof choice.message !== 'object') throw new Error('No message in upstream response');
var content = [], message = choice.message;
if (message.content !== undefined && message.content !== null) content.push({ type: 'text', text: textValue(message.content) });
if (message.refusal) content.push({ type: 'text', text: textValue(message.refusal) });
for (var tc of message.tool_calls || []) {
if (!tc.id || !tc.function || !tc.function.name) throw new Error('Incomplete upstream tool call');
content.push({ type: 'tool_use', id: tc.id, name: tc.function.name, input: toolInput(tc.function.arguments) });
}
return { id: response.id || ('msg_' + Date.now()), type: 'message', role: 'assistant', content: content, model: model || response.model, stop_reason: mapFinishReason(choice.finish_reason, content.some(function (b) { return b.type === 'tool_use'; })), stop_sequence: null, usage: usageOf(response.usage) };
}
function sse(event, data) { return 'event: ' + event + '\ndata: ' + JSON.stringify(data) + '\n\n'; }
function createStreamTranslator(model) {
var started = false, finished = false, stopped = false, textIndex = null, nextIndex = 0;
var tools = new Map(), usage = usageOf(), reason;
return function (chunk) {
if (stopped) throw new Error('Data after stream end');
var events = [];
function emit(type, fields) { events.push(sse(type, { type: type, ...fields })); }
if (chunk === null) {
if (!finished) throw new Error('Upstream stream ended before finish_reason');
stopped = true;
emit('message_delta', { delta: { stop_reason: reason, stop_sequence: null }, usage: usage });
emit('message_stop', {});
return events;
}
if (chunk.error) throw new Error(chunk.error.message || 'Upstream stream error');
if (chunk.usage) usage = usageOf(chunk.usage);
var choice = chunk.choices && chunk.choices.find(function (c) { return c.index === undefined || c.index === 0; });
if (!choice) {
if (!Array.isArray(chunk.choices)) throw new Error('Invalid upstream stream chunk');
return events;
}
if (finished) throw new Error('Completion data after finish_reason');
if (!started) {
started = true;
emit('message_start', { message: { id: chunk.id || ('msg_' + Date.now()), type: 'message', role: 'assistant', content: [], model: model || chunk.model, stop_reason: null, stop_sequence: null, usage: usage } });
}
var delta = choice.delta || {};
var text = delta.content || delta.refusal;
if (text) {
textValue(text);
if (textIndex === null) { textIndex = nextIndex++; emit('content_block_start', { index: textIndex, content_block: { type: 'text', text: '' } }); }
emit('content_block_delta', { index: textIndex, delta: { type: 'text_delta', text: text } });
}
for (var tc of delta.tool_calls || []) {
if (!Number.isInteger(tc.index) || tc.index < 0) throw new Error('Invalid upstream tool index');
if (!tools.has(tc.index)) tools.set(tc.index, { id: '', name: '', args: '' });
var buf = tools.get(tc.index);
if (tc.id) buf.id += tc.id;
if (tc.function) { buf.name += tc.function.name || ''; buf.args += tc.function.arguments || ''; }
}
if (choice.finish_reason) {
reason = mapFinishReason(choice.finish_reason, tools.size > 0);
if (textIndex !== null) emit('content_block_stop', { index: textIndex });
// Buffer tools until complete so fragmented metadata and parallel calls
// produce valid, sequential Anthropic content blocks with validated JSON.
for (var tool of tools.values()) {
if (!tool.id || !tool.name) throw new Error('Incomplete upstream tool call');
toolInput(tool.args);
var index = nextIndex++;
emit('content_block_start', { index: index, content_block: { type: 'tool_use', id: tool.id, name: tool.name, input: {} } });
emit('content_block_delta', { index: index, delta: { type: 'input_json_delta', partial_json: tool.args || '{}' } });
emit('content_block_stop', { index: index });
}
finished = true;
}
return events;
};
}
// Parse complete SSE events, independent of transport chunk / UTF-8 boundaries.
async function* translateStream(reader, model) {
var decoder = new TextDecoder(), buffer = '', data = [], translate = createStreamTranslator(model);
function payload(line) {
if (line === '') { var result = data.length ? data.join('\n') : undefined; data = []; return result; }
if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
}
while (true) {
var read = await reader.read();
buffer += read.done ? decoder.decode() : decoder.decode(read.value, { stream: true });
if (read.done && buffer && !/[\r\n]$/.test(buffer)) buffer += '\n';
var match;
while ((match = /[\r\n]/.exec(buffer))) {
var offset = match.index;
if (!read.done && buffer[offset] === '\r' && offset === buffer.length - 1) break;
var line = buffer.slice(0, offset);
buffer = buffer.slice(offset + (buffer.slice(offset, offset + 2) === '\r\n' ? 2 : 1));
var value = payload(line);
if (value === undefined) continue;
if (value === '[DONE]') { yield* translate(null); return; }
yield* translate(JSON.parse(value));
}
if (read.done) {
if (data.length) {
var tail = data.join('\n');
if (tail !== '[DONE]') yield* translate(JSON.parse(tail));
}
yield* translate(null);
return;
}
}
}
function json(body, status, headers) {
return new Response(JSON.stringify(body), { status: status || 200, headers: { 'Content-Type': 'application/json', ...headers } });
}
function errorResponse(status, message) {
var types = { 400: 'invalid_request_error', 401: 'authentication_error', 403: 'permission_error', 404: 'not_found_error', 429: 'rate_limit_error' };
return json({ type: 'error', error: { type: types[status] || 'api_error', message: message } }, status);
}
function startProxy(config) {
var upstreamURL = new URL(config.baseURL || 'https://api.x.ai/v1');
if (!['http:', 'https:'].includes(upstreamURL.protocol) || upstreamURL.username || upstreamURL.password || upstreamURL.search || upstreamURL.hash)
throw new Error('OpenAI baseURL must be an HTTP(S) API base without credentials, query, or fragment');
upstreamURL.pathname = upstreamURL.pathname.replace(/\/+$/, '') + '/chat/completions';
var server = Bun.serve({
port: 0, hostname: '127.0.0.1', idleTimeout: 255,
fetch: async function (req) {
var path = new URL(req.url).pathname;
if (req.method === 'GET' && path === '/health') return new Response('ok');
var count = path === '/v1/messages/count_tokens' || path === '/messages/count_tokens';
if (req.method !== 'POST' || (!count && path !== '/v1/messages' && path !== '/messages')) return errorResponse(404, 'Not found');
var body, request;
try {
body = await req.json();
request = translateRequest(body, config.effort);
request.model = body.model || config.model || '';
} catch (e) { return errorResponse(400, 'Translation error: ' + e.message); }
if (count) return json({ input_tokens: estimateTokens(request) }, 200, { 'x-clawgod-token-count': 'estimate' });
var abort = new AbortController();
var onAbort = function () { abort.abort(req.signal.reason); };
req.signal.addEventListener('abort', onAbort, { once: true });
if (req.signal.aborted) onAbort();
var timeout = Number(config.timeoutMs) || 3000000;
var timer = setTimeout(function () { abort.abort(new Error('Upstream request timed out')); }, timeout);
if (timer.unref) timer.unref();
function cleanup() { clearTimeout(timer); req.signal.removeEventListener('abort', onAbort); }
var upstream;
try {
upstream = await fetch(upstreamURL.href, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + config.apiKey }, body: JSON.stringify(request), signal: abort.signal });
if (!upstream.ok) {
var errText = await upstream.text(), errBody;
try { errBody = JSON.parse(errText); } catch {}
var response = errorResponse(upstream.status, (errBody && errBody.error && errBody.error.message) || errText || ('HTTP ' + upstream.status));
var retryAfter = upstream.headers.get('retry-after');
if (retryAfter) response.headers.set('retry-after', retryAfter);
cleanup();
return response;
}
if (!request.stream) {
var result = translateResponse(await upstream.json(), request.model);
cleanup();
return json(result);
}
if (!upstream.body || !(upstream.headers.get('content-type') || '').toLowerCase().startsWith('text/event-stream'))
throw new Error('Expected an upstream text/event-stream response');
} catch (e) {
abort.abort();
cleanup();
return errorResponse(502, 'Upstream request failed: ' + e.message);
}
var reader = upstream.body.getReader(), iterator = translateStream(reader, request.model), cancelled = false;
var encoder = new TextEncoder();
async function closeReader() { try { await reader.cancel(); } catch {} cleanup(); }
var readable = new ReadableStream({
async pull(controller) {
try {
var next = await iterator.next();
if (cancelled) return;
if (next.done) { await closeReader(); controller.close(); }
else controller.enqueue(encoder.encode(next.value));
} catch (e) {
if (!cancelled) {
controller.enqueue(encoder.encode(sse('error', { type: 'error', error: { type: 'api_error', message: 'Stream error: ' + e.message } })));
controller.close();
}
abort.abort();
await closeReader();
}
},
async cancel() { cancelled = true; abort.abort(); await closeReader(); await iterator.return(); },
});
return new Response(readable, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' } });
},
});
return { port: server.port, stop: function () { server.stop(); } };
}
module.exports = { startProxy: startProxy };
@@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { createRequire } from 'node:module';
const { startProxy } = createRequire(import.meta.url)('./openai-proxy.cjs');
test('Bun HTTP integration: tool round trip, streaming usage, errors, count and timeout', { skip: typeof Bun === 'undefined' }, async () => {
const calls = [];
let mode = 'tool';
let toolFinish = 'tool_calls';
const upstream = Bun.serve({
hostname: '127.0.0.1', port: 0,
async fetch(req) {
assert.equal(new URL(req.url).pathname, '/v1/chat/completions');
assert.equal(req.headers.get('authorization'), 'Bearer fixture-key');
const body = await req.json();
calls.push(body);
if (mode === 'tool' || mode === 'text') {
assert.deepEqual(body.messages.slice(0, 2), [
{ role: 'system', content: 'top-level instructions' },
{ role: 'system', content: 'inline instructions\nadditional context' },
]);
}
if (mode === 'error') return Response.json({ error: { message: 'slow down' } }, { status: 429, headers: { 'retry-after': '2' } });
if (mode === 'timeout') return new Response(new ReadableStream({
start(c) { c.enqueue(new TextEncoder().encode(':waiting\n\n')); },
}), { headers: { 'content-type': 'text/event-stream' } });
const tool = mode === 'tool';
const message = tool ? { content: null, tool_calls: [{ id: 'call-1', type: 'function', function: { name: 'read_file', arguments: '{"path":"README.md"}' } }] } : { content: 'file read' };
const finish_reason = tool ? toolFinish : 'stop';
const usage = { prompt_tokens: 42, completion_tokens: 9 };
if (!body.stream) return Response.json({ id: 'fixture', choices: [{ message, finish_reason }], usage });
const delta = tool ? { tool_calls: message.tool_calls.map(t => ({ ...t, index: 0 })) } : message;
const chunks = [
{ id: 'fixture', choices: [{ delta, finish_reason: null, index: 0 }] },
{ id: 'fixture', choices: [{ delta: {}, finish_reason, index: 0 }] },
{ choices: [], usage },
];
// The proxy must preserve events across actual HTTP byte boundaries.
const wire = new TextEncoder().encode(chunks.map(c => `data: ${JSON.stringify(c)}\n\n`).join('') + 'data: [DONE]\n\n');
let offset = 0;
return new Response(new ReadableStream({
pull(c) {
if (offset >= wire.length) return c.close();
c.enqueue(wire.slice(offset, offset + 5)); offset += 5;
},
}), { headers: { 'content-type': 'text/event-stream' } });
},
});
const config = { baseURL: `http://127.0.0.1:${upstream.port}/v1`, apiKey: 'fixture-key' };
const proxy = startProxy(config);
let timedProxy;
async function send(body, path = '/v1/messages', server = proxy) {
return fetch(`http://127.0.0.1:${server.port}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
}
try {
for (const finish of ['tool_calls', 'stop']) for (const stream of [false, true]) {
toolFinish = finish;
mode = 'tool';
const first = { model: 'fixture-model', system: 'top-level instructions', messages: [
{ role: 'system', content: [{ type: 'text', text: 'inline instructions', cache_control: { type: 'ephemeral' } }, { type: 'text', text: 'additional context' }] },
{ role: 'user', content: 'Read README.md' },
], tools: [{ name: 'read_file', input_schema: { type: 'object', properties: { path: { type: 'string' } } } }], tool_choice: { type: 'tool', name: 'read_file' }, max_tokens: 100, stream };
const r = await send(first);
assert.equal(r.status, 200);
let content;
if (stream) {
const events = (await r.text()).split('\n').filter(l => l.startsWith('data: ')).map(l => JSON.parse(l.slice(6)));
assert.equal(events.at(-1).type, 'message_stop');
assert.equal(events.at(-2).delta.stop_reason, 'tool_use');
assert.deepEqual(events.at(-2).usage, { input_tokens: 42, output_tokens: 9 });
content = events.filter(e => e.type === 'content_block_start').map(e => e.content_block);
content[0].input = JSON.parse(events.find(e => e.delta?.type === 'input_json_delta').delta.partial_json);
} else {
const message = await r.json();
assert.equal(message.stop_reason, 'tool_use');
content = message.content;
}
assert.deepEqual(content, [{ type: 'tool_use', id: 'call-1', name: 'read_file', input: { path: 'README.md' } }]);
mode = 'text';
const second = await send({ ...first, stream: false, tool_choice: { type: 'auto' }, messages: [...first.messages, { role: 'assistant', content }, { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call-1', content: 'README contents' }] }] });
assert.equal((await second.json()).content[0].text, 'file read');
assert.deepEqual(calls.at(-1).messages.at(-1), { role: 'tool', tool_call_id: 'call-1', content: 'README contents' });
const beforeCount = calls.length;
const count = await send(first, '/v1/messages/count_tokens');
assert.equal(count.headers.get('x-clawgod-token-count'), 'estimate');
assert.ok((await count.json()).input_tokens > 0);
assert.equal(calls.length, beforeCount);
mode = 'error';
const error = await send(first);
assert.equal(error.status, 429);
assert.equal(error.headers.get('retry-after'), '2');
assert.equal((await error.json()).error.type, 'rate_limit_error');
}
mode = 'timeout';
timedProxy = startProxy({ ...config, timeoutMs: 500 });
const timed = await send({ model: 'fixture', stream: true, messages: [{ role: 'user', content: 'hi' }] }, '/v1/messages', timedProxy);
assert.equal(timed.status, 200);
const text = await timed.text();
assert.match(text, /event: error/);
assert.doesNotMatch(text, /event: message_stop/);
} finally {
proxy.stop();
timedProxy?.stop();
upstream.stop(true);
}
});
+407
View File
@@ -0,0 +1,407 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { test } from 'node:test';
const source = readFileSync(new URL('./openai-proxy.cjs', import.meta.url), 'utf8');
const success = (extra = {}) => ({ id: 'chat-fixture', model: 'upstream-model', choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], usage: { prompt_tokens: 123, completion_tokens: 7 }, ...extra });
const jsonResponse = (body = success(), options) => new Response(JSON.stringify(body), options);
const chunk = (delta = {}, finish_reason = null) => ({ id: 'chat-fixture', choices: [{ index: 0, delta, finish_reason }] });
const usageChunk = { choices: [], usage: { prompt_tokens: 123, completion_tokens: 7 } };
const encodeEvents = (chunks, done = true) => chunks.map(c => `data: ${JSON.stringify(c)}\n\n`).join('') + (done ? 'data: [DONE]\n\n' : '');
function streamResponse(text, byteSize = Infinity, onCancel = () => {}) {
const bytes = new TextEncoder().encode(text);
let offset = 0;
return new Response(new ReadableStream({
pull(controller) {
if (offset >= bytes.length) return controller.close();
controller.enqueue(bytes.slice(offset, offset + byteSize));
offset += byteSize;
},
cancel: onCancel,
}), { headers: { 'Content-Type': 'text/event-stream; charset=utf-8' } });
}
function setup(upstream = () => jsonResponse(), config = {}) {
let handler, stopped = false;
const calls = [], module = { exports: {} };
runInNewContext(source, {
module, URL, Response, ReadableStream, TextEncoder, TextDecoder, AbortController, setTimeout, clearTimeout,
Bun: { serve(options) { handler = options.fetch; return { port: 12345, stop() { stopped = true; } }; } },
async fetch(url, options) { calls.push({ url, ...options, body: JSON.parse(options.body) }); return upstream(options); },
});
const proxy = module.exports.startProxy({ baseURL: 'https://upstream.invalid/v1/', apiKey: 'test-key', model: 'fallback-model', ...config });
return {
calls, proxy, isStopped: () => stopped,
send(body = {}, path = '/v1/messages', options = {}) {
return handler(new Request('http://127.0.0.1:12345' + path, {
method: 'POST', body: JSON.stringify({ model: 'custom-model', messages: [{ role: 'user', content: 'hello' }], max_tokens: 100, ...body }), ...options,
}));
},
};
}
async function readEvents(response) {
assert.equal(response.status, 200);
assert.match(response.headers.get('content-type'), /^text\/event-stream/);
const text = await response.text();
return text.split('\n').filter(line => line.startsWith('data: ')).map(line => JSON.parse(line.slice(6)));
}
function checkLifecycle(events) {
assert.equal(events[0].type, 'message_start');
assert.equal(events.filter(e => e.type === 'message_start').length, 1);
assert.equal(events.at(-2).type, 'message_delta');
assert.equal(events.at(-1).type, 'message_stop');
assert.equal(events.filter(e => e.type === 'message_stop').length, 1);
let active = null, nextIndex = 0;
for (const event of events) {
if (event.type === 'content_block_start') {
assert.equal(active, null);
assert.equal(event.index, nextIndex++);
active = event.index;
} else if (event.type === 'content_block_delta') assert.equal(event.index, active);
else if (event.type === 'content_block_stop') { assert.equal(event.index, active); active = null; }
}
assert.equal(active, null);
}
test('request translation preserves conversation, parallel tool results and user schema/data', async () => {
const p = setup();
const toolData = { cache_control: 'actual user data', nested: { cache_control: 'keep' } };
const schema = { type: 'object', properties: { cache_control: { type: 'string' } }, required: ['cache_control'] };
const r = await p.send({
system: [{ type: 'text', text: 'first', cache_control: { type: 'ephemeral' } }, { type: 'text', text: 'second' }],
messages: [
{ role: 'user', content: 'run tools' },
{ role: 'assistant', content: [{ type: 'thinking', thinking: 'private', signature: 'opaque' }, { type: 'text', text: 'running' }, { type: 'tool_use', id: 'a', name: 'one', input: toolData }, { type: 'tool_use', id: 'b', name: 'two', input: {} }] },
{ role: 'user', content: [{ type: 'text', text: 'continue' }, { type: 'tool_result', tool_use_id: 'a', content: [{ type: 'text', text: 'output' }] }, { type: 'tool_result', tool_use_id: 'b', content: 'failed', is_error: true }] },
],
tools: [{ name: 'one', description: 'a tool', input_schema: schema, cache_control: { type: 'ephemeral' } }],
temperature: 0, top_p: 0.9, stop_sequences: ['END'],
});
assert.equal(r.status, 200);
const call = p.calls[0], body = call.body;
assert.equal(call.url, 'https://upstream.invalid/v1/chat/completions');
assert.equal(call.headers.Authorization, 'Bearer test-key');
assert.deepEqual(body.messages.map(m => m.role), ['system', 'user', 'assistant', 'tool', 'tool', 'user']);
assert.equal(body.messages[0].content, 'first\nsecond');
assert.equal(body.messages[2].content, 'running');
assert.deepEqual(JSON.parse(body.messages[2].tool_calls[0].function.arguments), toolData);
assert.deepEqual(body.tools[0].function.parameters, schema);
assert.equal(body.messages[3].tool_call_id, 'a');
assert.equal(body.messages[4].content, '[ERROR] failed');
assert.equal(body.messages[5].content, 'continue');
assert.deepEqual([body.temperature, body.top_p, body.stop], [0, 0.9, ['END']]);
assert.equal(body.messages[0].cache_control, undefined);
assert.equal(body.tools[0].cache_control, undefined);
});
test('inline system messages preserve instructions and order alongside top-level system', async () => {
for (const stream of [false, true]) {
for (const system of [undefined, 'top-level instructions', [{ type: 'text', text: 'top-level instructions', cache_control: { type: 'ephemeral' } }]]) {
const p = setup(() => stream ? streamResponse(encodeEvents([chunk({ content: 'ok' }), chunk({}, 'stop'), usageChunk])) : jsonResponse());
const response = await p.send({ system, stream, messages: [
{ role: 'system', content: 'initial instructions' },
{ role: 'user', content: 'hello' },
{ role: 'assistant', content: 'hi' },
{ role: 'system', content: [{ type: 'text', text: 'follow-up instructions', cache_control: { type: 'ephemeral' } }, { type: 'text', text: 'more context' }] },
{ role: 'user', content: 'continue' },
] });
assert.equal(response.status, 200);
if (stream) checkLifecycle(await readEvents(response));
else assert.equal((await response.json()).content[0].text, 'ok');
assert.deepEqual(p.calls[0].body.messages, [
...(system ? [{ role: 'system', content: 'top-level instructions' }] : []),
{ role: 'system', content: 'initial instructions' },
{ role: 'user', content: 'hello' },
{ role: 'assistant', content: 'hi' },
{ role: 'system', content: 'follow-up instructions\nmore context' },
{ role: 'user', content: 'continue' },
]);
}
}
});
test('inline system messages contribute to local token counts without upstream calls', async () => {
const p = setup(() => { throw new Error('must not call upstream'); });
const messages = [{ role: 'user', content: 'hello' }];
const baseline = await (await p.send({ messages }, '/v1/messages/count_tokens')).json();
for (const content of ['instruction '.repeat(100), [{ type: 'text', text: 'instruction '.repeat(100) }]]) {
const response = await p.send({ messages: [{ role: 'system', content }, ...messages] }, '/v1/messages/count_tokens');
assert.equal(response.status, 200);
assert.ok((await response.json()).input_tokens > baseline.input_tokens);
}
assert.equal(p.calls.length, 0);
});
test('tool_choice and parallel constraints map without changing omitted defaults', async () => {
for (const [type, expected] of [['auto', 'auto'], ['any', 'required'], ['none', 'none'], ['tool', { type: 'function', function: { name: 'run' } }]]) {
for (const disable of [true, false, undefined]) {
const p = setup();
await p.send({ tool_choice: { type, name: 'run', disable_parallel_tool_use: disable } });
assert.deepEqual(p.calls[0].body.tool_choice, expected);
assert.equal(p.calls[0].body.parallel_tool_calls, disable === undefined ? undefined : !disable);
}
}
const p = setup();
await p.send();
assert.equal(p.calls[0].body.tool_choice, undefined);
});
test('base64/URL images, PDF files and text documents retain their content', async () => {
const p = setup();
await p.send({ messages: [{ role: 'user', content: [
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'aGVsbG8=' } },
{ type: 'image', source: { type: 'url', url: 'https://example.invalid/image.png' } },
{ type: 'document', title: 'manual.pdf', source: { type: 'base64', media_type: 'application/pdf', data: 'JVBERg==' } },
{ type: 'document', source: { type: 'text', data: 'document text' } },
] }] });
assert.deepEqual(p.calls[0].body.messages[0].content, [
{ type: 'image_url', image_url: { url: 'data:image/png;base64,aGVsbG8=' } },
{ type: 'image_url', image_url: { url: 'https://example.invalid/image.png' } },
{ type: 'file', file: { filename: 'manual.pdf', file_data: 'data:application/pdf;base64,JVBERg==' } },
{ type: 'text', text: 'document text' },
]);
});
test('unsupported attachments, citations, tools and malformed requests fail before fetch', async () => {
for (const fields of [
{ messages: [{ role: 'user', content: [{ type: 'document', source: { type: 'url', url: 'https://example.invalid/a.pdf' } }] }] },
{ messages: [{ role: 'user', content: [{ type: 'document', citations: { enabled: true }, source: { type: 'text', data: 'text' } }] }] },
{ messages: [{ role: 'user', content: [{ type: 'image', source: { type: 'file' } }] }] },
{ messages: [{ role: 'user', content: [{ type: 'audio' }] }] },
{ messages: [{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'a', content: [{ type: 'image' }] }] }] },
{ messages: [{ role: 'assistant', content: [{ type: 'server_tool_use' }] }] },
{ messages: [{ role: 'unknown', content: 'text' }] },
{ messages: [{ role: 'system', content: null }] },
{ messages: [{ role: 'system', content: [{ type: 'text' }] }] },
{ messages: [{ role: 'system', content: [{ type: 'image', source: { type: 'url', url: 'https://example.invalid/a.png' } }] }] },
{ messages: [{ role: 'user', content: 7 }] },
{ messages: [{ role: 'user', content: [{ type: 'text' }] }] },
{ messages: 'invalid' }, { system: [{ type: 'image' }] },
{ tools: [{ type: 'web_search_20250305', name: 'web_search' }] },
{ tool_choice: { type: 'bad' } }, { output_config: { format: { type: 'json_schema' } } },
]) {
const p = setup();
const r = await p.send(fields);
assert.equal(r.status, 400, JSON.stringify(fields));
assert.equal((await r.json()).error.type, 'invalid_request_error');
assert.equal(p.calls.length, 0);
}
for (const body of ['{', 'null', '[]']) {
const p = setup();
assert.equal((await p.send({}, '/v1/messages', { body })).status, 400);
assert.equal(p.calls.length, 0);
}
});
test('count_tokens is local, deterministic, includes system/tools/media and excludes output budget', async () => {
const p = setup(() => { throw new Error('must not call upstream'); });
async function count(fields = {}) {
const r = await p.send(fields, '/v1/messages/count_tokens');
assert.equal(r.status, 200);
assert.equal(r.headers.get('x-clawgod-token-count'), 'estimate');
const n = (await r.json()).input_tokens;
assert.ok(Number.isInteger(n) && n > 0);
return n;
}
const baseline = await count();
assert.equal(await count({ max_tokens: 10000 }), baseline);
assert.equal(await count(), baseline);
assert.ok(await count({ system: 'a'.repeat(1000) }) > baseline);
assert.ok(await count({ messages: [{ role: 'user', content: '你好'.repeat(100) }] }) > baseline);
assert.ok(await count({ tools: [{ name: 'run', input_schema: { type: 'object', properties: { parameter: { type: 'string' } } } }] }) > baseline);
// Schema/business fields named like media must not be interpreted as attachments.
assert.ok(await count({ tools: [{ name: 'run', input_schema: { type: 'object', properties: { type: { const: 'file' } }, examples: [{ type: 'file' }] } }] }) > baseline);
for (const block of [
{ type: 'image', source: { type: 'url', url: 'https://example.invalid/a.png' } },
{ type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: 'AAAA'.repeat(500) } },
]) assert.ok(await count({ messages: [{ role: 'user', content: [block] }] }) > baseline);
assert.equal(p.calls.length, 0);
});
test('non-stream text, tool JSON, refusal, usage, model fallback and finish reasons', async () => {
for (const [reason, expected] of [['stop', 'tool_use'], ['length', 'max_tokens'], ['tool_calls', 'tool_use'], ['content_filter', 'refusal']]) {
const p = setup(() => jsonResponse(success({ choices: [{ message: { content: 'text', tool_calls: [{ id: 'call-1', function: { name: 'run', arguments: '{"x":1}' } }] }, finish_reason: reason }] })));
const result = await (await p.send({ model: '' })).json();
assert.equal(p.calls[0].body.model, 'fallback-model');
assert.equal(result.model, 'fallback-model');
assert.equal(result.stop_reason, expected);
assert.equal(result.stop_sequence, null);
assert.deepEqual(result.usage, { input_tokens: 123, output_tokens: 7 });
assert.deepEqual(result.content[1], { type: 'tool_use', id: 'call-1', name: 'run', input: { x: 1 } });
}
const p = setup(() => jsonResponse(success({ choices: [{ message: { content: null, refusal: 'cannot comply' }, finish_reason: 'stop' }] })));
assert.equal((await (await p.send()).json()).content[0].text, 'cannot comply');
});
test('malformed non-stream responses never become successful fabricated text/tool calls', async () => {
for (const body of [
{}, { choices: [] }, { error: { message: 'provider failure' } },
success({ choices: [{ message: 'invalid', finish_reason: 'stop' }] }),
success({ choices: [{ message: { content: [{ type: 'text', text: 'invalid' }] }, finish_reason: 'stop' }] }),
success({ choices: [{ message: { content: 'ok' }, finish_reason: 'unknown' }] }),
...['{', '[]', 'null', '42'].map(argumentsText => success({ choices: [{ message: { tool_calls: [{ id: 'a', function: { name: 'run', arguments: argumentsText } }] }, finish_reason: 'tool_calls' }] })),
success({ choices: [{ message: { tool_calls: [{ function: { name: 'run', arguments: '{}' } }] }, finish_reason: 'tool_calls' }] }),
]) {
const p = setup(() => jsonResponse(body));
assert.equal((await p.send()).status, 502);
}
assert.equal((await setup(() => new Response('bad json')).send()).status, 502);
});
test('upstream HTTP errors preserve status/message/retry-after for both streaming modes', async () => {
for (const stream of [false, true]) for (const status of [400, 401, 403, 404, 429, 500, 503]) {
const p = setup(() => jsonResponse({ error: { message: 'provider error' } }, { status, headers: { 'retry-after': '3' } }));
const r = await p.send({ stream });
assert.equal(r.status, status);
assert.equal(r.headers.get('retry-after'), '3');
const result = await r.json();
assert.equal(result.type, 'error');
assert.equal(result.error.message, 'provider error');
if (status === 429) assert.equal(result.error.type, 'rate_limit_error');
}
const r = await setup(() => new Response('bad gateway', { status: 502 })).send({ stream: true });
assert.equal((await r.json()).error.message, 'bad gateway');
assert.equal((await setup(() => { throw new Error('offline'); }).send()).status, 502);
});
test('SSE usage trailer arrives before the single final message_delta/message_stop', async () => {
for (const size of [1, 7, Infinity]) for (const done of [true, false]) {
const p = setup(() => streamResponse(encodeEvents([chunk({ role: 'assistant' }), chunk({ content: '你好🙂' }), chunk({}, 'stop'), usageChunk], done), size));
const events = await readEvents(await p.send({ stream: true }));
checkLifecycle(events);
assert.equal(events.find(e => e.type === 'content_block_delta').delta.text, '你好🙂');
assert.deepEqual(events.at(-2).usage, { input_tokens: 123, output_tokens: 7 });
assert.equal(p.calls[0].body.stream_options.include_usage, true);
}
});
test('SSE accepts CRLF, bare CR, no-space data, comments, multiline JSON and unterminated final event', async () => {
for (const newline of ['\r\n', '\r', '\n']) {
const event = 'data:' + JSON.stringify(chunk({ content: 'ok' })).replace(',"choices"', newline + 'data:,"choices"') + newline + newline;
const wire = ':heartbeat' + newline + newline + event + 'data:' + JSON.stringify(chunk({}, 'stop')) + newline + newline + 'data:[DONE]';
const p = setup(() => streamResponse(wire, 1));
checkLifecycle(await readEvents(await p.send({ stream: true })));
}
});
test('SSE handles empty completion, missing usage, inline usage and all supported finish reasons', async () => {
for (const [reason, expected] of [['stop', 'end_turn'], ['length', 'max_tokens'], ['content_filter', 'refusal']]) {
for (const inline of [false, true]) {
const final = chunk({}, reason);
if (inline) final.usage = usageChunk.usage;
const p = setup(() => streamResponse(encodeEvents([final])));
const events = await readEvents(await p.send({ stream: true }));
checkLifecycle(events);
assert.equal(events.at(-2).delta.stop_reason, expected);
assert.deepEqual(events.at(-2).usage, inline ? { input_tokens: 123, output_tokens: 7 } : { input_tokens: 0, output_tokens: 0 });
}
}
});
test('SSE reassembles interleaved parallel tool arguments and fragmented metadata', async () => {
const chunks = [
chunk({ content: 'running' }),
chunk({ tool_calls: [{ index: 1, id: 'call-', function: { name: 'sec', arguments: '{"b":' } }, { index: 0, id: 'first', function: { name: 'first', arguments: '{"a":' } }] }),
chunk({ tool_calls: [{ index: 0, function: { arguments: '1}' } }, { index: 1, id: 'second', function: { name: 'ond', arguments: '2}' } }] }),
chunk({}, 'tool_calls'), usageChunk,
];
const events = await readEvents(await setup(() => streamResponse(encodeEvents(chunks), 3)).send({ stream: true }));
checkLifecycle(events);
const starts = events.filter(e => e.type === 'content_block_start' && e.content_block.type === 'tool_use');
assert.deepEqual(starts.map(e => [e.content_block.id, e.content_block.name]), [['call-second', 'second'], ['first', 'first']]);
const args = events.filter(e => e.delta?.type === 'input_json_delta').map(e => JSON.parse(e.delta.partial_json));
assert.deepEqual(args, [{ b: 2 }, { a: 1 }]);
assert.equal(events.at(-2).delta.stop_reason, 'tool_use');
});
test('SSE tool calls with upstream stop still request tool execution', async () => {
for (const [finish, expected] of [['stop', 'tool_use'], ['length', 'max_tokens'], ['content_filter', 'refusal']]) {
const chunks = [chunk({ tool_calls: [{ index: 0, id: 'call-1', function: { name: 'run', arguments: '{"x":1}' } }] }), chunk({}, finish), usageChunk];
const events = await readEvents(await setup(() => streamResponse(encodeEvents(chunks))).send({ stream: true }));
checkLifecycle(events);
assert.equal(events.at(-2).delta.stop_reason, expected);
}
});
test('SSE malformed/truncated/error streams emit error without success completion', async () => {
const cases = [
'', 'data: not-json\n\n', 'data: [DONE]\n\n',
encodeEvents([chunk({ content: 'partial' })], false),
encodeEvents([chunk({ content: 'partial' }), { error: { message: 'stream failed' } }]),
encodeEvents([chunk({}, 'bad-reason')]),
encodeEvents([chunk({ content: { invalid: true } }), chunk({}, 'stop')]),
encodeEvents([chunk({ tool_calls: [{ index: 0, function: { arguments: '{}' } }] }), chunk({}, 'tool_calls')]),
encodeEvents([chunk({ tool_calls: [{ index: 0, id: 'a', function: { name: 'run', arguments: '{' } }] }), chunk({}, 'tool_calls')]),
encodeEvents([chunk({ tool_calls: [{ index: -1 }] })]),
encodeEvents([chunk({}, 'stop'), chunk({ content: 'late' })]),
];
for (const wire of cases) {
const events = await readEvents(await setup(() => streamResponse(wire)).send({ stream: true }));
assert.equal(events.at(-1).type, 'error', wire);
assert.ok(!events.some(e => e.type === 'message_stop'), wire);
}
const p = setup(() => new Response(new ReadableStream({ pull(c) { c.error(new Error('socket closed')); } }), { headers: { 'content-type': 'text/event-stream' } }));
const events = await readEvents(await p.send({ stream: true }));
assert.match(events.at(-1).error.message, /socket closed/);
});
test('streaming rejects HTTP 200 JSON instead of returning an empty successful stream', async () => {
assert.equal((await setup().send({ stream: true })).status, 502);
});
test('text is delivered before upstream finishes and downstream cancellation aborts upstream', async () => {
let controller, cancelled = false;
const p = setup(() => new Response(new ReadableStream({
start(c) { controller = c; }, cancel() { cancelled = true; },
}), { headers: { 'content-type': 'text/event-stream' } }));
const response = await p.send({ stream: true });
const reader = response.body.getReader();
controller.enqueue(new TextEncoder().encode(encodeEvents([chunk({ content: 'early' })], false)));
let seen = '';
while (!seen.includes('early')) seen += new TextDecoder().decode((await reader.read()).value);
assert.ok(!seen.includes('message_stop'));
await reader.cancel();
assert.equal(cancelled, true);
assert.equal(p.calls[0].signal.aborted, true);
});
test('client abort and timeout propagate to fetch; timeout also spans response streaming', async () => {
function waitForAbort(options) {
return new Promise((_, reject) => {
if (options.signal.aborted) reject(options.signal.reason);
else options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true });
});
}
const client = new AbortController(), p = setup(waitForAbort);
const pending = p.send({}, '/v1/messages', { signal: client.signal });
client.abort(new Error('client left'));
assert.equal((await pending).status, 502);
assert.equal(p.calls[0].signal.aborted, true);
// Keep the test process alive while the production timer is unref'ed.
const hold = setTimeout(() => {}, 2000);
try {
const timeout = setup(waitForAbort, { timeoutMs: 10 });
assert.equal((await timeout.send()).status, 502);
assert.equal(timeout.calls[0].signal.aborted, true);
const streaming = setup(options => new Response(new ReadableStream({
start(c) { options.signal.addEventListener('abort', () => c.error(new Error('timed out')), { once: true }); },
}), { headers: { 'content-type': 'text/event-stream' } }), { timeoutMs: 10 });
const events = await readEvents(await streaming.send({ stream: true }));
assert.equal(events.at(-1).type, 'error');
assert.match(events.at(-1).error.message, /timed out/);
} finally { clearTimeout(hold); }
});
test('exact routes, base paths and proxy lifecycle', async () => {
const p = setup(undefined, { baseURL: 'https://upstream.invalid/custom/v1///' });
assert.equal((await p.send({}, '/anything/messages')).status, 404);
assert.equal((await p.send({}, '/v1/messages', { method: 'GET', body: undefined })).status, 404);
assert.equal((await p.send({}, '/health', { method: 'GET', body: undefined })).status, 200);
await p.send({}, '/messages');
assert.equal(p.calls[0].url, 'https://upstream.invalid/custom/v1/chat/completions');
assert.equal((await p.send({}, '/messages/count_tokens')).status, 200);
p.proxy.stop();
assert.equal(p.isStopped(), true);
for (const baseURL of ['ftp://example.invalid', 'https://user:pass@example.invalid/v1', 'https://example.invalid/v1?key=x', 'https://example.invalid/v1#fragment'])
assert.throws(() => setup(undefined, { baseURL }), /baseURL/);
});
+1021
View File
File diff suppressed because it is too large Load Diff
+147
View File
@@ -0,0 +1,147 @@
// Patch regression tests, including classifierTimeoutFloor — the parser behind the
// classifier-timeout patch (see runtime-helpers.cjs). Gating
// (globalThis.__clawgodPatches?.["classifier-timeout"]) and the
// CLAWGOD_CLASSIFIER_TIMEOUT_MS read live in the injected patch code; the
// helper only parses/validates the raw value and returns null on failure.
// This test pins both the parser and the exact gate composition the patch
// emits.
//
// Coverage (per PR review): legal values, illegal values (Infinity, overflow,
// non-numeric, blank, unset), and the classifier-tuning-gate-off case.
// Run: node src/shared/patch.test.mjs (wired into CI build-sources)
import { classifierTimeoutFloor as floor } from './runtime-helpers.cjs';
import assert from 'node:assert/strict';
import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync, spawnSync } from 'node:child_process';
import { runInNewContext } from 'node:vm';
// legal values → parsed as the floor (injected Math.max applies it)
assert.equal(floor('200000'), 200000);
assert.equal(floor('5000'), 5000);
assert.equal(floor('-100000'), -100000);
// "0" is a legitimate override, not a failure
assert.equal(floor('0'), 0);
// parse failure → null (caller keeps the original formula)
assert.equal(floor('Infinity'), null);
assert.equal(floor('1e309'), null);
assert.equal(floor('abc'), null);
assert.equal(floor(''), null);
assert.equal(floor(' '), null);
assert.equal(floor(undefined), null);
// Gate composition as the patch emits it: gate ? floor(env) : null, then
// _ct===null ? formula : Math.max(formula, _ct). Null (gate off or parse
// failure) keeps the original formula; a real number — a legitimate "0"
// included — is applied as a floor. No 0 sentinel.
const patchRes = (env, gateOn, formula) => {
const _ct = gateOn ? floor(env) : null;
return _ct === null ? formula : Math.max(formula, _ct);
};
// gate on, legal → floor applied (defeats the formula/cap when larger)
assert.equal(patchRes('200000', true, 100), 200000);
// gate on, floor below the formula → formula kept
assert.equal(patchRes('5000', true, 80000), 80000);
// gate on, legal "0" → neutral floor, formula kept (0 is not a failure)
assert.equal(patchRes('0', true, 80000), 80000);
assert.equal(patchRes('0', true, 0), 0);
// gate off → env ignored even when legal
assert.equal(patchRes('200000', false, 100), 100);
// gate on, parse failure (null) → original formula kept
assert.equal(patchRes('Infinity', true, 80000), 80000);
assert.equal(patchRes('1e309', true, 80000), 80000);
assert.equal(patchRes('', true, 80000), 80000);
assert.equal(patchRes(undefined, true, 80000), 80000);
console.log('[patch.test] classifierTimeoutFloor semantics ok');
// Exercise the real patcher against legacy bundles and the 2.1.268 chunk
// layout (issues #175–177). Keep metadata and neighboring commands intact,
// and evaluate the emitted gate with the feature both enabled and disabled.
const ultraplanFixtures = [
{ label: 'legacy literal', description: 'description:`Draft a plan`', original: '!1' },
{ label: 'getter and flag helper', description: 'get description(){return`Draft a plan (${estimate()})`}', original: '$flag()' },
{ label: '2.1.268 availability', description: 'get description(){return`Draft a plan (${estimate()})`}', original: '$flag()', metadata: 'availability:["claude-ai"],' },
];
const testDir = mkdtempSync(join(tmpdir(), 'clawgod-patch-test-'));
try {
copyFileSync(new URL('./patch.mjs', import.meta.url), join(testDir, 'patch.mjs'));
for (const graph of [false, true]) {
if (graph) mkdirSync(join(testDir, 'bunfs'));
for (const fixture of ultraplanFixtures) {
const source = `var command={type:"local-jsx",name:"ultraplan",${fixture.description},argumentHint:"<prompt>",${fixture.metadata || ''}isEnabled:()=>${fixture.original},policyGate:policy,load:()=>load()};var neighbor={name:"other",argumentHint:"<prompt>",isEnabled:()=>!1};`;
const target = join(testDir, graph ? 'bunfs/commands.js' : 'cli.original.cjs');
if (graph) writeFileSync(join(testDir, 'cli.original.cjs'), '// entry');
writeFileSync(target, source);
const output = execFileSync(process.execPath, [join(testDir, 'patch.mjs')], { encoding: 'utf8' });
assert.match(output, /Ultraplan enable \(1 replacement in 1 file\)/, fixture.label);
const patched = readFileSync(target, 'utf8');
const enabled = `isEnabled:()=>${fixture.original}`;
assert.equal(patched, source.replace(enabled, `isEnabled:()=>(globalThis.__clawgodPatches?.["ultraplan"]!==!1?!0:${fixture.original})`));
for (const toggle of [undefined, true, false]) {
for (const upstream of [false, true]) {
let calls = 0;
const context = {
$flag: () => { calls++; return upstream; },
estimate: () => 'a few minutes', policy: () => false, load: () => 'loaded',
...(toggle === undefined ? {} : { __clawgodPatches: { ultraplan: toggle } }),
};
const result = runInNewContext(`${patched};[command.isEnabled(),neighbor.isEnabled(),command.description,command.policyGate(),command.load()]`, context);
const originalValue = fixture.original === '!1' ? false : upstream;
assert.equal(result[0], toggle === false ? originalValue : true, fixture.label);
assert.equal(result[1], false);
assert.match(result[2], /^Draft a plan/);
assert.equal(result[3], false);
assert.equal(result[4], 'loaded');
assert.equal(calls, toggle === false && fixture.original !== '!1' ? 1 : 0);
}
}
}
// An unsupported shape must stay visible as a failure, even if a nearby
// command happens to contain the old argumentHint/isEnabled sequence.
for (const body of [
'description:"Future gate",argumentHint:"<prompt>",newMetadata:!0,isEnabled:()=>$flag()',
'description:"Cloud command stub"',
]) {
const source = `var command={name:"ultraplan",${body}};var neighbor={name:"other",argumentHint:"<prompt>",isEnabled:()=>!1};`;
const target = join(testDir, graph ? 'bunfs/commands.js' : 'cli.original.cjs');
writeFileSync(target, source);
const result = spawnSync(process.execPath, [join(testDir, 'patch.mjs')], { encoding: 'utf8' });
assert.equal(result.status, 1);
assert.match(result.stdout, /Ultraplan enable — regex stale/);
assert.equal(readFileSync(target, 'utf8'), source);
}
}
// The provider check moved into a no-argument helper in Claude 2.1.280.
// Both forms must keep the upstream restriction when the feature is off.
for (const graph of [false, true]) {
rmSync(join(testDir, 'bunfs'), { recursive: true, force: true });
if (graph) mkdirSync(join(testDir, 'bunfs'));
for (const [label, condition] of [
['inline provider', 'provider!=="firstParty"&&!isAws(provider)&&(model==="claude-opus-4-6"||model==="claude-sonnet-4-6"||model.includes("haiku"))'],
['provider helper', 'isThirdParty()&&(model==="claude-opus-4-6"||model==="claude-sonnet-4-6"||model.includes("haiku"))'],
]) {
const source = `function isThirdParty(){return provider!=="firstParty"&&!isAws(provider)}function isAws(value){return value==="anthropicAws"}function supports(model){if(${condition})return!1;return!0}`;
const target = join(testDir, graph ? 'bunfs/auto-mode.js' : 'cli.original.cjs');
if (graph) writeFileSync(join(testDir, 'cli.original.cjs'), '// entry');
writeFileSync(target, source);
const output = execFileSync(process.execPath, [join(testDir, 'patch.mjs')], { encoding: 'utf8' });
assert.match(output, /Auto-mode unlock for third-party API \(inline gate\) \(1 replacement in 1 file\)/, label);
const patched = readFileSync(target, 'utf8');
for (const toggle of [false, true]) {
for (const provider of ['firstParty', 'anthropicAws', 'proxy']) {
const context = { provider, __clawgodPatches: { 'auto-mode-inline-gate': toggle } };
const result = runInNewContext(`${patched};supports("claude-sonnet-4-6")`, context);
assert.equal(result, toggle || provider !== 'proxy', `${label}: ${provider}, toggle ${toggle}`);
}
}
}
}
} finally {
rmSync(testDir, { recursive: true, force: true });
}
console.log('[patch.test] ultraplan bundle/graph compatibility and toggle semantics ok');
console.log('[patch.test] auto-mode provider gate compatibility and toggle semantics ok');
+92
View File
@@ -0,0 +1,92 @@
import { readFileSync, writeFileSync, unlinkSync, existsSync, readdirSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const here = dirname(fileURLToPath(import.meta.url));
const src = `${here}/cli.original.js`;
const dst = `${here}/cli.original.cjs`;
const pathMapFile = `${here}/pathmap.json`;
let code = readFileSync(src, 'utf8');
// v2.1.245+ splits the app into an ESM chunk graph; post-process then
// rewrites the whole bunfs/ dir too. Legacy single-bundle has no pathmap.
const isChunked = existsSync(pathMapFile);
// (0) Strip leading @bun pragma comments (e.g. "// @bun @bytecode @bun-cjs\n")
// Bun requires the file to start directly with "(function" (CJS) or the
// first import (ESM) — any preceding comment breaks that detection.
function stripPragma(c) { return c.replace(/^(?:\/\/[^\n]*\n)+/, ''); }
// build-time fileURLToPath() leaks → use cli.cjs's own __filename
function fixFileURLs(c) {
return c.replace(
/[\w$]+\.fileURLToPath\("file:\/\/\/home\/runner\/work\/claude-cli-internal\/claude-cli-internal\/[^"]*"\)/g,
() => '__filename',
);
}
if (isChunked) {
// ── v2.1.245+ ESM chunk graph path ──
const pathMap = JSON.parse(readFileSync(pathMapFile, 'utf8'));
// build the replace table: /$bunfs/root/X → <here>/<relative-on-disk>
const replaceTable = new Map();
for (const [bunPath, rel] of Object.entries(pathMap)) {
replaceTable.set(bunPath, join(here, rel));
}
function rewriteGraph(text) {
// Replace string literals containing the virtual in-bundle root
// (POSIX "/$bunfs/root/..." or Windows single-drive "B:/~BUN/root/...")
// with the on-disk absolute path from the replace table.
return text.replace(/["'`](?:\/\$bunfs\/root|[A-Za-z]:\/~BUN\/root)\/[^"'`]+["'`]/g, (m) => {
const body = m.slice(1, -1);
const target = replaceTable.get(body) || replaceTable.get(body.replaceAll('\\','/'));
// JSON.stringify emits a valid JS string literal. This is essential on
// Windows, where path.join() returns backslashes that would otherwise be
// interpreted as escapes (for example, \b in "\bunfs").
return target ? JSON.stringify(target) : m;
});
}
// entry → cli.original.cjs (ESM, no IIFE wrap)
code = stripPragma(code);
code = rewriteGraph(code);
code = fixFileURLs(code);
writeFileSync(dst, code);
unlinkSync(src);
// rewrite every chunk/asset file in bunfs/ in place
const bunfsDir = join(here, 'bunfs');
let n = 0;
for (const f of readdirSync(bunfsDir)) {
if (!f.endsWith('.js') && !f.endsWith('.mjs')) continue;
const fp = join(bunfsDir, f);
let fc = readFileSync(fp, 'utf8');
fc = stripPragma(fc);
fc = rewriteGraph(fc);
fc = fixFileURLs(fc);
writeFileSync(fp, fc);
n++;
}
console.log(`cli.original.cjs: ${code.length} bytes (chunked, rewrote ${n} graph files)`);
} else {
// ── Legacy single-bundle path ──
code = stripPragma(code);
// (1) bunfs .node module paths → runtime vendor lookup
code = code.replace(
/require\(['"](\/\$bunfs\/root\/([\w-]+)\.node)['"]\)/g,
(m, _full, name) =>
`require(require('path').join(__dirname,'vendor',${JSON.stringify(name)},\`\${process.arch==='arm64'?'arm64':'x64'}-\${process.platform==='darwin'?'darwin':process.platform==='linux'?'linux':'win32'}\`,${JSON.stringify(name + '.node')}))`,
);
code = fixFileURLs(code);
// (3) make the outer (function(...){...}) actually run
code = code.replace(/\}\)\s*$/, '})(exports, require, module, __filename, __dirname)');
writeFileSync(dst, code);
unlinkSync(src);
console.log(`cli.original.cjs: ${code.length} bytes`);
}
+143
View File
@@ -0,0 +1,143 @@
import assert from 'node:assert/strict';
import * as fs from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createRequire } from 'node:module';
import { runInNewContext } from 'node:vm';
const require = createRequire(import.meta.url);
const read = file => fs.readFileSync(new URL(file, import.meta.url), 'utf8');
const launcher = read('./cli.cjs');
const proxy = read('./openai-proxy.cjs');
const home = fs.mkdtempSync(join(tmpdir(), 'clawgod-provider-test-'));
const dir = join(home, '.clawgod');
fs.mkdirSync(dir);
// Run the real launcher and proxy handler; only HOME, Bun's listener, the
// final Claude bundle, and upstream HTTP transport are replaced.
function launch(config = {}, env = {}) {
fs.writeFileSync(join(dir, 'provider.json'), JSON.stringify(config));
let handler, loaded = false;
const requests = [];
const proc = {
argv: ['node', 'cli.cjs'], env: { ...env }, execPath: '/test/bun',
stderr: { write() {} }, on() {},
};
const proxyModule = { exports: {} };
runInNewContext(proxy, {
module: proxyModule, URL, Response, ReadableStream, TextEncoder, TextDecoder,
AbortController, setTimeout, clearTimeout,
Bun: { serve(options) { handler = options.fetch; return { port: 12345, stop() {} }; } },
async fetch(url, options) {
const body = JSON.parse(options.body);
requests.push({ url, headers: options.headers, body });
if (body.stream) {
const chunks = [
{ id: 'fixture', choices: [{ delta: { content: 'ok' }, finish_reason: null }] },
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
];
return new Response(chunks.map(chunk => `data: ${JSON.stringify(chunk)}\n\n`).join('') + 'data: [DONE]\n\n', { headers: { 'Content-Type': 'text/event-stream' } });
}
return new Response(JSON.stringify({
id: 'fixture', choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1 },
}));
},
});
runInNewContext(launcher, {
process: proc,
require(name) {
if (name === 'os') return { homedir: () => home };
if (name === './openai-proxy.cjs') return proxyModule.exports;
if (name === './cli.original.cjs') { loaded = true; return {}; }
if (['./feature-gates.cjs', './runtime-helpers.cjs', './bun-ant-shim.cjs'].includes(name)) return {};
return require(name);
},
});
assert.ok(loaded, 'launcher must reach the Claude bundle');
return {
env: proc.env,
async send(fields = {}) {
assert.ok(handler, 'proxy must be started');
const body = { model: 'custom-model-alias', messages: [{ role: 'user', content: 'hello' }], max_tokens: 100, ...fields };
const response = await handler(new Request('http://127.0.0.1:12345/v1/messages', {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer proxy-passthrough' },
body: JSON.stringify(body),
}));
assert.equal(response.status, 200);
if (body.stream) assert.match(await response.text(), /event: message_stop/);
else assert.equal((await response.json()).content[0].text, 'ok');
return requests.at(-1);
},
};
}
try {
const custom = { apiKey: 'fixture-key', baseURL: 'https://gateway.invalid', effort: 'high' };
for (const token of [undefined, '', ' ', '\t', 'fixture-env-token']) {
const env = { ANTHROPIC_API_KEY: 'stale-key' };
if (token !== undefined) env.ANTHROPIC_AUTH_TOKEN = token;
const result = launch(custom, env).env;
assert.equal(result.ANTHROPIC_API_KEY, undefined);
assert.equal(result.ANTHROPIC_AUTH_TOKEN, token?.trim() || custom.apiKey);
assert.equal(result.CLAUDE_CODE_EFFORT_LEVEL, 'high');
}
const official = launch({ apiKey: 'official-key' }, { ANTHROPIC_AUTH_TOKEN: 'stale-token' }).env;
assert.equal(official.ANTHROPIC_API_KEY, 'official-key');
assert.equal(official.ANTHROPIC_AUTH_TOKEN, undefined);
assert.equal(launch({}, { ANTHROPIC_AUTH_TOKEN: 'external-token' }).env.ANTHROPIC_AUTH_TOKEN, 'external-token');
console.log('[provider.test] direct provider auth, blank tokens, and OAuth preservation ok');
for (const type of ['grok', 'openai-compat', 'openai-chat']) {
const selector = type === 'openai-chat' ? { protocol: type } : { type };
const config = { ...selector, apiKey: 'fixture-key', baseURL: 'https://upstream.invalid/v1', model: 'custom-model-alias', smallModel: 'small-alias', timeoutMs: 4321 };
const session = launch({ ...config, effort: 'low' }, { ANTHROPIC_API_KEY: 'stale-key', ANTHROPIC_AUTH_TOKEN: 'stale-token' });
assert.equal(session.env.ANTHROPIC_API_KEY, undefined);
assert.equal(session.env.ANTHROPIC_AUTH_TOKEN, 'proxy-passthrough');
assert.equal(session.env.ANTHROPIC_BASE_URL, 'http://127.0.0.1:12345');
assert.equal(session.env.ANTHROPIC_MODEL, config.model);
assert.equal(session.env.ANTHROPIC_SMALL_FAST_MODEL, config.smallModel);
assert.equal(session.env.API_TIMEOUT_MS, '4321');
assert.equal(session.env.CLAUDE_CODE_EFFORT_LEVEL, 'low');
// Unknown model aliases may make Claude omit output_config altogether.
const request = await session.send();
assert.equal(request.url, 'https://upstream.invalid/v1/chat/completions');
assert.equal(request.headers.Authorization, 'Bearer fixture-key');
assert.equal(request.body.reasoning_effort, 'low');
for (const stream of [false, true]) {
for (const [input, expected] of [['low', 'low'], ['medium', 'medium'], ['high', 'high'], ['xhigh', 'xhigh'], ['max', 'xhigh'], ['auto', undefined]]) {
const translated = await launch(config).send({ stream, output_config: { effort: input } });
assert.equal(translated.body.reasoning_effort, expected, `${type}: request effort ${input}, stream=${stream}`);
assert.equal(translated.body.output_config, undefined);
const fallback = await launch({ ...config, effort: input }).send({ stream });
assert.equal(fallback.body.reasoning_effort, expected, `${type}: configured effort ${input}, stream=${stream}`);
}
}
const explicit = launch({ ...config, effort: 'high' }, { CLAUDE_CODE_EFFORT_LEVEL: 'low', API_TIMEOUT_MS: '9876' });
assert.equal(explicit.env.API_TIMEOUT_MS, '9876');
assert.equal(explicit.env.CLAUDE_CODE_EFFORT_LEVEL, 'low');
assert.equal((await explicit.send({ output_config: { effort: 'high' } })).body.reasoning_effort, 'low');
const automatic = launch({ ...config, effort: 'high' }, { CLAUDE_CODE_EFFORT_LEVEL: 'auto' });
assert.equal((await automatic.send({ output_config: { effort: 'high' } })).body.reasoning_effort, undefined);
const empty = launch({ ...config, effort: 'high' }, { CLAUDE_CODE_EFFORT_LEVEL: '' });
assert.equal((await empty.send()).body.reasoning_effort, undefined);
assert.equal((await empty.send({ output_config: { effort: 'medium' } })).body.reasoning_effort, 'medium');
assert.equal((await launch(config).send()).body.reasoning_effort, undefined);
}
const grok = launch({ type: 'grok', baseURL: 'https://api.x.ai/v1', effort: 'low' }, { GROK_API_KEY: 'grok-env-key' });
const grokRequest = await grok.send();
assert.equal(grokRequest.headers.Authorization, 'Bearer grok-env-key');
assert.equal(grokRequest.body.reasoning_effort, 'low');
assert.equal((await launch({ type: 'grok' }, { GROK_API_KEY: 'test' }).send()).url, 'https://api.x.ai/v1/chat/completions');
const direct = launch({ ...custom, type: 'openai-compat', protocol: 'anthropic' });
assert.equal(direct.env.ANTHROPIC_BASE_URL, custom.baseURL);
for (const protocol of ['auto', 'openai-responses', '', null, 'typo']) {
assert.throws(() => launch({ ...custom, protocol }), /Unsupported provider protocol/);
}
assert.throws(() => launch({ protocol: 'openai-chat', apiKey: 'test' }), /explicit baseURL/);
assert.throws(() => launch({ protocol: 'openai-chat', baseURL: 'https://upstream.invalid/v1' }), /requires an API key/);
console.log('[provider.test] proxy auth, effort translation/fallback/precedence, streaming, and defaults ok');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bun
// Re-extract + post-process + patch the user's currently-installed
// native Claude binary. Invoked by cli.cjs when it detects that
// .source-version no longer matches the latest binary in versions/.
import { spawnSync } from 'child_process';
import { writeFileSync, existsSync, mkdirSync, rmSync } from 'fs';
import { dirname, join, basename } from 'path';
import { fileURLToPath } from 'url';
const here = dirname(fileURLToPath(import.meta.url));
const nativeBin = process.argv[2];
if (!nativeBin || !existsSync(nativeBin)) {
console.error('repatch: native binary path required and must exist');
process.exit(1);
}
rmSync(join(here, 'vendor'), { recursive: true, force: true });
rmSync(join(here, 'bunfs'), { recursive: true, force: true });
rmSync(join(here, 'pathmap.json'), { force: true });
rmSync(join(here, 'cli.original.js'), { force: true });
const runtime = process.execPath;
function run(label, args) {
const r = spawnSync(runtime, args, { cwd: here, stdio: 'inherit' });
if (r.status !== 0) {
console.error(`repatch: ${label} failed (exit ${r.status})`);
process.exit(1);
}
}
const extractor = join(here, 'extract-natives.mjs');
const postProc = join(here, 'post-process.mjs');
const patcher = join(here, 'patch.mjs');
run('extract', [extractor, nativeBin, here]);
run('post-process', [postProc]);
run('clean source backup', [patcher, '--capture-clean-source']);
run('patcher', [patcher]);
writeFileSync(join(here, '.source-version'), basename(nativeBin) + '\n');
console.log(`[clawgod] re-patched to ${basename(nativeBin)}`);
+54
View File
@@ -0,0 +1,54 @@
'use strict';
// Runtime helpers shared by injected patches, exposed on
// globalThis.__clawgodHelpers. The patched cli.original.cjs lives in its own
// module scope, so it reaches these helpers only through globalThis.
//
// cli.cjs requires this module once at launch; after that, adding a new
// helper means editing this single file — no build.js / cli.cjs / template
// changes. (feature-gates.cjs is a future merge target here.)
//
// Helpers return values only: injected code owns gating, env reads, timers,
// and mutation of the renderer's state.
// Parses a CLAWGOD_CLASSIFIER_TIMEOUT_MS value to a finite number, or null
// when it cannot be parsed (missing/blank/non-numeric/Infinity/overflow). The
// caller decides the fallback: the injected patch code checks for null
// explicitly and keeps the original formula, while any real number — a
// legitimate "0" included — is applied as a floor. Returning null (not 0)
// keeps "0" as a real override and never conflates it with a parse failure.
function classifierTimeoutFloor(envValue) {
if (typeof envValue === 'string' && envValue.trim() === '') return null;
const value = Number(envValue);
return Number.isFinite(value) ? value : null;
}
// A DA1 reply can arrive in multiple stdin reads (issue #171). The renderer
// normally flushes incomplete escapes after 50ms, which turns a split ESC [
// into a key and lets the reply's suffix reach the text input. While a DA1
// sentinel is outstanding, allow the same 2s inter-read budget upstream uses
// for recognized control-sequence prefixes. This also covers 2.1.263, whose
// longer-prefix handling predates that upstream fix.
//
// Do not filter input: ordinary keys, paste, and complete sequences still go
// through the existing parser immediately. A lone Escape or Alt+[ during a
// pending probe is delayed, then passed to the original parser on timeout.
// With no outstanding probe, even those keys retain their usual timing.
function terminalReplyDelay(querier, reader, now) {
const parse = reader?.parse;
if (parse?.mode !== 'NORMAL' || typeof parse.incomplete !== 'string') return 0;
if (!/^\x1b(?:\[(?:\?[\d;]*)?)?$/.test(parse.incomplete)) return 0;
// 2.1.263 writes queue entries immediately. 2.1.274 adds `written`, and
// may queue probes before stdin attaches; those cannot have replies yet.
if (!Array.isArray(querier?.queue) || !querier.queue.some((entry) =>
entry?.kind === 'barrier' || (entry?.kind === 'sentinel' && entry.written !== false))) return 0;
if (!Number.isFinite(now) || !Number.isFinite(reader.lastInputAt)) return 0;
return Math.max(0, 2000 - Math.max(0, now - reader.lastInputAt));
}
// The runtime container (globalThis.__clawgodHelpers) IS the module's own
// exports, so a new helper only needs an export line here to be reachable
// from the patched bundle — no separate registration object. We expose
// module.exports, not module (the latter carries id/filename/paths metadata).
module.exports.classifierTimeoutFloor = classifierTimeoutFloor;
module.exports.terminalReplyDelay = terminalReplyDelay;
globalThis.__clawgodHelpers = module.exports;
+122
View File
@@ -0,0 +1,122 @@
import assert from 'node:assert/strict';
import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
const root = mkdtempSync(join(tmpdir(), 'clawgod-source-backup-'));
const command = '.command("update").alias("upgrade").description("Update").action(s(async(A)=>{original()}))';
const colors = 'const color="rgb(215,119,87)";';
try {
for (const graph of [false, true]) {
const dir = join(root, graph ? 'graph' : 'legacy');
mkdirSync(dir);
copyFileSync(new URL('./patch.mjs', import.meta.url), join(dir, 'patch.mjs'));
const entry = join(dir, 'cli.original.cjs');
if (graph) mkdirSync(join(dir, 'bunfs'));
const target = graph ? join(dir, 'bunfs/commands.js') : entry;
const snapshot = join(dir, 'source-backup.json');
const writeSource = (version, body = command) => {
writeFileSync(entry, `// Version: ${version}\n${graph ? '// entry' : body + colors}`);
if (graph) {
writeFileSync(target, body);
writeFileSync(join(dir, 'bunfs/colors.mjs'), colors);
writeFileSync(join(dir, 'bunfs/asset.txt'), 'unchanged asset');
}
};
const run = (...args) => spawnSync(process.execPath, [join(dir, 'patch.mjs'), ...args], { encoding: 'utf8' });
const succeeds = (...args) => {
const result = run(...args);
assert.equal(result.status, 0, result.stderr + result.stdout);
return result.stdout;
};
writeSource('2.1.283');
const clean = readFileSync(target, 'utf8');
succeeds('--capture-clean-source');
const backup = readFileSync(snapshot, 'utf8');
const first = succeeds();
const patched = readFileSync(target, 'utf8');
assert.match(patched, /clawgod self-update/);
assert.notEqual(patched, clean);
for (let attempt = 0; attempt < 3; attempt++) {
const output = succeeds();
assert.equal(output.match(/Result: .*/)[0], first.match(/Result: .*/)[0]);
assert.equal(readFileSync(target, 'utf8'), patched, 'no nested or duplicate patches');
assert.equal(readFileSync(snapshot, 'utf8'), backup, 'clean backup stays clean');
}
succeeds('--revert');
assert.equal(readFileSync(target, 'utf8'), clean);
if (graph) assert.equal(readFileSync(join(dir, 'bunfs/colors.mjs'), 'utf8'), colors);
succeeds();
assert.equal(readFileSync(target, 'utf8'), patched);
writeFileSync(snapshot, '{truncated');
assert.equal(run().status, 1);
assert.equal(readFileSync(target, 'utf8'), patched);
writeFileSync(snapshot, backup);
if (graph) {
writeFileSync(join(dir, 'bunfs/extra.js'), '// mismatched graph');
assert.equal(run().status, 1);
assert.equal(readFileSync(target, 'utf8'), patched);
rmSync(join(dir, 'bunfs/extra.js'));
}
// A fresh extraction changes the version and must replace the old backup.
writeSource('2.1.284');
assert.equal(run().status, 1, 'reject mismatched backup version');
succeeds('--capture-clean-source');
assert.equal(JSON.parse(readFileSync(snapshot)).version, '2.1.284');
assert.match(readFileSync(entry + '.bak', 'utf8'), /Version: 2.1.284/);
succeeds();
// A valid patch alongside a stale one must not leave a partial installation.
rmSync(snapshot);
rmSync(entry + '.bak');
writeSource('2.1.284', 'const unsupported=\'.command("update").alias("upgrade")\';');
const before = readFileSync(target, 'utf8');
for (const args of [[], ['--dry-run']]) {
const failed = run(...args);
assert.equal(failed.status, 1);
assert.match(failed.stdout, /regex stale/);
assert.equal(readFileSync(target, 'utf8'), before);
assert.equal(existsSync(entry + '.bak'), false);
if (graph) assert.equal(readFileSync(join(dir, 'bunfs/colors.mjs'), 'utf8'), colors);
}
}
if (process.platform !== 'win32') {
// Exercise the real shell preflight and failure propagation without a
// download or a Claude/Bun installation.
const template = readFileSync(new URL('../templates/install.sh', import.meta.url), 'utf8');
const start = template.indexOf('mkdir -p "$CLAWGOD_DIR" "$BIN_DIR"', template.indexOf('# ─── Handle --no-upgrade'));
const selection = template.slice(start, template.indexOf('if [ "$NO_UPGRADE" != "1" ]; then', start));
assert.ok(selection.includes('Recovering clean source'));
const dir = join(root, 'shell');
mkdirSync(dir);
const env = { ...process.env, CLAWGOD_DIR: dir, BIN_DIR: join(dir, 'bin'), NO_UPGRADE: '1', VERSION: 'latest' };
const shell = body => spawnSync('bash', ['-c', 'set -e\ninfo() { echo "$*"; }; warn() { echo "$*"; }; dim() { :; };\n' + body], { env, encoding: 'utf8' });
writeFileSync(join(dir, 'cli.original.cjs'), '// Version: 2.1.272');
writeFileSync(join(dir, '.source-version'), '2.1.283\n');
let result = shell(selection + '\necho "CHOICE=$NO_UPGRADE,$VERSION"');
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /CHOICE=0,2.1.283/);
rmSync(join(dir, '.source-version'));
result = shell(selection + '\necho "CHOICE=$NO_UPGRADE,$VERSION"');
assert.match(result.stdout, /CHOICE=0,2.1.272/);
writeFileSync(join(dir, 'source-backup.json'), '{}');
result = shell(selection + '\necho "CHOICE=$NO_UPGRADE,$VERSION"');
assert.match(result.stdout, /CHOICE=1,latest/);
rmSync(join(dir, 'source-backup.json'));
writeFileSync(join(dir, 'cli.original.cjs'), '// unknown version');
assert.equal(shell(selection).status, 1);
writeFileSync(join(dir, 'patch.mjs'), 'process.exit(7)');
const applyStart = template.indexOf('# ─── Apply patches');
const apply = template.slice(applyStart, template.indexOf('# ─── Report which renderer', applyStart));
result = shell(apply + '\necho UNEXPECTED_SUCCESS');
assert.equal(result.status, 7);
assert.match(result.stdout, /Installation aborted/);
assert.doesNotMatch(result.stdout, /UNEXPECTED_SUCCESS/);
}
console.log('[source-backup.test] complete backups, repeated patching, revert, validation, upgrades, and failure without source writes passed');
} finally {
rmSync(root, { recursive: true, force: true });
}
+81
View File
@@ -0,0 +1,81 @@
'use strict';
// Bounded installer probe shared by PowerShell 5.1 and POSIX shells. Running
// under Node keeps the watchdog independent of the Bun runtime being checked.
const { spawn, spawnSync } = require('node:child_process');
const { writeFileSync } = require('node:fs');
const { dirname, join } = require('node:path');
const [bun, cli] = process.argv.slice(2);
const timeoutMs = Number(process.env.CLAWGOD_STARTUP_TIMEOUT_MS || 30000);
if (!bun || !cli || !Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647) {
process.stderr.write('Usage: node startup-check.cjs <bun> <cli.cjs>; CLAWGOD_STARTUP_TIMEOUT_MS must be a positive integer <= 2147483647.\n');
process.exitCode = 1;
} else {
const logPath = join(dirname(cli), 'startup-check.log');
const limit = 1024 * 1024;
let output = Buffer.alloc(0), truncated = false, finished = false;
const versionPattern = /^\d+\.\d+\.\d+[^\r\n]*\(Claude Code\)\s*$/;
let stdoutLine = '', sawVersion = false;
const child = spawn(bun, [cli, '--version'], {
stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
detached: process.platform !== 'win32',
});
const capture = chunk => {
output = Buffer.concat([output, chunk]);
if (output.length > limit) { output = output.subarray(output.length - limit); truncated = true; }
};
child.stdout.on('data', chunk => {
capture(chunk);
// Detect stdout lines independently of stderr ordering and the bounded log
// tail. A warning must neither fabricate nor erase a successful version.
const lines = (stdoutLine + chunk.toString('utf8')).split('\n');
stdoutLine = lines.pop().slice(-4096);
if (lines.some(line => versionPattern.test(line))) sawVersion = true;
});
child.stderr.on('data', capture);
function finish(code, message) {
if (finished) return;
finished = true;
clearTimeout(timer);
const text = (truncated ? '[Earlier startup output truncated]\n' : '') + output.toString('utf8');
const diagnostic = message ? '\n[clawgod] ' + message + '\n' : '';
let logWritten = false;
try { writeFileSync(logPath, text + diagnostic); logWritten = true; }
catch (error) {
process.stderr.write('[clawgod] Cannot write startup log: ' + error.message + '\n');
code = code || 1;
}
if (text) process.stdout.write(text);
if (diagnostic) process.stderr.write(diagnostic);
if (code && logWritten) process.stderr.write('[clawgod] Startup log: ' + logPath + '\n');
process.exitCode = code;
}
const timer = setTimeout(() => {
// Kill only the probe's process tree, never the parent Claude session that
// invoked `claude update`. Closing our pipes also bounds inherited handles.
if (child.pid) {
if (process.platform === 'win32') {
spawnSync('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
stdio: 'ignore', windowsHide: true, timeout: 2000,
});
} else {
try { process.kill(-child.pid, 'SIGKILL'); } catch {}
}
try { child.kill('SIGKILL'); } catch {}
}
child.stdout.destroy();
child.stderr.destroy();
child.unref();
finish(124, 'Startup verification timed out after ' + timeoutMs + ' ms. The Bun probe did not finish; launcher installation was aborted. If this machine needs more time, increase CLAWGOD_STARTUP_TIMEOUT_MS and retry.');
}, timeoutMs);
child.on('error', error => finish(1, 'Could not start Bun: ' + error.message));
child.on('close', (code, signal) => {
if (code !== 0) finish(code || 1, 'Patched Claude startup failed' + (signal ? ' (' + signal + ')' : ' (exit ' + code + ')') + '.');
else if (!sawVersion && !versionPattern.test(stdoutLine))
finish(1, 'Bun exited successfully but did not print a Claude Code version.');
else finish(0);
});
}
+191
View File
@@ -0,0 +1,191 @@
import assert from 'node:assert/strict';
import * as fs from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { spawnSync } from 'node:child_process';
import { runInNewContext } from 'node:vm';
const require = createRequire(import.meta.url);
const sourceDir = dirname(fileURLToPath(import.meta.url));
const checker = join(sourceDir, 'startup-check.cjs');
const wrapper = fs.readFileSync(join(sourceDir, 'cli.cjs'), 'utf8');
const root = fs.mkdtempSync(join(tmpdir(), 'clawgod startup test-'));
const dir = join(root, 'profile space \u6d4b\u8bd5', '.clawgod');
fs.mkdirSync(dir, { recursive: true });
const cli = join(dir, 'cli.cjs');
const log = join(dir, 'startup-check.log');
const env = { ...process.env, CLAWGOD_STARTUP_TIMEOUT_MS: '10000' };
function check(code, options = {}) {
fs.writeFileSync(cli, code);
return spawnSync(process.execPath, [checker, process.execPath, cli], { encoding: 'utf8', timeout: 15000, maxBuffer: 2 * 1024 * 1024, env, ...options });
}
const version = '2.1.285 (Claude Code)';
try {
let r = check(`if(process.argv[2]!=='--version')process.exit(9); console.log(${JSON.stringify(version)});`);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /2\.1\.285 \(Claude Code\)/);
assert.equal(fs.readFileSync(log, 'utf8'), version + '\n');
// Closed stdin prevents an installer probe from entering an interactive prompt.
r = check(`if(require('fs').readFileSync(0,'utf8')!=='')process.exit(8); console.log(${JSON.stringify(version)});`);
assert.equal(r.status, 0, r.stderr);
r = check('console.error("fixture startup failure"); process.exit(7);');
assert.equal(r.status, 7);
assert.match(r.stdout + r.stderr, /fixture startup failure/);
assert.match(fs.readFileSync(log, 'utf8'), /fixture startup failure/);
r = check('console.error("Expected CommonJS module to have a function wrapper"); process.exit(1);');
assert.equal(r.status, 1);
assert.match(r.stdout, /Expected CommonJS module to have a function wrapper/);
for (const output of ['', 'console.log("unrelated 2.1.285 output");']) {
r = check(output);
assert.equal(r.status, 1);
assert.match(r.stderr, /did not print a Claude Code version/);
}
r = check(`console.error(${JSON.stringify(version)});`);
assert.equal(r.status, 1, 'A version quoted only in stderr is not successful --version output');
r = check(`process.stdout.write('2.1.'); setTimeout(()=>{console.error('interleaved warning');process.stdout.write('285 (Claude Code)');},20);`);
assert.equal(r.status, 0, 'stderr must not split stdout version detection');
r = check(`console.log(${JSON.stringify(version)}); process.stderr.write('x'.repeat(1200000));`);
assert.equal(r.status, 0, 'bounded diagnostic logs must not discard a valid earlier version');
r = spawnSync(process.execPath, [checker, join(root, 'missing-bun'), cli], { encoding: 'utf8', timeout: 5000, env });
assert.equal(r.status, 1);
assert.match(r.stderr, /Could not start Bun/);
for (const limit of ['invalid', '0', '-1', '2147483648']) {
r = check('throw Error("must not run")', { env: { ...env, CLAWGOD_STARTUP_TIMEOUT_MS: limit } });
assert.equal(r.status, 1);
assert.match(r.stderr, /positive integer/);
}
// This was the failure shape in #203: --version has printed but a server or
// interval keeps Bun alive. A silent/blocking startup must also be bounded.
for (const code of [`console.log(${JSON.stringify(version)}); setInterval(()=>{},1000);`, 'while(true){}']) {
const started = Date.now();
r = check(code, { env: { ...env, CLAWGOD_STARTUP_TIMEOUT_MS: '1200' } });
assert.equal(r.status, 124, r.stderr);
assert.ok(Date.now() - started < 8000, 'watchdog must bound process and pipe lifetime');
assert.match(r.stderr, /timed out after 1200 ms/);
assert.match(r.stderr, /Startup log:/);
assert.match(fs.readFileSync(log, 'utf8'), /launcher installation was aborted/);
}
// A child inheriting the probe's pipes must not survive the timeout either.
const childPidFile = join(dir, 'child.pid');
try {
r = check(`const cp=require('child_process').spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'inherit'}); require('fs').writeFileSync(${JSON.stringify(childPidFile)},String(cp.pid)); setInterval(()=>{},1000);`, { env: { ...env, CLAWGOD_STARTUP_TIMEOUT_MS: '1200' } });
assert.equal(r.status, 124, r.stderr);
const pid = Number(fs.readFileSync(childPidFile, 'utf8'));
let alive = true;
for (let attempt = 0; attempt < 30 && alive; attempt++) {
try { process.kill(pid, 0); await new Promise(resolve => setTimeout(resolve, 100)); }
catch (error) { assert.equal(error.code, 'ESRCH'); alive = false; }
}
assert.equal(alive, false, 'timeout must terminate probe descendants');
} finally {
if (fs.existsSync(childPidFile)) {
try { process.kill(Number(fs.readFileSync(childPidFile, 'utf8')), 'SIGKILL'); } catch {}
}
}
r = check(`process.stdout.write('x'.repeat(1200000),()=>{ console.error('output-tail'); process.exitCode=3; });`);
assert.equal(r.status, 3);
const boundedLog = fs.readFileSync(log, 'utf8');
assert.ok(Buffer.byteLength(boundedLog) < 1100000);
assert.match(boundedLog, /Earlier startup output truncated/);
assert.match(boundedLog, /output-tail/);
fs.rmSync(log);
fs.mkdirSync(log);
try {
r = check(`console.log(${JSON.stringify(version)});`);
assert.equal(r.status, 1, 'Log write failure must be reported');
assert.match(r.stderr, /Cannot write startup log/);
assert.doesNotMatch(r.stderr, /\[clawgod\] Startup log:/, 'Do not advertise a log that was not written');
} finally { fs.rmSync(log, { recursive: true }); }
console.log('[startup-check.test] version output, exit codes, closed stdin, paths, timeouts and bounded logs passed');
// Check the real launcher before loading Claude: no provider reads, migration,
// API listener or release-check fetch may run for standalone version queries.
const profile = dirname(dir);
const config = join(dir, 'provider.json');
fs.writeFileSync(join(dir, '.claude.json'), '{"migration":"must stay"}');
fs.writeFileSync(join(dir, '.clawgod-version'), '2.0.0');
for (const flag of ['--version', '-v']) {
for (const provider of [undefined, { protocol: 'openai-chat', apiKey: 'fixture', baseURL: 'https://example.invalid/v1' }, { type: 'openai-compat', apiKey: 'fixture', baseURL: 'https://example.invalid/v1' }, { type: 'grok', apiKey: 'fixture' }, { protocol: 'invalid' }]) {
if (provider) fs.writeFileSync(config, JSON.stringify(provider));
else fs.rmSync(config, { force: true });
const before = fs.existsSync(config) ? fs.readFileSync(config, 'utf8') : null;
let loaded = false, network = false;
const modules = [];
runInNewContext(wrapper, {
process: { argv: ['bun', cli, flag], env: {}, execPath: '/test/bun', stderr: { write() {} } },
fetch() { network = true; throw new Error('version must not fetch'); },
AbortSignal,
require(name) {
if (name === 'os') return { homedir: () => profile };
if (name === './openai-proxy.cjs') throw new Error('version must not start a provider');
if (['./feature-gates.cjs', './runtime-helpers.cjs', './bun-ant-shim.cjs'].includes(name)) { modules.push(name); return {}; }
if (name === './cli.original.cjs') { loaded = true; return {}; }
return require(name);
},
});
assert.equal(loaded, true);
assert.equal(network, false);
assert.deepEqual(modules, ['./feature-gates.cjs', './runtime-helpers.cjs', './bun-ant-shim.cjs']);
assert.equal(fs.existsSync(join(dir, '.claude.json')), true);
assert.equal(fs.existsSync(join(profile, '.claude.json')), false);
assert.equal(fs.existsSync(config) ? fs.readFileSync(config, 'utf8') : null, before);
}
}
// A prompt/update argument containing --version is not a version-only query.
fs.writeFileSync(config, JSON.stringify({ protocol: 'invalid' }));
for (const args of [['update', '--version', '2.1.285'], ['-p', '--version']]) {
assert.throws(() => runInNewContext(wrapper, {
process: { argv: ['bun', cli, ...args], env: {} },
require(name) { return name === 'os' ? { homedir: () => profile } : require(name); },
}), /Unsupported provider protocol/);
}
console.log('[startup-check.test] standalone version skips provider/update side effects and still loads runtime helpers/bundle');
// Real Bun process regression, enabled in Unix/Windows smoke CI and locally
// when Bun is available. No user home or credentials are read or modified.
const bun = process.env.CLAWGOD_TEST_BUN || 'bun';
const available = spawnSync(bun, ['--version'], { timeout: 5000, encoding: 'utf8' });
if (available.status === 0) {
fs.writeFileSync(cli, wrapper);
fs.copyFileSync(join(sourceDir, 'openai-proxy.cjs'), join(dir, 'openai-proxy.cjs'));
for (const name of ['feature-gates.cjs', 'runtime-helpers.cjs', 'bun-ant-shim.cjs']) fs.writeFileSync(join(dir, name), '');
fs.writeFileSync(join(dir, 'cli.original.cjs'), `console.log(${JSON.stringify(version)});`);
fs.writeFileSync(config, JSON.stringify({ protocol: 'openai-chat', apiKey: 'fixture', baseURL: 'https://example.invalid/v1' }));
const entry = join(dir, 'entry.cjs');
fs.writeFileSync(entry, `require('os').homedir=()=>${JSON.stringify(profile)}; global.fetch=()=>{throw Error('unexpected version fetch')}; require('./cli.cjs');`);
r = spawnSync(process.execPath, [checker, bun, entry], { encoding: 'utf8', env: { ...env, CLAWGOD_STARTUP_TIMEOUT_MS: '3000' }, timeout: 8000 });
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.equal(r.stdout.trim(), version);
assert.doesNotMatch(r.stderr, /proxy on port/);
console.log('[startup-check.test] real Bun exits after --version with an OpenAI provider configured');
} else {
assert.ok(!process.env.CLAWGOD_TEST_BUN, 'Explicit test Bun must be executable');
console.log('[startup-check.test] Bun unavailable: real runtime case skipped');
}
// Run the actual POSIX installer check section without installing anything.
if (process.platform !== 'win32') {
const template = fs.readFileSync(join(sourceDir, '../templates/install.sh'), 'utf8');
const section = template.slice(template.indexOf('dim "Verifying Bun'), template.indexOf('# ─── Replace claude command'));
assert.ok(section.startsWith('dim "Verifying Bun'));
fs.copyFileSync(checker, join(dir, 'startup-check.cjs'));
const script = `set -e\nCLAWGOD_DIR="$1"\nBUN_BIN="$2"\nwarn(){ printf '%s\\n' "$*"; }\ninfo(){ printf '%s\\n' "$*"; }\ndim(){ :; }\n${section}\nprintf 'launcher-step-reached\\n'\n`;
for (const [code, expected] of [
[`console.log(${JSON.stringify(version)});`, 0],
['console.error("fixture failed"); process.exit(7);', 7],
['setInterval(()=>{},1000);', 124],
['console.log("Expected CommonJS module to have a function wrapper"); process.exit(1);', 1],
]) {
fs.writeFileSync(cli, code);
r = spawnSync('bash', ['-c', script, 'test', dir, process.execPath], { encoding: 'utf8', timeout: 8000, env: { ...env, CLAWGOD_STARTUP_TIMEOUT_MS: '1200' } });
assert.equal(r.status, expected, r.stdout + r.stderr);
assert.equal(r.stdout.includes('launcher-step-reached'), expected === 0);
assert.equal(r.stdout.includes('Bun loads cli.original.cjs'), expected === 0);
}
console.log('[startup-check.test] Unix installer preserves failure/timeout status before launcher replacement');
}
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
+84
View File
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import { mkdtempSync, copyFileSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runInNewContext } from 'node:vm';
import { terminalReplyDelay } from './runtime-helpers.cjs';
const pending = { queue: [{ kind: 'sentinel', written: true }] };
const reader = (incomplete, mode = 'NORMAL') => ({ parse: { mode, incomplete }, lastInputAt: 100 });
for (const prefix of ['\x1b', '\x1b[', '\x1b[?', '\x1b[?61;4;6;']) {
assert.equal(terminalReplyDelay(pending, reader(prefix), 150), 1950);
assert.equal(terminalReplyDelay(pending, reader(prefix), 2100), 0, 'timeout remains bounded');
for (const querier of [null, {}, { queue: [] }, { queue: [{ kind: 'query' }] },
{ queue: [{ kind: 'sentinel', written: false }] }]) {
assert.equal(terminalReplyDelay(querier, reader(prefix), 150), 0, 'no sent DA1 probe: normal timing');
}
assert.equal(terminalReplyDelay({ queue: [{ kind: 'sentinel' }] }, reader(prefix), 150), 1950, 'legacy query queue');
assert.equal(terminalReplyDelay({ queue: [{ kind: 'barrier' }] }, reader(prefix), 150), 1950, 'resync DA1 probe');
}
for (const input of ['', 'hello', '22;23;24;28;32;42;52c', '\x03', '\x1b[A', '\x1b[?61;4c', '\x1b[x']) {
assert.equal(terminalReplyDelay(pending, reader(input), 150), 0, `do not delay ${JSON.stringify(input)}`);
}
assert.equal(terminalReplyDelay(pending, reader('\x1b[', 'IN_PASTE'), 150), 0);
assert.equal(terminalReplyDelay(pending, reader('\x1b['), NaN), 0);
// Exercise the actual emitted callback, including older minified identifiers,
// graph/legacy patching, idempotence, expiry, and a wrapper without the helper.
const dir = mkdtempSync(join(tmpdir(), 'clawgod-terminal-reply-test-'));
try {
copyFileSync(new URL('./patch.mjs', import.meta.url), join(dir, 'patch.mjs'));
for (const graph of [false, true]) {
if (graph) mkdirSync(join(dir, 'bunfs'));
for (const names of [['Hf', 'za', 't', 'Ry'], ['zf', 'Lr', 'n', 'E0']]) {
const [hasInput, wait, now, flush] = names;
const source = `globalThis.App=class{flushIncomplete=()=>{if(this.incompleteEscapeTimer=null,!${hasInput}(this.keyReader))return;if(this.props.stdin.readableLength>0){this.incompleteEscapeTimer=setTimeout(this.flushIncomplete,${wait});return}let ${now}=performance.now();this.applyKeysRead(${flush}(this.keyReader,${now}),${now})}};`;
const target = join(dir, graph ? 'bunfs/renderer.js' : 'cli.original.cjs');
if (graph) writeFileSync(join(dir, 'cli.original.cjs'), '// graph entry\n');
writeFileSync(target, source);
execFileSync(process.execPath, [join(dir, 'patch.mjs')]);
const patched = readFileSync(target, 'utf8');
assert.ok(patched.includes('globalThis.__clawgodHelpers?.terminalReplyDelay'));
execFileSync(process.execPath, [join(dir, 'patch.mjs')]);
assert.equal(readFileSync(target, 'utf8'), patched);
let clock = 150, scheduled, flushed = 0;
const context = {
__clawgodHelpers: { terminalReplyDelay },
performance: { now: () => clock },
setTimeout: (fn, ms) => { scheduled = { fn, ms }; return 42; },
[hasInput]: (r) => !!r.parse.incomplete,
[wait]: 50,
[flush]: (r) => { flushed++; return r.parse.incomplete; },
};
runInNewContext(patched, context);
const app = new context.App();
Object.assign(app, { keyReader: reader('\x1b['), querier: pending,
props: { stdin: { readableLength: 0 } }, applyKeysRead: (value) => { app.delivered = value; } });
app.flushIncomplete();
assert.equal(flushed, 0);
assert.equal(scheduled.ms, 1950);
assert.equal(app.incompleteEscapeTimer, 42);
clock = 2100;
scheduled.fn();
assert.equal(app.delivered, '\x1b[', 'Alt+[ is preserved when no reply completes it');
assert.equal(flushed, 1);
clock = 150;
app.querier = { queue: [] };
app.keyReader = reader('\x1b');
app.flushIncomplete();
assert.equal(app.delivered, '\x1b', 'Escape remains immediate after its normal timer without probes');
app.querier = pending;
delete context.__clawgodHelpers;
app.flushIncomplete();
assert.equal(flushed, 3, 'older wrapper keeps its original callback');
app.props.stdin.readableLength = 1;
app.flushIncomplete();
assert.equal(scheduled.ms, 50, 'buffered stdin retains the original retry path');
}
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
console.log('[terminal-reply.test] pending DA1 fragments, keyboard fallback, timeout, and patch composition ok');
+41
View File
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import * as fs from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createRequire } from 'node:module';
import { runInNewContext } from 'node:vm';
const require = createRequire(import.meta.url);
const wrapper = fs.readFileSync(new URL('./cli.cjs', import.meta.url), 'utf8');
const home = fs.mkdtempSync(join(tmpdir(), 'clawgod-updater-test-'));
const dir = join(home, '.clawgod');
fs.mkdirSync(dir);
try {
for (const mode of ['on', 'off', 'max']) {
for (const marker of ['.lean-disabled', '.lean-max']) fs.rmSync(join(dir, marker), { force: true });
if (mode !== 'on') fs.writeFileSync(join(dir, mode === 'off' ? '.lean-disabled' : '.lean-max'), '');
for (const inherited of [undefined, '', '0', 'false', '1']) {
const env = inherited === undefined ? {} : { DISABLE_AUTOUPDATER: inherited };
let loaded = false;
runInNewContext(wrapper, {
process: { argv: ['bun', 'cli.cjs'], env, execPath: '/test/bun', stderr: { write() {} } },
require(name) {
if (name === 'os') return { homedir: () => home };
if (name === './cli.original.cjs') {
// Check at bundle load time, before upstream captures environment.
assert.equal(env.DISABLE_AUTOUPDATER, '1', `${mode}, inherited=${inherited}`);
loaded = true;
return {};
}
if (['./feature-gates.cjs', './runtime-helpers.cjs', './bun-ant-shim.cjs'].includes(name)) return {};
return require(name);
},
});
assert.ok(loaded);
}
}
console.log('[updater.test] native auto-updates disabled before bundle load in every Lean mode');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
+804
View File
@@ -0,0 +1,804 @@
#Requires -Version 5.1
<#
.SYNOPSIS
ClawGod Installer for Windows
.DESCRIPTION
Downloads Claude Code from npm, applies feature unlock patches,
and replaces the 'claude' command with the patched version.
.EXAMPLE
irm clawgod.0chen.cc/install.ps1 | iex
# or
.\install.ps1
.\install.ps1 -Version 2.1.89
.\install.ps1 -NoUpgrade
.\install.ps1 -Uninstall
#>
param(
[string]$Version = "latest",
[switch]$NoUpgrade,
[switch]$Uninstall,
[switch]$LeanOff,
[switch]$LeanOn,
[switch]$LeanMax
)
$ErrorActionPreference = "Stop"
if ($env:CLAWGOD_VERSION -and $Version -eq "latest") { $Version = $env:CLAWGOD_VERSION }
if ($env:CLAWGOD_NO_UPGRADE -eq "1") { $NoUpgrade = [switch]$true }
if ($env:CLAWGOD_LEAN_OFF -eq "1") { $LeanOff = [switch]$true }
if ($env:CLAWGOD_LEAN_ON -eq "1") { $LeanOn = [switch]$true }
if ($env:CLAWGOD_LEAN_MAX -eq "1") { $LeanMax = [switch]$true }
$ClawDir = Join-Path $env:USERPROFILE ".clawgod"
$BinDir = Join-Path $env:USERPROFILE ".local\bin"
$ClawSelfVersion = "0.0.0-dev" # injected by release workflow from git tag
# --- Colors -----------------------------------------------------------
function Write-OK($msg) { Write-Host " $([char]0x2713) $msg" -ForegroundColor Green }
function Write-Err($msg) { Write-Host " $([char]0x2717) $msg" -ForegroundColor Red }
function Write-Warn($msg) { Write-Host " ! $msg" -ForegroundColor Yellow }
function Write-Dim($msg) { Write-Host " $msg" -ForegroundColor DarkGray }
Write-Host ""
Write-Host " ClawGod Installer" -ForegroundColor White -NoNewline
Write-Host " (Windows)" -ForegroundColor DarkGray
Write-Host ""
# --- Uninstall --------------------------------------------------------
if ($Uninstall) {
# Restore original claude
$claudeOrig = Join-Path $BinDir "claude.orig.cmd"
$claudeCmd = Join-Path $BinDir "claude.cmd"
if (Test-Path $claudeOrig) {
Move-Item -Force $claudeOrig $claudeCmd
Write-OK "Original claude restored"
} elseif ((Test-Path $claudeCmd) -and (Select-String -Path $claudeCmd -Pattern "clawgod" -Quiet -ErrorAction SilentlyContinue)) {
Remove-Item -Force $claudeCmd
Write-OK "Removed ClawGod launcher ($claudeCmd)"
}
# Also check for .exe backup
$claudeExeOrig = Join-Path $BinDir "claude.orig.exe"
$claudeExe = Join-Path $BinDir "claude.exe"
if (Test-Path $claudeExeOrig) {
Move-Item -Force $claudeExeOrig $claudeExe
Write-OK "Original claude.exe restored"
}
# Remove explicit clawgod alias
$clawgodCmd = Join-Path $BinDir "clawgod.cmd"
if (Test-Path $clawgodCmd) {
Remove-Item -Force $clawgodCmd
Write-OK "Removed clawgod alias"
}
foreach ($f in @("cli.js","cli.cjs","cli.original.js","cli.original.cjs","cli.original.js.bak","cli.original.cjs.bak","source-backup.json","patch.js","patch.mjs","extract-natives.mjs","post-process.mjs","repatch.mjs","startup-check.cjs","startup-check.log","openai-proxy.cjs","feature-gates.cjs","runtime-helpers.cjs","bun-ant-shim.cjs","clawgod-import.exe",".source-version","node_modules","bun-runtime","vendor","bunfs","pathmap.json")) {
$p = Join-Path $ClawDir $f
if (Test-Path $p) { Remove-Item -Recurse -Force $p }
}
Write-OK "ClawGod uninstalled"
Write-Host ""
Write-Dim "Restart your terminal for changes to take effect."
Write-Host ""
exit 0
}
# --- Prerequisites ----------------------------------------------------
try { $null = Get-Command node -ErrorAction Stop }
catch {
Write-Err "Node.js is required (>= 18) for the patcher. Install from https://nodejs.org"
exit 1
}
$nodeVer = [int](node -e "console.log(process.versions.node.split('.')[0])")
if ($nodeVer -lt 18) {
Write-Err "Node.js >= 18 required (found v$nodeVer)"
exit 1
}
# --- Ensure Bun (runtime that executes the patched cli.js) ------------
$BunBin = $null
try { $BunBin = (Get-Command bun -ErrorAction Stop).Source } catch {}
if (-not $BunBin) {
$homeBun = Join-Path $env:USERPROFILE ".bun\bin\bun.exe"
if (Test-Path $homeBun) { $BunBin = $homeBun }
}
if (-not $BunBin) {
Write-Dim "Installing Bun (required runtime for v2.1.113+ cli.js) ..."
try {
Invoke-Expression "$(Invoke-RestMethod https://bun.sh/install.ps1)" 2>$null | Out-Null
} catch {}
$BunBin = Join-Path $env:USERPROFILE ".bun\bin\bun.exe"
if (-not (Test-Path $BunBin)) {
Write-Err "Bun installation failed. Install manually: https://bun.sh/install"
exit 1
}
}
# Resolve bun.ps1 -> bun.exe. When Bun is installed via `npm install -g bun`,
# Get-Command returns a .ps1 wrapper script. A .cmd launcher cannot invoke .ps1
# directly -- Windows opens the file association dialog instead of executing it.
# Probe known install paths instead of parsing wrapper scripts.
if ($BunBin -and $BunBin -match '\.ps1$') {
$resolved = $null
$bunDir = Split-Path $BunBin
# 1. npm global: bun.ps1 sits next to node_modules/bun/bin/bun.exe
$cand = Join-Path $bunDir "node_modules\bun\bin\bun.exe"
if (Test-Path $cand) { $resolved = $cand }
# 2. bun.sh official install
if (-not $resolved) {
$cand = Join-Path $env:USERPROFILE ".bun\bin\bun.exe"
if (Test-Path $cand) { $resolved = $cand }
}
# 3. Scoop: shim exe lives in ~/scoop/shims/
if (-not $resolved) {
$cand = Join-Path $env:USERPROFILE "scoop\shims\bun.exe"
if (Test-Path $cand) { $resolved = $cand }
}
# 4. Chocolatey: typically in C:\ProgramData\chocolatey\bin\
if (-not $resolved) {
$chocoBin = Join-Path $env:ProgramData "chocolatey\bin\bun.exe"
if (Test-Path $chocoBin) { $resolved = $chocoBin }
}
if ($resolved) {
Write-Dim "Resolved bun.ps1 -> $resolved"
$BunBin = $resolved
} else {
Write-Warn "Bun resolved to .ps1 wrapper ($BunBin). The launcher may not work."
Write-Warn "Consider installing Bun via bun.sh/install.ps1 for a native bun.exe."
}
}
Write-OK "Bun: $(& $BunBin --version)"
# --- Bun version pre-flight -------------------------------------------
# Anthropic builds the native binary with Bun's canary channel; stable
# bun.sh trails by one version. Bun < 1.3.14 panics on cli.original.cjs
# with "Expected CommonJS module to have a function wrapper". Refuse
# early -- no npm download / no patch / no late sanity surprise where
# PowerShell's NativeCommandError display buries the friendly message.
# Bump $MinBunVersion when Anthropic moves the embedded Bun forward
# again.
$MinBunVersion = '1.3.14'
$BunVersionRaw = ''
try {
$bunOut = & $BunBin --version 2>$null | Select-Object -First 1
if ($bunOut) { $BunVersionRaw = "$bunOut".Trim() }
} catch {}
$BunVersionNum = ($BunVersionRaw -split '-')[0]
$BunVersionOk = $false
try {
if ($BunVersionNum) {
$BunVersionOk = ([version]$BunVersionNum) -ge ([version]$MinBunVersion)
}
} catch {}
if (-not $BunVersionOk) {
Write-Host ""
Write-Err "Bun $BunVersionRaw is below the required minimum ($MinBunVersion)."
Write-Err ""
Write-Err " Anthropic builds claude-code with Bun's canary channel. Older Bun"
Write-Err " panics on cli.original.cjs with 'Expected CommonJS module to have"
Write-Err " a function wrapper'. This is a hard requirement, not a warning."
Write-Err ""
Write-Err " Upgrade with one of:"
Write-Err " bun upgrade --canary"
Write-Err " powershell -c ""iex & {`$(irm https://bun.sh/install.ps1)} -Version canary"""
Write-Err ""
Write-Err " If your bun is from scoop (the binary is behind a shim and refuses"
Write-Err " to self-replace, so 'bun upgrade' silently hangs):"
Write-Err " scoop uninstall bun"
Write-Err " irm https://bun.sh/install.ps1 | iex"
Write-Err " bun upgrade --canary"
Write-Err ""
Write-Err " Then re-run this installer."
exit 1
}
# --- ripgrep prerequisite (search/grep tool) --------------------------
# Hard prerequisite -- without rg the Grep tool inside Claude Code fails.
try {
$rgPath = (Get-Command rg -ErrorAction Stop).Source
Write-OK "ripgrep: $rgPath"
}
catch {
Write-Err "ripgrep (rg) is required but not found in PATH."
Write-Err " Claude Code's Grep tool will not function without it."
Write-Err ""
Write-Err " Install: winget install BurntSushi.ripgrep.MSVC"
Write-Err " or: scoop install ripgrep"
Write-Err " or: choco install ripgrep"
Write-Err ""
Write-Err " Re-run this script after installing rg."
exit 1
}
# --- Handle -NoUpgrade (re-patch the installed version) --------------
if ($NoUpgrade) {
New-Item -ItemType Directory -Force -Path $ClawDir | Out-Null
New-Item -ItemType Directory -Force -Path $BinDir | Out-Null
$existingCjs = Join-Path $ClawDir "cli.original.cjs"
if (-not (Test-Path $existingCjs)) {
Write-Err "-NoUpgrade requires an existing installation."
Write-Err "Run a full install first (without -NoUpgrade)."
exit 1
}
if (Test-Path (Join-Path $ClawDir 'source-backup.json')) {
Write-OK "Using complete clean-source backup (-NoUpgrade)"
} else {
# Older installers backed up only the entry, not bunfs chunks. Recover
# clean source from the exact installed version, never from latest.
$versionStamp = Join-Path $ClawDir '.source-version'
$installedVersion = [regex]::Match('', '^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$')
if (Test-Path $versionStamp) {
$installedVersion = [regex]::Match((Get-Content $versionStamp -Raw).Trim(), '^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$')
}
if (-not $installedVersion.Success) {
$installedVersion = [regex]::Match((Get-Content $existingCjs -Raw), 'Version:\s*(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)')
}
if (-not $installedVersion.Success) {
throw 'No complete source backup and installed version is unknown. Reinstall with -Version <version>.'
}
$Version = $installedVersion.Groups[1].Value
$NoUpgrade = [switch]$false
Write-OK "Recovering clean source for installed version $Version (one-time download, no version upgrade)"
}
}
if (-not $NoUpgrade) {
# --- Locate native Bun binary (cli.js source) -------------------------
# Source: npm registry (@anthropic-ai/claude-code-win32-<arch>).
# Local binary detection is intentionally skipped -- see policy note below.
New-Item -ItemType Directory -Force -Path $ClawDir | Out-Null
New-Item -ItemType Directory -Force -Path $BinDir | Out-Null
$NativeBin = $null
$NativeBinLabel = $null
$NativeBinTmpDir = $null
# Detect platform suffix
if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64" -or $env:PROCESSOR_ARCHITEW6432 -eq "ARM64") {
$arch = "arm64"
} else {
$arch = "x64"
}
$platformSuffix = "win32-$arch"
# Detection policy: ALWAYS pull from the npm registry @latest.
#
# Earlier versions of this script also probed local install directories
# (versions/, claude.orig, npm-global, bun-global) before falling back to
# the registry. Every one of those is a stale-source trap: clawgod patches
# out `claude update`, so users never re-run the underlying installers,
# and those directories freeze at whatever version was on disk the day
# clawgod was first installed. `claude update` (which is now redirected
# here) would re-detect the frozen binary forever -- never reaching the
# registry. See INCIDENT_LOG 2026-04-29 entry. The fix is to skip local
# detection entirely; the npm tarball is ~60-90 MB compressed, fetched
# once per upgrade.
# npm registry -- pull the platform tarball directly via Node.
# Avoids depending on `npm` and `tar` being on PATH (older Windows 10
# builds lack tar.exe; some PowerShell shims mangle `& npm`). Node is
# already a hard prerequisite for the patcher, so reuse it.
if (-not $NativeBin) {
$npmPkg = "@anthropic-ai/claude-code-$platformSuffix"
Write-Dim "Fetching $npmPkg@$Version from npm registry ..."
$NativeBinTmpDir = Join-Path $env:TEMP "clawgod-binary-$([Guid]::NewGuid().ToString('N'))"
New-Item -ItemType Directory -Force -Path $NativeBinTmpDir | Out-Null
$fetchScript = Join-Path $NativeBinTmpDir "fetch.mjs"
$useNpmFetch = $false
$noProxy = $env:NO_PROXY
if ($env:HTTPS_PROXY -or $env:HTTP_PROXY) {
if ($noProxy -match '(?i)npmjs\.org') {
Write-Dim "NO_PROXY includes npmjs.org -- using direct fetch"
} elseif (Get-Command npm -ErrorAction SilentlyContinue) {
$useNpmFetch = $true
} else {
Write-Warn "HTTP proxy detected but npm not found. fetch.mjs may not work through your proxy."
Write-Warn "Install npm or set NO_PROXY=registry.npmjs.org to bypass."
}
}
if ($useNpmFetch) {
Push-Location $NativeBinTmpDir
try {
$npmOut = npm pack "$npmPkg@$Version" --silent 2>&1
$tarball = Get-ChildItem $NativeBinTmpDir -Filter "*.tgz" | Select-Object -First 1
if ($tarball) {
tar xzf $tarball.FullName 2>$null
$cand = Join-Path $NativeBinTmpDir "package\claude.exe"
if ((Test-Path $cand) -and (Get-Item $cand).Length -gt 10MB) {
$NativeBin = $cand
$pkgJson = Join-Path $NativeBinTmpDir "package\package.json"
if (Test-Path $pkgJson) {
$NativeBinLabel = (Get-Content $pkgJson -Raw | ConvertFrom-Json).version
} else { $NativeBinLabel = "npm-latest" }
Write-OK "Downloaded $npmPkg@$NativeBinLabel (via npm)"
}
}
} finally { Pop-Location }
if (-not $NativeBin) {
Remove-Item -Recurse -Force $NativeBinTmpDir -ErrorAction SilentlyContinue
Write-Err "npm pack failed. Output:"
Write-Dim ($npmOut -join "`n")
exit 1
}
} else {
@'
{{CLAWGOD:npm-fetch.mjs}}
'@ | Set-Content $fetchScript -Encoding UTF8
$output = & node $fetchScript "$npmPkg@$Version" $NativeBinTmpDir 2>&1
$exitCode = $LASTEXITCODE
$output | ForEach-Object { Write-Host " $_" }
Remove-Item -Force $fetchScript -ErrorAction SilentlyContinue
if ($exitCode -ne 0) {
Remove-Item -Recurse -Force $NativeBinTmpDir -ErrorAction SilentlyContinue
Write-Err "Fetch failed (node exit $exitCode). Install the official binary manually:"
Write-Err " irm https://claude.ai/install.ps1 | iex"
exit 1
}
$cand = Join-Path $NativeBinTmpDir "package\claude.exe"
if ((Test-Path $cand) -and (Get-Item $cand).Length -gt 10MB) {
$NativeBin = $cand
$verLine = $output | Where-Object { $_ -match '^VERSION=' } | Select-Object -First 1
if ($verLine) { $NativeBinLabel = ($verLine -replace '^VERSION=', '').Trim() }
else { $NativeBinLabel = "npm-latest" }
} else {
Remove-Item -Recurse -Force $NativeBinTmpDir -ErrorAction SilentlyContinue
Write-Err "Tarball downloaded but expected package\claude.exe was missing or too small."
Write-Err " Tempdir kept for inspection: $NativeBinTmpDir"
exit 1
}
Write-OK "Downloaded $npmPkg@$NativeBinLabel"
}
}
if (-not $NativeBin) {
Write-Err "Native Claude Code binary not found"
Write-Err "Install the official binary first:"
Write-Err " irm https://claude.ai/install.ps1 | iex"
Write-Err "Then re-run this script."
exit 1
}
# Always write the extractor (used for cli.js and/or .node modules)
$extractorPath = Join-Path $ClawDir "extract-natives.mjs"
@'
{{CLAWGOD:extract-natives.mjs}}
'@ | Set-Content $extractorPath -Encoding UTF8
# --- Extract cli.js + native modules from Bun binary ------------------
# Single extractor pass: writes cli.original.js to $ClawDir and creates
# vendor\<name>\<arch>-<os>\<name>.node for every napi module in one go.
$VendorDir = Join-Path $ClawDir "vendor"
if (Test-Path $VendorDir) { Remove-Item -Recurse -Force $VendorDir }
$BunfsDir = Join-Path $ClawDir "bunfs"
if (Test-Path $BunfsDir) { Remove-Item -Recurse -Force $BunfsDir }
$PathMap = Join-Path $ClawDir "pathmap.json"
if (Test-Path $PathMap) { Remove-Item -Force $PathMap }
$dstCli = Join-Path $ClawDir "cli.original.js"
if (Test-Path $dstCli) { Remove-Item -Force $dstCli }
Write-Dim "Extracting cli.js + napi modules from $NativeBinLabel ..."
& node $extractorPath $NativeBin $ClawDir 2>&1 | ForEach-Object { Write-Host " $_" }
if (-not (Test-Path $dstCli)) {
Write-Err "Failed to extract cli.js from native binary"
exit 1
}
# Note: keep extractorPath around -- repatch.mjs uses it on version drift
# --- Post-process cli.js for Bun runtime -------------------------------
Write-Dim "Rewriting bunfs paths and IIFE invocation ..."
$postProc = Join-Path $ClawDir "post-process.mjs"
@'
{{CLAWGOD:post-process.mjs}}
'@ | Set-Content $postProc -Encoding UTF8
& node $postProc 2>&1 | ForEach-Object { Write-Host " $_" }
if (-not (Test-Path (Join-Path $ClawDir "cli.original.cjs"))) {
Write-Err "Post-process failed"
exit 1
}
# Stamp source version so wrapper can detect drift on next launch
Set-Content -Path (Join-Path $ClawDir ".source-version") -Value $NativeBinLabel -Encoding ASCII
# If we pulled the binary from npm into a tmpdir, clean up -- extraction
# is done; drift detection only consults %USERPROFILE%\.local\share\claude\versions\.
if ($NativeBinTmpDir -and (Test-Path $NativeBinTmpDir)) {
Remove-Item -Recurse -Force $NativeBinTmpDir -ErrorAction SilentlyContinue
}
Write-OK "cli.original.cjs ready ($NativeBinLabel)"
} # end -NoUpgrade skip
# --- Write re-patch helper (used by wrapper on version drift) ---------
@'
{{CLAWGOD:repatch.mjs}}
'@ | Set-Content (Join-Path $ClawDir "repatch.mjs") -Encoding UTF8
Write-OK "Re-patch helper installed (repatch.mjs)"
# --- Write OpenAI-compatible proxy ------------------------------------
# NOTE: PowerShell here-string @'...'@ cannot contain a line starting with '@
# The proxy source is identical to the install.sh version.
# $PSScriptRoot is empty when run via iex (e.g. claude update -> iex(irm $url)).
# Join-Path "" "file" throws a terminating error that -ErrorAction cannot catch.
try { $ProxySource = Get-Content (Join-Path $PSScriptRoot "openai-proxy.cjs") -Raw -ErrorAction Stop } catch { $ProxySource = $null }
if (-not $ProxySource) {
# Inline fallback: fetch from release assets
$ProxySource = @'
{{CLAWGOD:openai-proxy.cjs}}
'@
}
$ProxySource | Set-Content (Join-Path $ClawDir "openai-proxy.cjs") -Encoding UTF8
Write-OK "OpenAI-compatible proxy created (openai-proxy.cjs)"
# --- Write patch feature gates -----------------------------------------
@'
{{CLAWGOD:feature-gates.cjs}}
'@ | Set-Content (Join-Path $ClawDir "feature-gates.cjs") -Encoding UTF8
Write-OK "Patch feature gates created (feature-gates.cjs)"
# --- Write wrapper (cli.cjs, runs under Bun) --------------------------
@'
{{CLAWGOD:cli.cjs}}
'@ | Set-Content (Join-Path $ClawDir "cli.cjs") -Encoding UTF8
Set-Content (Join-Path $ClawDir ".clawgod-version") $ClawSelfVersion
Write-OK "Wrapper created (cli.cjs)"
@'
{{CLAWGOD:startup-check.cjs}}
'@ | Set-Content (Join-Path $ClawDir "startup-check.cjs") -Encoding UTF8
# --- Write classifier runtime helper -----------------------------------
@'
{{CLAWGOD:runtime-helpers.cjs}}
'@ | Set-Content (Join-Path $ClawDir "runtime-helpers.cjs") -Encoding UTF8
Write-OK "Classifier helper created (runtime-helpers.cjs)"
# --- Write Bun.ant runtime shim ---------------------------------------
# Claude Code 2.1.271+ renders through Bun.ant.CellSegmenter, an
# Anthropic-private Bun API that stock Bun does not ship. cli.cjs loads this
# shim before the bundle; without it the TUI never paints.
@'
{{CLAWGOD:bun-ant-shim.cjs}}
'@ | Set-Content (Join-Path $ClawDir "bun-ant-shim.cjs") -Encoding UTF8
Write-OK "Bun.ant runtime shim created (bun-ant-shim.cjs)"
# --- Write universal patcher ------------------------------------------
# (Same Node.js patcher as bash version -- inline to avoid extra download)
$patcherCode = @'
{{CLAWGOD:patch.mjs}}
'@
Set-Content (Join-Path $ClawDir "patch.mjs") $patcherCode -Encoding UTF8
Write-OK "Patcher created (patch.mjs)"
# --- Apply patches ----------------------------------------------------
if (-not $NoUpgrade) {
node (Join-Path $ClawDir "patch.mjs") --capture-clean-source
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
Write-Dim "Applying patches ..."
node (Join-Path $ClawDir "patch.mjs")
if ($LASTEXITCODE -ne 0) {
Write-Err "Patching failed (node exit $LASTEXITCODE). Installation aborted."
exit $LASTEXITCODE
}
# --- Report which renderer runtime this Claude Code build needs -------
# 2.1.271+ renders through Bun.ant.CellSegmenter, an Anthropic-private Bun
# API; cli.cjs answers it with bun-ant-shim.cjs. Older builds use plain Bun
# APIs and ignore the shim.
$needsShim = $false
$bunfsDir = Join-Path $ClawDir "bunfs"
$targets = @()
if (Test-Path $bunfsDir) {
$targets += Get-ChildItem -Path $bunfsDir -File -ErrorAction SilentlyContinue
}
$entry = Join-Path $ClawDir "cli.original.cjs"
if (Test-Path $entry) { $targets += Get-Item $entry }
# Scan every extracted chunk (`.js` and `.mjs`) plus the entry point, matching
# the POSIX side, so the report fires when any of them references the API.
$hit = $targets |
Select-String -Pattern 'Bun\.ant\.CellSegmenter' -List -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($hit) { $needsShim = $true }
if ($needsShim) {
Write-Dim "Renderer runtime: Bun.ant.CellSegmenter (Claude >= 2.1.271) - served by bun-ant-shim.cjs"
} else {
Write-Dim "Renderer runtime: stock Bun APIs (shim present but unused)"
}
# --- Create default configs -------------------------------------------
$featuresFile = Join-Path $ClawDir "features.json"
if (-not (Test-Path $featuresFile)) {
$featuresJson = @'
{{CLAWGOD:features.json}}
'@
[System.IO.File]::WriteAllText($featuresFile, $featuresJson, (New-Object System.Text.UTF8Encoding $false))
Write-OK "Default features.json created"
}
# Patch feature toggles (user-editable): {"<feature>": false}, absent = on.
# Written only when missing -- the user's choices survive updates/uninstalls.
$patchesFile = Join-Path $ClawDir "patches.json"
if (-not (Test-Path $patchesFile)) {
[System.IO.File]::WriteAllText($patchesFile, "{}`r`n", (New-Object System.Text.UTF8Encoding $false))
Write-OK "Default patches.json created (all features on)"
}
# --- Lean mode: optimize ~/.claude/settings.json ----------------------
$leanOffFlag = Join-Path $ClawDir ".lean-disabled"
$leanMaxFlag = Join-Path $ClawDir ".lean-max"
$claudeSettingsDir = Join-Path $env:USERPROFILE ".claude"
$claudeSettings = Join-Path $claudeSettingsDir "settings.json"
New-Item -ItemType Directory -Force -Path $claudeSettingsDir | Out-Null
if ($LeanOff) {
New-Item -ItemType File -Force -Path $leanOffFlag | Out-Null
if (Test-Path $leanMaxFlag) { Remove-Item $leanMaxFlag -Force }
$leanRemoveScript = @'
{{CLAWGOD:lean-remove.cjs}}
'@
if (Test-Path $claudeSettings) {
try { node -e $leanRemoveScript "$claudeSettings" 2>$null } catch {}
}
Write-OK "Lean mode disabled (all tools restored)"
} elseif ($LeanOn) {
if (Test-Path $leanOffFlag) { Remove-Item $leanOffFlag -Force }
if (Test-Path $leanMaxFlag) { Remove-Item $leanMaxFlag -Force }
} elseif ($LeanMax) {
if (Test-Path $leanOffFlag) { Remove-Item $leanOffFlag -Force }
New-Item -ItemType File -Force -Path $leanMaxFlag | Out-Null
}
if (-not (Test-Path $leanOffFlag)) {
$leanIsMax = (Test-Path $leanMaxFlag)
$leanApplyScript = @'
{{CLAWGOD:lean-apply.cjs}}
'@
try {
node -e $leanApplyScript "$claudeSettings" "$leanIsMax" 2>$null
if ($leanIsMax) { Write-OK "Lean settings applied: max (~/.claude/settings.json)" }
else { Write-OK "Lean settings applied: on (~/.claude/settings.json)" }
} catch {}
} else {
Write-Host " $([char]0x2022) Lean mode disabled (claude --lean-on to re-enable)" -ForegroundColor DarkGray
}
# --- Sanity check: ensure user's Bun can load cli.original.cjs --------
# Anthropic builds the native binary with a bleeding-edge Bun build (e.g.
# 1.3.14 while stable still ships 1.3.13). Older Bun crashes loading the
# extracted cli.original.cjs with "Expected CommonJS module to have a
# function wrapper". Detect this BEFORE we install the launcher -- better
# to fail loudly than to leave the user with a launcher that panics on
# first invocation.
Write-Dim "Verifying Bun can load patched cli.original.cjs (bounded startup check) ..."
$sanityCli = Join-Path $ClawDir "cli.cjs"
# PowerShell folds native-command stderr into the error stream as
# ErrorRecord objects; with $ErrorActionPreference='Stop' (common when
# this script is piped through `iex`) that terminates BEFORE we even
# read $sanityOut. Localize ErrorActionPreference + try/catch so the
# panic message reliably lands in $sanityOut and our friendly Write-Err
# block runs. Defense-in-depth -- pre-flight already blocks Bun < $MinBunVersion;
# this remains for the day Anthropic bumps embedded Bun past our constant.
$sanityOut = $null
try {
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$sanityOut = (& node (Join-Path $ClawDir "startup-check.cjs") $BunBin $sanityCli 2>&1 | Out-String)
$sanityExitCode = $LASTEXITCODE
} catch {
$sanityOut = "$_"
$sanityExitCode = 1
} finally {
$ErrorActionPreference = $prevEAP
}
if ($sanityOut -match "Expected CommonJS module to have a function wrapper") {
Write-Host ""
Write-Err "The selected Bun runtime cannot load Anthropic's cli.original.cjs."
Write-Err ""
Write-Err " Anthropic builds with Bun's canary channel (currently ~1.3.14), while"
Write-Err " bun.sh's main download is on stable (currently 1.3.13). The canary build"
Write-Err " is NOT visible on bun.sh's download page -- it lives on GitHub Releases"
Write-Err " and is reachable only via 'bun upgrade --canary'."
Write-Err ""
Write-Err " If your bun is from bun.sh:"
Write-Err " bun upgrade --canary"
Write-Err " or: powershell -c ""iex & {`$(irm https://bun.sh/install.ps1)} -Version canary"""
Write-Err ""
Write-Err " If your bun is from scoop (the binary is behind a shim and refuses to"
Write-Err " self-replace, so 'bun upgrade' silently hangs):"
Write-Err " scoop uninstall bun"
Write-Err " irm https://bun.sh/install.ps1 | iex"
Write-Err " bun upgrade --canary"
Write-Err ""
Write-Err " Then re-run .\install.ps1 -- this sanity check will pass."
exit 1
}
if ($sanityExitCode -ne 0) {
Write-Host ""
Write-Err "Patched Claude failed its startup check (exit $sanityExitCode):"
Write-Err "$sanityOut"
exit $sanityExitCode
}
Write-OK "Bun loads cli.original.cjs"
# --- Replace claude command -------------------------------------------
# Build launcher content using %USERPROFILE% env var where possible to avoid
# encoding issues when the profile path contains non-ASCII characters (e.g.
# Chinese/Korean/Japanese usernames). cmd.exe resolves %USERPROFILE% at
# runtime so no problematic characters need to be baked into the .cmd file.
$cliPathInCmd = "%USERPROFILE%\.clawgod\cli.cjs"
$normalizedUserProfile = $env:USERPROFILE.TrimEnd('\', '/')
$normalizedBunBin = $BunBin.TrimEnd('\', '/')
$userProfilePrefix = "$normalizedUserProfile\"
if ($normalizedBunBin.Equals($normalizedUserProfile, [StringComparison]::OrdinalIgnoreCase) -or
$normalizedBunBin.StartsWith($userProfilePrefix, [StringComparison]::OrdinalIgnoreCase)) {
$bunRelative = $normalizedBunBin.Substring($normalizedUserProfile.Length).TrimStart('\', '/')
$bunPathInCmd = "%USERPROFILE%\$bunRelative"
} else {
# Bun outside USERPROFILE (e.g. system-wide install) -- fall back to
# absolute path since %USERPROFILE%-relative expansion doesn't apply.
$bunPathInCmd = $BunBin
}
# Download clawgod-import binary
$importBin = Join-Path $ClawDir "clawgod-import.exe"
if (-not (Test-Path $importBin)) {
$importUrl = "https://github.com/0Chencc/clawgod/releases/latest/download/clawgod-import-windows-x64.exe"
try {
Invoke-WebRequest -Uri $importUrl -OutFile $importBin -UseBasicParsing -ErrorAction Stop
Write-OK "Provider import tool installed (clawgod-import.exe)"
} catch {
Write-Dim "Provider import tool not yet available (build pending)"
}
}
$importPathInCmd = "%USERPROFILE%\.clawgod\clawgod-import.exe"
$launcherContent = "@echo off`r`nif `"%~1`"==`"import`" (`r`n if exist `"$importPathInCmd`" (`r`n shift`r`n `"$importPathInCmd`" %1 %2 %3 %4 %5 %6 %7 %8 %9`r`n exit /b %ERRORLEVEL%`r`n ) else (`r`n echo clawgod: import tool not installed. Reinstall clawgod to get it.`r`n exit /b 127`r`n )`r`n)`r`nif not exist `"$cliPathInCmd`" (`r`n echo clawgod: cli.cjs not found. Reinstall: irm https://github.com/0Chencc/clawgod/releases/latest/download/install.ps1 ^| iex`r`n exit /b 127`r`n)`r`nif not exist `"$bunPathInCmd`" (`r`n echo clawgod: bun not found at $bunPathInCmd. Install: https://bun.sh/install`r`n exit /b 127`r`n)`r`nset `"CLAUDE_CODE_EXECPATH=%~dp0claude.orig.exe`"`r`n`"$bunPathInCmd`" `"$cliPathInCmd`" %*"
# Find and back up original claude
$claudeCmd = Join-Path $BinDir "claude.cmd"
$claudeExe = Join-Path $BinDir "claude.exe"
$claudeOrigCmd = Join-Path $BinDir "claude.orig.cmd"
$claudeOrigExe = Join-Path $BinDir "claude.orig.exe"
# Check multiple locations for original claude
$originalFound = $false
foreach ($loc in @(
(Join-Path $BinDir "claude.exe"),
(Join-Path $BinDir "claude.cmd"),
(Join-Path $env:USERPROFILE ".local\share\claude\versions"),
(Join-Path $env:LOCALAPPDATA "Programs\claude-code")
)) {
if (Test-Path $loc) {
# Reinstalling must not back up our own launcher as the original.
# Continue searching versions/ if no native executable was found yet.
if ($loc -like "*.cmd" -and (Select-String -LiteralPath $loc -Pattern '\.clawgod[\\/]cli\.(?:cjs|js)' -Quiet)) {
continue
}
# Back up .exe if exists and not already backed up
if ($loc -like "*.exe" -and -not (Test-Path $claudeOrigExe)) {
Copy-Item $loc $claudeOrigExe -Force
Write-OK "Original claude.exe backed up -> claude.orig.exe"
$originalFound = $true
}
# Back up .cmd if exists and not already backed up
if ($loc -like "*.cmd" -and -not (Test-Path $claudeOrigCmd)) {
Copy-Item $loc $claudeOrigCmd -Force
Write-OK "Original claude.cmd backed up -> claude.orig.cmd"
$originalFound = $true
}
# If it's a versions directory, find the latest exe
if (Test-Path $loc -PathType Container) {
$latestExe = Get-ChildItem $loc -File | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($latestExe -and -not (Test-Path $claudeOrigExe)) {
Copy-Item $latestExe.FullName $claudeOrigExe -Force
Write-OK "Original claude backed up -> claude.orig.exe ($($latestExe.Name))"
$originalFound = $true
}
}
break
}
}
# Write both entry points before removing a competing exe. If Windows refuses
# removal, the explicit clawgod command remains available for recovery.
foreach ($cmd in @("claude", "clawgod")) {
$launcherContent | Set-Content (Join-Path $BinDir "$cmd.cmd") -Encoding Default
}
# Clean up leftover timestamped/old exes from previous installs
Get-ChildItem $BinDir -Filter "claude.*.exe" -ErrorAction SilentlyContinue |
Where-Object { $_.Name -ne "claude.orig.exe" } |
ForEach-Object { Remove-Item $_.FullName -Force -ErrorAction SilentlyContinue }
# Remove claude.exe so .cmd takes precedence
# Keep one backup as claude.orig.exe, discard the rest
if (Test-Path $claudeExe) {
if (-not (Test-Path $claudeOrigExe)) {
Rename-Item $claudeExe $claudeOrigExe -Force
Write-OK "Renamed claude.exe -> claude.orig.exe"
} else {
# Backup already exists -- just remove the new claude.exe
try {
Remove-Item -Force $claudeExe
} catch {
# Running binaries can often be renamed even when deletion fails.
# A unique name also avoids collisions during repeated installs.
$suffix = [Guid]::NewGuid().ToString('N')
try {
Rename-Item $claudeExe "claude.$suffix.exe" -ErrorAction Stop
} catch {
throw "Cannot remove or rename $claudeExe. Close Claude Code sessions (including VS Code), then rerun this installer. Use 'clawgod' to run the patched CLI in the meantime. Windows error: $($_.Exception.Message)"
}
}
}
}
if (Test-Path $claudeExe) {
throw "$claudeExe still shadows claude.cmd. Close Claude Code sessions and rerun this installer; use 'clawgod' in the meantime."
}
# An exe earlier in PATH can still shadow claude.cmd; clawgod is unambiguous.
Write-OK "Commands 'claude' + 'clawgod' -> patched"
# --- Ensure BinDir is in PATH -----------------------------------------
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($userPath -notlike "*$BinDir*") {
[Environment]::SetEnvironmentVariable("Path", "$BinDir;$userPath", "User")
$env:Path = "$BinDir;$env:Path"
Write-OK "Added $BinDir to user PATH"
Write-Dim "(restart terminal for PATH to take effect)"
}
# --- Done -------------------------------------------------------------
Write-Host ""
Write-Host " ClawGod installed!" -ForegroundColor Green
Write-Host ""
Write-Dim " claude -- Start patched Claude Code (green logo)"
Write-Dim " claude.orig -- Run original unpatched Claude Code"
Write-Host ""
Write-Dim " Updates: 'claude update' is patched to route through this installer."
Write-Dim " Just run it as usual -- pulls latest Anthropic release + re-patches"
Write-Dim " in one step. Extra options:"
Write-Dim " claude update --version 2.1.180 (install a specific version)"
Write-Dim " claude update --no-upgrade (re-patch the installed version)"
Write-Dim " To leave clawgod and use vanilla update:"
Write-Dim " bash ~/.clawgod/install.sh --uninstall"
Write-Host ""
Write-Err " If 'claude' still runs the old version, restart your terminal."
Write-Host ""
Write-Dim " Config: ~/.clawgod/provider.json"
Write-Dim " Flags: ~/.clawgod/features.json"
Write-Host ""
Write-Dim " If 'claude' panics with 'Expected CommonJS module to have a function wrapper',"
Write-Dim " your Bun lags Anthropic's embedded Bun. Upgrade with one of:"
Write-Dim " bun upgrade --canary (if installed from bun.sh)"
Write-Dim " scoop update bun (scoop -- may lag stable)"
Write-Dim " irm https://bun.sh/install.ps1 | iex (re-install latest)"
Write-Host ""
+732
View File
@@ -0,0 +1,732 @@
#!/bin/bash
set -e
# ─────────────────────────────────────────────────────────
# ClawGod Installer
#
# Downloads Claude Code from npm, applies patches, replaces claude command
#
# 用法:
# curl -fsSL https://raw.githubusercontent.com/0Chencc/clawgod/main/install.sh | bash
# # 或
# bash install.sh [--version 2.1.89] [--no-upgrade]
# ─────────────────────────────────────────────────────────
CLAWGOD_DIR="$HOME/.clawgod"
BIN_DIR="$HOME/.local/bin"
VERSION="${CLAWGOD_VERSION:-latest}"
NO_UPGRADE="${CLAWGOD_NO_UPGRADE:-}"
LEAN_OFF="${CLAWGOD_LEAN_OFF:-}"
LEAN_ON="${CLAWGOD_LEAN_ON:-}"
LEAN_MAX="${CLAWGOD_LEAN_MAX:-}"
CLAWGOD_SELF_VERSION="0.0.0-dev" # injected by release workflow from git tag
# Parse args
while [[ $# -gt 0 ]]; do
case $1 in
--version) VERSION="$2"; shift 2 ;;
--no-upgrade) NO_UPGRADE=1; shift ;;
--uninstall) UNINSTALL=1; shift ;;
--lean-off) LEAN_OFF=1; shift ;;
--lean-on) LEAN_ON=1; shift ;;
--lean-max) LEAN_MAX=1; shift ;;
*) shift ;;
esac
done
# Colors
GREEN='\033[0;32m'
RED='\033[0;31m'
DIM='\033[2m'
BOLD='\033[1m'
NC='\033[0m'
info() { echo -e " ${GREEN}✓${NC} $1"; }
warn() { echo -e " ${RED}✗${NC} $1"; }
dim() { echo -e " ${DIM}$1${NC}"; }
echo ""
echo -e "${BOLD} ClawGod Installer${NC}"
echo ""
# ─── Uninstall ─────────────────────────────────────────
if [ "$UNINSTALL" = "1" ]; then
CLAUDE_BIN=$(command -v claude 2>/dev/null || true)
for DIR in "${CLAUDE_BIN:+$(dirname "$CLAUDE_BIN")}" "$BIN_DIR"; do
[ -z "$DIR" ] && continue
if [ -e "$DIR/claude.orig" ]; then
# Has backup — restore it
mv "$DIR/claude.orig" "$DIR/claude"
info "Original claude restored ($DIR/claude)"
elif [ -f "$DIR/claude" ] && grep -q "clawgod" "$DIR/claude" 2>/dev/null; then
# Our launcher, no backup — remove it (otherwise it points to deleted cli.js)
rm -f "$DIR/claude"
info "Removed ClawGod launcher ($DIR/claude)"
fi
# Always remove the explicit clawgod alias if it's ours
if [ -f "$DIR/clawgod" ] && grep -q "clawgod" "$DIR/clawgod" 2>/dev/null; then
rm -f "$DIR/clawgod"
info "Removed ClawGod alias ($DIR/clawgod)"
fi
done
rm -rf "$CLAWGOD_DIR/node_modules" "$CLAWGOD_DIR/vendor" "$CLAWGOD_DIR/bun-runtime" "$CLAWGOD_DIR/cli.original.js" "$CLAWGOD_DIR/cli.original.js.bak" "$CLAWGOD_DIR/cli.original.cjs" "$CLAWGOD_DIR/cli.original.cjs.bak" "$CLAWGOD_DIR/source-backup.json" "$CLAWGOD_DIR/cli.js" "$CLAWGOD_DIR/cli.cjs" "$CLAWGOD_DIR/patch.mjs" "$CLAWGOD_DIR/patch.js" "$CLAWGOD_DIR/extract-natives.mjs" "$CLAWGOD_DIR/post-process.mjs" "$CLAWGOD_DIR/repatch.mjs" "$CLAWGOD_DIR/startup-check.cjs" "$CLAWGOD_DIR/startup-check.log" "$CLAWGOD_DIR/openai-proxy.cjs" "$CLAWGOD_DIR/feature-gates.cjs" "$CLAWGOD_DIR/runtime-helpers.cjs" "$CLAWGOD_DIR/bun-ant-shim.cjs" "$CLAWGOD_DIR/clawgod-import" "$CLAWGOD_DIR/.source-version"
hash -r 2>/dev/null
info "ClawGod uninstalled"
echo ""
warn " Restart your terminal or run: hash -r"
echo ""
exit 0
fi
# ─── Prerequisites ─────────────────────────────────────
if ! command -v node &>/dev/null; then
warn "Node.js is required (>= 18) for the patcher. Install from https://nodejs.org"
exit 1
fi
NODE_VERSION=$(node -e "console.log(process.versions.node.split('.')[0])")
if [ "$NODE_VERSION" -lt 18 ]; then
warn "Node.js >= 18 required (found v$NODE_VERSION)"
exit 1
fi
# ─── Ensure Bun (runtime that executes the patched cli.js) ─────────────
BUN_BIN=""
if command -v bun &>/dev/null; then
BUN_BIN=$(command -v bun)
elif [ -x "$HOME/.bun/bin/bun" ]; then
BUN_BIN="$HOME/.bun/bin/bun"
else
dim "Installing Bun (required runtime for v2.1.113+ cli.js) ..."
curl -fsSL https://bun.sh/install | bash >/dev/null 2>&1 || true
BUN_BIN="$HOME/.bun/bin/bun"
if [ ! -x "$BUN_BIN" ]; then
warn "Bun installation failed. Install manually: https://bun.sh/install"
exit 1
fi
fi
info "Bun: $($BUN_BIN --version)"
# ─── Bun version pre-flight ───────────────────────────────────────────
# Anthropic builds the native binary with Bun's canary channel; stable
# bun.sh trails by one version. Bun < 1.3.14 panics on cli.original.cjs
# with "Expected CommonJS module to have a function wrapper". Refuse
# early — no npm download / no patch / no late sanity surprise.
# Bump MIN_BUN_VERSION when Anthropic moves the embedded Bun forward
# again (track via 'bun upgrade --canary' on a runner + smoke test).
MIN_BUN_VERSION="1.3.14"
BUN_VERSION_RAW=$($BUN_BIN --version 2>/dev/null | head -1)
BUN_VERSION_NUM=$(echo "$BUN_VERSION_RAW" | sed 's/-.*//')
if [ -z "$BUN_VERSION_NUM" ] \
|| [ "$(printf '%s\n%s\n' "$BUN_VERSION_NUM" "$MIN_BUN_VERSION" | sort -V | head -1)" != "$MIN_BUN_VERSION" ]; then
warn ""
warn "Bun ${BUN_VERSION_RAW:-<unknown>} is below the required minimum ($MIN_BUN_VERSION)."
warn ""
warn " Anthropic builds claude-code with Bun's canary channel. Older Bun"
warn " panics on cli.original.cjs with 'Expected CommonJS module to have"
warn " a function wrapper'. This is a hard requirement, not a warning."
warn ""
warn " Upgrade with one of:"
warn " bun upgrade --canary (if installed via curl/install.sh)"
warn " brew upgrade bun (homebrew)"
warn " scoop uninstall bun && \\ (scoop — shim blocks self-replace)"
warn " irm https://bun.sh/install.ps1 | iex && bun upgrade --canary"
warn ""
warn " Then re-run this installer."
exit 1
fi
# ─── ripgrep prerequisite (search/grep tool) ──────────────────────────
# Without rg the Grep tool inside Claude Code fails. Bun-bundled ripgrep
# is only reachable from inside the standalone executable; running the
# extracted cli.js under Bun runtime means we depend on system rg.
# This is a hard prerequisite — refuse to install otherwise.
if ! command -v rg &>/dev/null; then
warn "ripgrep (rg) is required but not found in PATH."
warn " Claude Code's Grep tool will not function without it."
warn ""
case "$(uname -s)" in
Darwin) warn " Install: brew install ripgrep" ;;
Linux) warn " Install: apt install ripgrep | dnf install ripgrep | pacman -S ripgrep" ;;
*) warn " Install: https://github.com/BurntSushi/ripgrep#installation" ;;
esac
warn ""
warn " Re-run this script after installing rg."
exit 1
fi
info "ripgrep: $(rg --version | head -1)"
# ─── Handle --no-upgrade (re-patch the installed version) ───────────
mkdir -p "$CLAWGOD_DIR" "$BIN_DIR"
if [ "$NO_UPGRADE" = "1" ]; then
if [ ! -f "$CLAWGOD_DIR/cli.original.cjs" ]; then
warn "--no-upgrade requires an existing installation."
warn "Run a full install first (without --no-upgrade)."
exit 1
fi
if [ -f "$CLAWGOD_DIR/source-backup.json" ]; then
info "Using complete clean-source backup (--no-upgrade)"
else
# Old installations have no clean chunk backup. Use the installer's version
# stamp, falling back to the entry header, to avoid silently upgrading.
VERSION=$(node -e '
const fs = require("fs");
const source = fs.readFileSync(process.argv[1], "utf8");
let stamp = "";
try { stamp = fs.readFileSync(process.argv[2], "utf8").trim(); } catch {}
const version = stamp.match(/^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/)?.[1] ||
source.match(/Version:\s*(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/)?.[1];
if (!version) process.exit(1);
console.log(version);
' "$CLAWGOD_DIR/cli.original.cjs" "$CLAWGOD_DIR/.source-version") || {
warn "No complete source backup and installed version is unknown. Reinstall with --version <version>."
exit 1
}
NO_UPGRADE=0
info "Recovering clean source for installed version $VERSION (one-time download, no version upgrade)"
fi
fi
if [ "$NO_UPGRADE" != "1" ]; then
# ─── Locate native Bun binary (cli.js source) ──────────────────────────
# v2.1.113+ ships a Bun standalone executable as the only canonical form.
# We extract cli.js text from this binary, patch it, then run via Bun
# runtime. Source: npm registry (@anthropic-ai/claude-code-<platform>).
# Local binary detection is intentionally skipped — see policy note below.
mkdir -p "$CLAWGOD_DIR" "$BIN_DIR"
NATIVE_BIN=""
NATIVE_BIN_LABEL=""
NATIVE_BIN_TMPDIR=""
# Detection policy: ALWAYS pull from the npm registry @latest.
#
# Earlier versions of this script also probed local `node_modules` roots
# (npm-global, bun-global) before falling back to the registry. That was
# a stale-source trap: once clawgod is installed it patches out
# `claude update`, so users never re-run `npm install -g` / `bun add -g`.
# Both directories freeze at whatever version was on disk the day clawgod
# was first installed, and `claude update` (which is now redirected here)
# would re-detect that frozen binary forever — never reaching the
# registry. See INCIDENT_LOG 2026-04-29 entry. The fix is to skip local
# detection entirely; the npm tarball is ~60-90 MB compressed, fetched
# once per upgrade, and npm's HTTP cache keeps repeats fast.
# Detect platform suffix (used by the npm fetch below)
case "$(uname -s)" in
Darwin) os="darwin" ;;
Linux) os="linux" ;;
*) os="" ;;
esac
case "$(uname -m)" in
arm64|aarch64) arch="arm64" ;;
x86_64|amd64) arch="x64" ;;
*) arch="" ;;
esac
if [ "$os" = "linux" ] && (ldd /bin/ls 2>/dev/null | grep -q musl); then
PLATFORM="${os}-${arch}-musl"
else
PLATFORM="${os}-${arch}"
fi
# Pull the Bun standalone binary from the npm registry. Anthropic publishes
# per-platform packages (e.g. claude-code-darwin-arm64); their tarball ships
# the binary directly under package/.
if [ -z "$NATIVE_BIN" ]; then
if ! command -v npm &>/dev/null; then
warn "No native Claude Code binary found locally, and npm is not installed."
warn " Either install the official binary first:"
warn " curl -fsSL https://claude.ai/install.sh | bash"
warn " or install npm so we can fetch it from the registry."
exit 1
fi
if [ -z "$os" ] || [ -z "$arch" ]; then
warn "Unsupported platform: $(uname -s) $(uname -m)"
exit 1
fi
NPM_PKG="@anthropic-ai/claude-code-${PLATFORM}"
dim "Fetching $NPM_PKG@$VERSION from npm registry ..."
NATIVE_BIN_TMPDIR=$(mktemp -d)
if ( cd "$NATIVE_BIN_TMPDIR" && npm pack "$NPM_PKG@$VERSION" --silent >/dev/null 2>&1 ); then
TARBALL=$(ls "$NATIVE_BIN_TMPDIR"/*.tgz 2>/dev/null | head -1)
if [ -n "$TARBALL" ]; then
( cd "$NATIVE_BIN_TMPDIR" && tar xzf "$TARBALL" )
cand="$NATIVE_BIN_TMPDIR/package/claude"
if [ -f "$cand" ]; then
sz=$(stat -f%z "$cand" 2>/dev/null || stat -c%s "$cand" 2>/dev/null || echo 0)
if [ "$sz" -gt 10000000 ]; then
NATIVE_BIN="$cand"
NATIVE_BIN_LABEL=$(node -e "console.log(require('$NATIVE_BIN_TMPDIR/package/package.json').version)" 2>/dev/null || echo "npm-latest")
fi
fi
fi
fi
if [ -z "$NATIVE_BIN" ]; then
rm -rf "$NATIVE_BIN_TMPDIR"
warn "Failed to download $NPM_PKG from npm."
warn " Install the official Claude Code binary manually:"
warn " curl -fsSL https://claude.ai/install.sh | bash"
exit 1
fi
info "Downloaded $NPM_PKG@$NATIVE_BIN_LABEL"
fi
if [ -z "$NATIVE_BIN" ]; then
warn "Native Claude Code binary not found"
warn "Install the official binary first:"
warn " curl -fsSL https://claude.ai/install.sh | bash"
warn "Then re-run this script."
exit 1
fi
# Write extractor to a temp file (used both for cli.js and .node modules)
cat > "$CLAWGOD_DIR/extract-natives.mjs" << 'EXTRACTOR_EOF'
{{CLAWGOD:extract-natives.mjs}}
EXTRACTOR_EOF
# ─── Extract cli.js + native modules from Bun binary ──────────
# Note: extract-natives.mjs and post-process.mjs are kept around (NOT deleted)
# so the wrapper's drift detector can re-run them when the user upgrades
# their native Claude binary.
# Single extractor pass: writes cli.original.js + (v2.1.245+) full chunk graph
# to $CLAWGOD_DIR/bunfs/ and vendor/<name>/<arch>-<os>/<name>.node, plus a
# pathmap.json for post-process path rewriting.
rm -rf "$CLAWGOD_DIR/vendor" "$CLAWGOD_DIR/bunfs" "$CLAWGOD_DIR/pathmap.json" \
"$CLAWGOD_DIR/cli.original.js" 2>/dev/null
dim "Extracting cli.js + modules from $(echo "$NATIVE_BIN_LABEL") ..."
if ! node "$CLAWGOD_DIR/extract-natives.mjs" "$NATIVE_BIN" "$CLAWGOD_DIR" 2>&1 | while IFS= read -r line; do echo " $line"; done; then
err "Failed to extract from native binary"
exit 1
fi
[ -f "$CLAWGOD_DIR/cli.original.js" ] || { err "cli.js missing after extraction"; exit 1; }
# ─── Post-process cli.js for Bun runtime ──────────────────────
# 0. Strip leading @bun pragma comments so Bun recognises the CJS wrapper
# 1. Rewrite /$bunfs/root/X.node paths to point at extracted vendor modules
# 2. Rewrite build-time /home/runner/.../*.ts URLs (used by ripgrep,
# sandbox, computer-use, etc. for asset resolution) to __filename so
# relative resolutions land near our cli.original.cjs
# 3. Wrap the Bun-cjs IIFE with an actual invocation so `require()` runs it
# 4. Save as .cjs (Bun + CJS module wrapper)
dim "Rewriting bunfs paths and IIFE invocation ..."
cat > "$CLAWGOD_DIR/post-process.mjs" << 'POSTPROC_EOF'
{{CLAWGOD:post-process.mjs}}
POSTPROC_EOF
node "$CLAWGOD_DIR/post-process.mjs" 2>&1 | while IFS= read -r line; do echo " $line"; done
[ -f "$CLAWGOD_DIR/cli.original.cjs" ] || { err "Post-process failed"; exit 1; }
# Stamp the source version so the wrapper can detect drift on next launch
echo "$NATIVE_BIN_LABEL" > "$CLAWGOD_DIR/.source-version"
# If we pulled the binary from npm into a tmpdir, clean it up now —
# extraction is done, drift detection only consults ~/.local/share/claude/versions/.
if [ -n "$NATIVE_BIN_TMPDIR" ]; then
rm -rf "$NATIVE_BIN_TMPDIR"
fi
info "cli.original.cjs ready ($NATIVE_BIN_LABEL)"
fi # end --no-upgrade skip
# ─── Write re-patch helper (used by wrapper on version drift) ─────────
cat > "$CLAWGOD_DIR/repatch.mjs" << 'REPATCH_EOF'
{{CLAWGOD:repatch.mjs}}
REPATCH_EOF
chmod +x "$CLAWGOD_DIR/repatch.mjs"
info "Re-patch helper installed (repatch.mjs)"
# ─── Write OpenAI-compatible proxy ────────────────────────────
cat > "$CLAWGOD_DIR/openai-proxy.cjs" << 'PROXY_EOF'
{{CLAWGOD:openai-proxy.cjs}}
PROXY_EOF
info "OpenAI-compatible proxy created (openai-proxy.cjs)"
# ─── Write patch feature gates ────────────────────────────────
cat > "$CLAWGOD_DIR/feature-gates.cjs" << 'GATES_EOF'
{{CLAWGOD:feature-gates.cjs}}
GATES_EOF
info "Patch feature gates created (feature-gates.cjs)"
# ─── Write wrapper (cli.cjs, runs under Bun) ──────────────────
cat > "$CLAWGOD_DIR/cli.cjs" << 'WRAPPER_EOF'
{{CLAWGOD:cli.cjs}}
WRAPPER_EOF
chmod +x "$CLAWGOD_DIR/cli.cjs"
echo "$CLAWGOD_SELF_VERSION" > "$CLAWGOD_DIR/.clawgod-version"
info "Wrapper created (cli.cjs)"
cat > "$CLAWGOD_DIR/startup-check.cjs" << 'STARTUP_EOF'
{{CLAWGOD:startup-check.cjs}}
STARTUP_EOF
# ─── Write classifier runtime helper ────────────────────
cat > "$CLAWGOD_DIR/runtime-helpers.cjs" << 'CFG_EOF'
{{CLAWGOD:runtime-helpers.cjs}}
CFG_EOF
info "Classifier runtime helpers created (runtime-helpers.cjs)"
# ─── Write Bun.ant runtime shim ────────────────────────
# Claude Code 2.1.271+ renders through Bun.ant.CellSegmenter, an
# Anthropic-private Bun API that stock Bun does not ship. cli.cjs loads this
# shim before the bundle; without it the TUI never paints.
cat > "$CLAWGOD_DIR/bun-ant-shim.cjs" << 'BUNANT_EOF'
{{CLAWGOD:bun-ant-shim.cjs}}
BUNANT_EOF
info "Bun.ant runtime shim created (bun-ant-shim.cjs)"
# ─── Write universal patcher ───────────────────────────
cat > "$CLAWGOD_DIR/patch.mjs" << 'PATCHER_EOF'
{{CLAWGOD:patch.mjs}}
PATCHER_EOF
info "Patcher created (patch.mjs)"
# ─── Apply patches ─────────────────────────────────────
if [ "$NO_UPGRADE" != "1" ]; then
node "$CLAWGOD_DIR/patch.mjs" --capture-clean-source
fi
dim "Applying patches ..."
node "$CLAWGOD_DIR/patch.mjs" 2>&1 | while IFS= read -r line; do echo " $line"; done
patch_status=${PIPESTATUS[0]}
if [ "$patch_status" -ne 0 ]; then
warn "Patching failed (node exit $patch_status). Installation aborted."
exit "$patch_status"
fi
# ─── Report which renderer runtime this Claude Code build needs ────────
# 2.1.271+ renders through Bun.ant.CellSegmenter, an Anthropic-private Bun
# API; cli.cjs answers it with bun-ant-shim.cjs. Older builds use plain Bun
# APIs and ignore the shim. Named explicitly so support threads can tell the
# two paths apart at a glance.
if grep -rqs "Bun\.ant\.CellSegmenter" "$CLAWGOD_DIR/bunfs" "$CLAWGOD_DIR/cli.original.cjs" 2>/dev/null; then
info "Renderer runtime: Bun.ant.CellSegmenter (Claude >= 2.1.271) — served by bun-ant-shim.cjs"
else
info "Renderer runtime: stock Bun APIs (shim present but unused)"
fi
# ─── Create default configs ───────────────────────────
if [ ! -f "$CLAWGOD_DIR/features.json" ]; then
cat > "$CLAWGOD_DIR/features.json" << 'FEATURES_EOF'
{{CLAWGOD:features.json}}
FEATURES_EOF
info "Default features.json created"
fi
# Patch feature toggles (user-editable): {"<feature>": false}, absent = on.
# Written only when missing — the user's choices survive updates/uninstalls.
if [ ! -f "$CLAWGOD_DIR/patches.json" ]; then
printf '{}\n' > "$CLAWGOD_DIR/patches.json"
info "Default patches.json created (all features on)"
fi
# ─── Lean mode: optimize ~/.claude/settings.json ─────
# Three levels: off / on (default) / max
# State persisted via .lean-disabled and .lean-max flag files.
# Installer respects existing state on updates — never overwrites user choice.
LEAN_OFF_FLAG="$CLAWGOD_DIR/.lean-disabled"
LEAN_MAX_FLAG="$CLAWGOD_DIR/.lean-max"
# Handle explicit toggle from CLI (--lean-off / --lean-on / --lean-max)
if [ "$LEAN_OFF" = "1" ]; then
touch "$LEAN_OFF_FLAG"; rm -f "$LEAN_MAX_FLAG"
CLAUDE_SETTINGS="$HOME/.claude/settings.json"
if [ -f "$CLAUDE_SETTINGS" ]; then
node -e '
const fs=require("fs"),p=process.argv[1];
const allDeny=new Set(["DesignSync","NotebookEdit","PushNotification","RemoteTrigger","CronCreate","CronDelete","CronList","EnterPlanMode","ExitPlanMode","SendMessage","ScheduleWakeup","AskUserQuestion","ReportFindings"]);
const allFlags=["disableWorkflows","disableRemoteControl","disableClaudeAiConnectors","disableArtifact","disableBundledSkills"];
let s={};try{s=JSON.parse(fs.readFileSync(p,"utf8"))}catch{process.exit(0)}
for(const k of allFlags)delete s[k];
if(Array.isArray(s.permissions?.deny))s.permissions.deny=s.permissions.deny.filter(t=>!allDeny.has(t));
fs.writeFileSync(p,JSON.stringify(s,null,2)+"\n");
' "$CLAUDE_SETTINGS" 2>/dev/null
fi
info "Lean mode disabled (all tools restored)"
elif [ "$LEAN_ON" = "1" ]; then
rm -f "$LEAN_OFF_FLAG" "$LEAN_MAX_FLAG"
elif [ "$LEAN_MAX" = "1" ]; then
rm -f "$LEAN_OFF_FLAG"; touch "$LEAN_MAX_FLAG"
fi
if [ ! -f "$LEAN_OFF_FLAG" ]; then
CLAUDE_SETTINGS_DIR="$HOME/.claude"
CLAUDE_SETTINGS="$CLAUDE_SETTINGS_DIR/settings.json"
mkdir -p "$CLAUDE_SETTINGS_DIR"
LEAN_IS_MAX="false"
[ -f "$LEAN_MAX_FLAG" ] && LEAN_IS_MAX="true"
node -e '
const fs = require("fs");
const settingsPath = process.argv[1];
const isMax = process.argv[2]?.toLowerCase() === "true";
const baseDeny = ["DesignSync","NotebookEdit","PushNotification","RemoteTrigger","CronCreate","CronDelete","CronList"];
const maxDeny = ["EnterPlanMode","ExitPlanMode","SendMessage","ScheduleWakeup","AskUserQuestion","ReportFindings"];
const baseFlags = ["disableWorkflows","disableClaudeAiConnectors","disableArtifact"];
const maxFlags = ["disableBundledSkills","disableRemoteControl"];
const deny = isMax ? [...baseDeny, ...maxDeny] : baseDeny;
const flags = isMax ? [...baseFlags, ...maxFlags] : baseFlags;
let s = {};
try { s = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch {}
let changed = false;
// Downgrade max to on and migrate the old default Remote Control disable.
if (!isMax) {
for (const k of maxFlags) { if (k in s) { delete s[k]; changed = true; } }
if (Array.isArray(s.permissions?.deny)) {
const before = s.permissions.deny.length;
s.permissions.deny = s.permissions.deny.filter(t => !maxDeny.includes(t));
if (s.permissions.deny.length !== before) changed = true;
}
}
for (const k of flags) { if (!(k in s)) { s[k] = true; changed = true; } }
if (!s.permissions) s.permissions = {};
if (!Array.isArray(s.permissions.deny)) s.permissions.deny = [];
const ex = new Set(s.permissions.deny);
for (const t of deny) { if (!ex.has(t)) { s.permissions.deny.push(t); changed = true; } }
if (changed) fs.writeFileSync(settingsPath, JSON.stringify(s, null, 2) + "\n");
' "$CLAUDE_SETTINGS" "$LEAN_IS_MAX" 2>/dev/null
if [ -f "$LEAN_MAX_FLAG" ]; then
info "Lean settings applied: max (~/.claude/settings.json)"
else
info "Lean settings applied: on (~/.claude/settings.json)"
fi
else
dim "Lean mode disabled (claude --lean-on to re-enable)"
fi
# ─── Sanity check: ensure user's Bun can actually load cli.original.cjs ──
# Anthropic builds the native binary with a bleeding-edge Bun build (e.g.
# 1.3.14 while stable still ships 1.3.13). Older Bun crashes loading the
# extracted cli.original.cjs with "Expected CommonJS module to have a
# function wrapper". Detect this BEFORE we install the launcher — better
# to fail loudly than to leave the user with a launcher that panics on
# first invocation.
dim "Verifying Bun can load patched cli.original.cjs (bounded startup check) ..."
sanity_rc=0
sanity_out=$(node "$CLAWGOD_DIR/startup-check.cjs" "$BUN_BIN" "$CLAWGOD_DIR/cli.cjs" 2>&1) || sanity_rc=$?
if echo "$sanity_out" | grep -q "Expected CommonJS module to have a function wrapper"; then
echo ""
warn "The selected Bun runtime cannot load Anthropic's cli.original.cjs."
warn ""
warn " Anthropic builds with Bun's canary channel (currently ~1.3.14), while"
warn " bun.sh's main download is on stable (currently 1.3.13). The canary build"
warn " is NOT visible on bun.sh's download page — it lives on GitHub Releases"
warn " and is reachable only via 'bun upgrade --canary'."
warn ""
warn " If your bun is from bun.sh:"
warn " bun upgrade --canary"
warn ""
warn " If your bun is from a package manager (brew/apt/scoop) where the binary"
warn " is behind a shim and refuses to self-replace ('bun upgrade' silently"
warn " hangs or no-ops):"
warn " <pkg-manager> uninstall bun"
warn " curl -fsSL https://bun.sh/install | bash"
warn " bun upgrade --canary"
warn ""
warn " Then re-run install.sh — this sanity check will pass."
exit 1
fi
if [ "$sanity_rc" -ne 0 ]; then
warn "Patched Claude failed its startup check (exit $sanity_rc):"
printf '%s\n' "$sanity_out"
exit "$sanity_rc"
fi
info "Bun loads cli.original.cjs"
# ─── Replace claude command ───────────────────────────
# Detect where claude is actually installed (supports native, npm, pnpm, yarn).
# `command -v` is a POSIX builtin (works even on minimal images that no
# longer ship `which`); `|| true` keeps a clean miss from tripping
# `set -e` via the assignment's exit status under bash 5+.
CLAUDE_BIN=$(command -v claude 2>/dev/null || true)
if [ -z "$CLAUDE_BIN" ]; then
# No claude in PATH — use default location
CLAUDE_BIN="$BIN_DIR/claude"
dim "No existing claude found, installing to $BIN_DIR"
fi
CLAUDE_DIR=$(dirname "$CLAUDE_BIN")
# ─── Download clawgod-import binary ─────────────────────
IMPORT_BIN="$CLAWGOD_DIR/clawgod-import"
if [ ! -x "$IMPORT_BIN" ]; then
IMPORT_ARCH="$(uname -m)"
IMPORT_OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
case "$IMPORT_ARCH" in
x86_64|amd64) IMPORT_ARCH="x64" ;;
aarch64|arm64) IMPORT_ARCH="arm64" ;;
esac
case "$IMPORT_OS" in
darwin) IMPORT_SUFFIX="darwin-$IMPORT_ARCH" ;;
linux) IMPORT_SUFFIX="linux-$IMPORT_ARCH" ;;
*) IMPORT_SUFFIX="" ;;
esac
if [ -n "$IMPORT_SUFFIX" ]; then
IMPORT_URL="https://github.com/0Chencc/clawgod/releases/latest/download/clawgod-import-$IMPORT_SUFFIX"
if curl -fsSL -o "$IMPORT_BIN" "$IMPORT_URL" 2>/dev/null; then
chmod +x "$IMPORT_BIN"
info "Provider import tool installed (clawgod-import)"
else
dim "Provider import tool not yet available (build pending)"
fi
fi
fi
LAUNCHER_CONTENT="#!/bin/bash
# clawgod launcher
CLAWGOD_CLI=\"$CLAWGOD_DIR/cli.cjs\"
CLAWGOD_IMPORT=\"$CLAWGOD_DIR/clawgod-import\"
BUN_BIN=\"$BUN_BIN\"
# Route 'import' subcommand to clawgod-import binary
if [ \"\$1\" = \"import\" ]; then
shift
if [ -x \"\$CLAWGOD_IMPORT\" ]; then
exec \"\$CLAWGOD_IMPORT\" \"\$@\"
else
echo \"clawgod: import tool not installed. Reinstall clawgod to get it.\" >&2
exit 127
fi
fi
if [ ! -f \"\$CLAWGOD_CLI\" ]; then
echo \"clawgod: installation at $CLAWGOD_DIR is missing (cli.cjs not found)\" >&2
echo \"clawgod: reinstall via curl -fsSL https://github.com/0Chencc/clawgod/releases/latest/download/install.sh | bash\" >&2
echo \"clawgod: or remove this launcher: rm \\\"\$0\\\"\" >&2
exit 127
fi
if [ ! -x \"\$BUN_BIN\" ]; then
if command -v bun >/dev/null 2>&1; then BUN_BIN=\"\$(command -v bun)\"; fi
fi
if [ ! -x \"\$BUN_BIN\" ]; then
echo \"clawgod: bun runtime not found at \$BUN_BIN\" >&2
echo \"clawgod: install bun curl -fsSL https://bun.sh/install | bash\" >&2
exit 127
fi
export CLAUDE_CODE_EXECPATH=\"$CLAUDE_BIN.orig\"
export HERDR_AGENT=\"\${HERDR_AGENT:-claude}\"
exec \"\$BUN_BIN\" \"\$CLAWGOD_CLI\" \"\$@\""
# Back up original claude (only once)
if [ ! -e "$CLAUDE_BIN.orig" ]; then
if [ -L "$CLAUDE_BIN" ]; then
# Symlink (native install) — preserve target
NATIVE_BIN="$(readlink "$CLAUDE_BIN")"
ln -sf "$NATIVE_BIN" "$CLAUDE_BIN.orig"
info "Original claude backed up → claude.orig (→ $NATIVE_BIN)"
elif [ -f "$CLAUDE_BIN" ] && file "$CLAUDE_BIN" 2>/dev/null | grep -q "Mach-O\|ELF\|script"; then
# Binary or script (pnpm/npm global install)
cp "$CLAUDE_BIN" "$CLAUDE_BIN.orig"
info "Original claude backed up → claude.orig"
else
# Try versions dir as fallback
VERSIONS_DIR="$HOME/.local/share/claude/versions"
if [ -d "$VERSIONS_DIR" ]; then
NATIVE_BIN="$(ls -t "$VERSIONS_DIR"/* 2>/dev/null | while read f; do
file "$f" 2>/dev/null | grep -q "Mach-O\|ELF" && echo "$f" && break
done)" || true
if [ -n "$NATIVE_BIN" ]; then
ln -sf "$NATIVE_BIN" "$CLAUDE_BIN.orig"
info "Original claude backed up → claude.orig (→ $NATIVE_BIN)"
fi
fi
fi
fi
# Write launcher to the SAME directory where claude was found.
# CRITICAL: `echo > $f` follows symlinks — if $CLAUDE_BIN is a symlink
# (e.g. official ~/.local/bin/claude → ~/.local/share/claude/versions/X)
# we'd write our launcher into the real binary and destroy it. Always
# remove the existing entry first so we write a fresh regular file.
write_launcher() {
local target="$1"
local dir
dir=$(dirname "$target")
mkdir -p "$dir"
rm -f "$target"
printf '%s\n' "$LAUNCHER_CONTENT" > "$target"
chmod +x "$target"
}
write_launcher "$CLAUDE_BIN"
info "Command 'claude' → patched ($CLAUDE_BIN)"
# Also install to ~/.local/bin if claude was elsewhere (ensures PATH consistency)
if [ "$CLAUDE_DIR" != "$BIN_DIR" ]; then
write_launcher "$BIN_DIR/claude"
dim "Also installed to $BIN_DIR/claude"
fi
# Always expose an unambiguous `clawgod` alias alongside the `claude` override.
# Useful when:
# - Windows .exe overshadows our .cmd (clawgod has no .exe competitor)
# - User wants explicit "patched" intent
# - User restored claude.orig via uninstall but still wants the patched one
write_launcher "$BIN_DIR/clawgod"
info "Command 'clawgod' → patched ($BIN_DIR/clawgod)"
# ─── Check PATH ───────────────────────────────────────
if ! echo "$PATH" | grep -q "$CLAUDE_DIR" && ! echo "$PATH" | grep -q "$BIN_DIR"; then
# Detect shell config file
case "$(basename "$SHELL")" in
zsh) SHELL_RC="$HOME/.zshrc" ;;
bash) SHELL_RC="$HOME/.bashrc" ;;
fish) SHELL_RC="$HOME/.config/fish/config.fish" ;;
*) SHELL_RC="$HOME/.profile" ;;
esac
echo ""
warn "$BIN_DIR is not in PATH. Run:"
dim " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> $SHELL_RC && source $SHELL_RC"
fi
# ─── Flush shell cache ────────────────────────────────
hash -r 2>/dev/null
# ─── Done ─────────────────────────────────────────────
echo ""
echo -e " ${BOLD}${GREEN}ClawGod installed!${NC}"
echo ""
dim " claude — Start patched Claude Code (green logo)"
dim " claude.orig — Run original unpatched Claude Code"
echo ""
dim " Updates: 'claude update' is patched to route through this installer."
dim " Just run it as usual — pulls latest Anthropic release + re-patches"
dim " in one step. Extra options:"
dim " claude update --version 2.1.180 (install a specific version)"
dim " claude update --no-upgrade (re-patch the installed version)"
dim " To leave clawgod and use vanilla update:"
dim " bash ~/.clawgod/install.sh --uninstall"
echo ""
warn " If 'claude' still runs the old version, restart your terminal or run: hash -r"
echo ""
dim " Config: ~/.clawgod/provider.json"
dim " Flags: ~/.clawgod/features.json"
echo ""
dim " If 'claude' panics with 'Expected CommonJS module to have a function wrapper',"
dim " your Bun lags Anthropic's embedded Bun. Upgrade with one of:"
dim " bun upgrade --canary (if installed via curl/install.sh)"
dim " scoop update bun (scoop — may lag stable)"
dim " brew upgrade bun (homebrew)"
echo ""
+94
View File
@@ -0,0 +1,94 @@
# Exercise the actual installer section with real Windows files and locks.
$ErrorActionPreference = 'Stop'
$template = Get-Content (Join-Path $PSScriptRoot '..\templates\install.ps1') -Raw
$section = [regex]::Match($template, '(?s)# Find and back up original claude.*?(?=# --- Ensure BinDir is in PATH)').Value
if (-not $section) { throw 'Installer launcher section not found' }
$install = [ScriptBlock]::Create($section)
$root = Join-Path ([IO.Path]::GetTempPath()) ('clawgod-launchers-' + [Guid]::NewGuid().ToString('N'))
$savedHome = $env:USERPROFILE
$savedLocal = $env:LOCALAPPDATA
$savedPath = $env:PATH
$node = (Get-Command node.exe).Source
$script:messages = New-Object 'System.Collections.Generic.List[string]'
function Write-OK($message) { $script:messages.Add($message) }
function Assert($condition, $message) { if (-not $condition) { throw $message } }
try {
$env:USERPROFILE = $root
$env:LOCALAPPDATA = Join-Path $root 'AppData\Local'
$BinDir = Join-Path $root '.local\bin'
New-Item -ItemType Directory -Force $BinDir | Out-Null
$env:PATH = "$BinDir;$savedPath"
$exe = Join-Path $BinDir 'claude.exe'
$backup = Join-Path $BinDir 'claude.orig.exe'
$cmd = Join-Path $BinDir 'claude.cmd'
$launcherContent = '@echo clawgod-launcher-marker & rem %USERPROFILE%\.clawgod\cli.cjs'
Copy-Item $node $exe
$originalHash = (Get-FileHash $exe).Hash
& $install
Assert (-not (Test-Path $exe)) 'Fresh install left a competing exe'
Assert ((Get-FileHash $backup).Hash -eq $originalHash) 'Native backup differs'
# Repeated installs and repeated upstream repairs preserve the first backup.
foreach ($attempt in 1..3) {
& $install
Assert (-not (Test-Path (Join-Path $BinDir 'claude.orig.cmd'))) 'Backed up our own launcher'
Copy-Item $node $exe
& $install
Assert (-not (Test-Path $exe)) 'Reinstall left the repaired exe'
Assert ((Get-FileHash $backup).Hash -eq $originalHash) 'Reinstall changed native backup'
Assert ((Get-Command claude).Source -eq $cmd) 'PowerShell does not resolve claude.cmd'
Assert ((& cmd.exe /d /c 'claude --version') -match 'clawgod-launcher-marker') 'cmd.exe bypassed launcher'
}
# A running Windows executable rejects deletion but normally allows rename.
Copy-Item $node $exe
$running = Start-Process $exe -ArgumentList '-e', 'setInterval(()=>{},1000)' -PassThru -WindowStyle Hidden
try {
Start-Sleep -Milliseconds 300
Assert (-not $running.HasExited) 'Executable lock fixture did not start'
& $install
Assert (-not (Test-Path $exe)) 'Running exe was not renamed aside'
Assert (@(Get-ChildItem $BinDir -Filter 'claude.*.exe' | Where-Object Name -ne 'claude.orig.exe').Count -gt 0) 'Rename fallback was not exercised'
} finally {
if (-not $running.HasExited) { Stop-Process -Id $running.Id -Force; $running.WaitForExit() }
$running.Dispose()
}
# A lock without delete sharing prevents both removal and rename. Never
# report success, and still leave the explicit recovery launcher usable.
Copy-Item $node $exe
$lock = [IO.File]::Open($exe, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
try {
$script:messages.Clear()
$failure = $null
try { & $install } catch { $failure = $_.Exception.Message }
Assert ($failure -like '*Cannot remove or rename*') 'Locked exe did not report actionable failure'
Assert (-not ($script:messages -match "Commands 'claude'")) 'Locked install reported success'
Assert (Test-Path $exe) 'Lock fixture did not retain the executable'
Assert ((& cmd.exe /d /c 'clawgod --version') -match 'clawgod-launcher-marker') 'Recovery alias is unavailable'
} finally { $lock.Dispose() }
& $install
Assert (-not (Test-Path $exe)) 'Retry after releasing lock did not recover'
# Our existing cmd must not stop discovery of a retained native version.
Remove-Item $backup
$versions = Join-Path $root '.local\share\claude\versions'
New-Item -ItemType Directory -Force $versions | Out-Null
Copy-Item $node (Join-Path $versions 'test-version')
& $install
Assert ((Get-FileHash $backup).Hash -eq $originalHash) 'Own launcher prevented native backup discovery'
Assert (-not (Test-Path (Join-Path $BinDir 'claude.orig.cmd'))) 'Own launcher became original backup'
# A genuine preexisting cmd remains eligible for backup.
Set-Content $cmd '@echo original-launcher' -Encoding Ascii
& $install
Assert ((Get-Content (Join-Path $BinDir 'claude.orig.cmd') -Raw) -match 'original-launcher') 'Original cmd was not preserved'
Write-Host '[launchers.test] repeated installs, native repair, running/locked exe, and backup preservation passed'
} finally {
$env:USERPROFILE = $savedHome
$env:LOCALAPPDATA = $savedLocal
$env:PATH = $savedPath
Remove-Item -Recurse -Force $root -ErrorAction SilentlyContinue
}
+27
View File
@@ -0,0 +1,27 @@
const fs = require("fs");
const settingsPath = process.argv[1];
const isMax = process.argv[2]?.toLowerCase() === "true";
const baseDeny = ["DesignSync","NotebookEdit","PushNotification","RemoteTrigger","CronCreate","CronDelete","CronList"];
const maxDeny = ["EnterPlanMode","ExitPlanMode","SendMessage","ScheduleWakeup","AskUserQuestion","ReportFindings"];
const baseFlags = ["disableWorkflows","disableClaudeAiConnectors","disableArtifact"];
const maxFlags = ["disableBundledSkills","disableRemoteControl"];
const deny = isMax ? [...baseDeny, ...maxDeny] : baseDeny;
const flags = isMax ? [...baseFlags, ...maxFlags] : baseFlags;
let s = {};
try { s = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch {}
let changed = false;
// Downgrade max to on and migrate the old default Remote Control disable.
if (!isMax) {
for (const k of maxFlags) { if (k in s) { delete s[k]; changed = true; } }
if (Array.isArray(s.permissions?.deny)) {
const before = s.permissions.deny.length;
s.permissions.deny = s.permissions.deny.filter(t => !maxDeny.includes(t));
if (s.permissions.deny.length !== before) changed = true;
}
}
for (const k of flags) { if (!(k in s)) { s[k] = true; changed = true; } }
if (!s.permissions) s.permissions = {};
if (!Array.isArray(s.permissions.deny)) s.permissions.deny = [];
const ex = new Set(s.permissions.deny);
for (const t of deny) { if (!ex.has(t)) { s.permissions.deny.push(t); changed = true; } }
if (changed) fs.writeFileSync(settingsPath, JSON.stringify(s, null, 2) + "\n");
+7
View File
@@ -0,0 +1,7 @@
const fs=require("fs"),p=process.argv[1];
const allDeny=new Set(["DesignSync","NotebookEdit","PushNotification","RemoteTrigger","CronCreate","CronDelete","CronList","EnterPlanMode","ExitPlanMode","SendMessage","ScheduleWakeup","AskUserQuestion","ReportFindings"]);
const allFlags=["disableWorkflows","disableRemoteControl","disableClaudeAiConnectors","disableArtifact","disableBundledSkills"];
let s={};try{s=JSON.parse(fs.readFileSync(p,"utf8"))}catch{process.exit(0)}
for(const k of allFlags)delete s[k];
if(Array.isArray(s.permissions?.deny))s.permissions.deny=s.permissions.deny.filter(t=>!allDeny.has(t));
fs.writeFileSync(p,JSON.stringify(s,null,2)+"\n");
+63
View File
@@ -0,0 +1,63 @@
// Download a scoped npm tarball (no npm CLI dependency) and extract it
// using Node's built-in zlib + a minimal POSIX tar parser.
import { request as httpsRequest } from 'node:https';
import { request as httpRequest } from 'node:http';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { gunzipSync } from 'node:zlib';
import { URL } from 'node:url';
const [, , pkgSpec, outDir] = process.argv;
const last = pkgSpec.lastIndexOf('@');
const pkg = last > 0 ? pkgSpec.slice(0, last) : pkgSpec;
const ver = last > 0 ? pkgSpec.slice(last + 1) : 'latest';
function get(url, redirects = 0) {
return new Promise((resolve, reject) => {
if (redirects > 5) return reject(new Error(`Too many redirects`));
const parsed = new URL(url);
const reqMod = parsed.protocol === 'https:' ? httpsRequest : httpRequest;
const opts = { method: 'GET', hostname: parsed.hostname, port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), path: parsed.pathname + parsed.search };
reqMod(opts, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
return get(res.headers.location, redirects + 1).then(resolve, reject);
}
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
}
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks)));
res.on('error', reject);
}).on('error', reject).end();
});
}
const metaBuf = await get(`https://registry.npmjs.org/${pkg}/${ver}`);
const meta = JSON.parse(metaBuf.toString('utf8'));
console.log(`Resolved ${pkg}@${meta.version}`);
const tgz = await get(meta.dist.tarball);
console.log(`Downloaded ${(tgz.length / 1024 / 1024).toFixed(1)} MB`);
const buf = gunzipSync(tgz);
mkdirSync(outDir, { recursive: true });
let off = 0, files = 0;
while (off + 512 <= buf.length) {
const name = buf.slice(off, off + 100).toString('utf8').replace(/\0+$/, '');
if (!name) break;
const sizeOct = buf.slice(off + 124, off + 136).toString('utf8').replace(/[\0\s]+$/, '');
const size = parseInt(sizeOct, 8) || 0;
const typeflag = String.fromCharCode(buf[off + 156]);
off += 512;
if (typeflag === '0' || typeflag === '\0') {
const dest = join(outDir, name);
mkdirSync(dirname(dest), { recursive: true });
writeFileSync(dest, buf.slice(off, off + size));
files++;
}
off += Math.ceil(size / 512) * 512;
}
console.log(`Extracted ${files} files`);
console.log(`VERSION=${meta.version}`);
+32
View File
@@ -0,0 +1,32 @@
$ErrorActionPreference = 'Stop'
$template = Get-Content (Join-Path $PSScriptRoot '..\templates\install.ps1') -Raw
$section = [regex]::Match($template, '(?s)# --- Handle -NoUpgrade.*?(?=if \(-not \$NoUpgrade\) \{)').Value
if (-not $section) { throw 'Source recovery section not found' }
$selectSource = [ScriptBlock]::Create($section)
$ClawDir = Join-Path ([IO.Path]::GetTempPath()) ('clawgod-source-recovery-' + [Guid]::NewGuid().ToString('N'))
$BinDir = Join-Path $ClawDir 'bin'
function Write-OK($message) {}
function Assert($condition, $message) { if (-not $condition) { throw $message } }
try {
New-Item -ItemType Directory -Force $ClawDir | Out-Null
Set-Content (Join-Path $ClawDir 'cli.original.cjs') '// Version: 2.1.272'
Set-Content (Join-Path $ClawDir '.source-version') '2.1.283'
$NoUpgrade = [switch]$true; $Version = 'latest'
. $selectSource
Assert (-not $NoUpgrade -and $Version -eq '2.1.283') 'Recovery did not use stamped installed version'
Remove-Item (Join-Path $ClawDir '.source-version')
$NoUpgrade = [switch]$true; $Version = 'latest'
. $selectSource
Assert (-not $NoUpgrade -and $Version -eq '2.1.272') 'Entry version fallback failed'
Set-Content (Join-Path $ClawDir 'source-backup.json') '{}'
$NoUpgrade = [switch]$true; $Version = 'latest'
. $selectSource
Assert ($NoUpgrade -and $Version -eq 'latest') 'Complete backup should avoid downloads'
Remove-Item (Join-Path $ClawDir 'source-backup.json')
Set-Content (Join-Path $ClawDir 'cli.original.cjs') '// unknown version'
$NoUpgrade = [switch]$true; $Version = 'latest'
$failed = $false
try { . $selectSource } catch { $failed = $true }
Assert $failed 'Unknown installed version must not upgrade to latest'
Write-Host '[source-recovery.test] exact-version migration and local backup selection passed'
} finally { Remove-Item -Recurse -Force $ClawDir }
+59
View File
@@ -0,0 +1,59 @@
# Run the actual installer probe under Windows PowerShell 5.1, including its
# native stderr/ErrorActionPreference behavior and abort-before-launcher path.
$ErrorActionPreference = 'Stop'
$template = Get-Content (Join-Path $PSScriptRoot '..\templates\install.ps1') -Raw
$section = [regex]::Match($template, '(?s)Write-Dim "Verifying Bun.*?(?=# --- Replace claude command)').Value
if (-not $section) { throw 'Startup verification section not found' }
$root = Join-Path ([IO.Path]::GetTempPath()) ('clawgod startup-' + [Guid]::NewGuid().ToString('N'))
$scriptPath = Join-Path $root 'check.ps1'
$savedDir = $env:CLAWGOD_TEST_DIR
$savedTimeout = $env:CLAWGOD_STARTUP_TIMEOUT_MS
function Assert($condition, $message) { if (-not $condition) { throw $message } }
try {
New-Item -ItemType Directory -Force $root | Out-Null
Copy-Item (Join-Path $PSScriptRoot '..\shared\startup-check.cjs') (Join-Path $root 'startup-check.cjs')
$env:CLAWGOD_TEST_DIR = $root
$env:CLAWGOD_STARTUP_TIMEOUT_MS = '1200'
$prefix = @'
$ErrorActionPreference = 'Stop'
$ClawDir = $env:CLAWGOD_TEST_DIR
$BunBin = (Get-Command node.exe).Source
function Write-Dim($message) { Write-Host $message }
function Write-Err($message) { Write-Host $message }
function Write-OK($message) { Write-Host $message }
'@
$suffix = @'
if ($ErrorActionPreference -ne 'Stop') { throw 'ErrorActionPreference was not restored' }
Write-Host 'launcher-step-reached'
'@
Set-Content $scriptPath ($prefix + "`r`n" + $section + "`r`n" + $suffix) -Encoding ASCII
$cases = @(
@{ Code = 'console.log("2.1.285 (Claude Code)");'; Exit = 0; Expected = 'Bun loads cli.original.cjs' },
@{ Code = 'console.error("fixture stderr"); console.log("2.1.285 (Claude Code)");'; Exit = 0; Expected = 'Bun loads cli.original.cjs' },
@{ Code = 'console.error("fixture startup failed"); process.exit(7);'; Exit = 7; Expected = 'fixture startup failed' },
@{ Code = 'console.log("2.1.285 (Claude Code)"); setInterval(()=>{},1000);'; Exit = 124; Expected = 'timed out after 1200 ms' },
@{ Code = 'console.error("Expected CommonJS module to have a function wrapper"); process.exit(1);'; Exit = 1; Expected = 'bun upgrade --canary' },
@{ Code = ''; Exit = 1; Expected = 'did not print a Claude Code version' }
)
foreach ($case in $cases) {
Set-Content (Join-Path $root 'cli.cjs') $case.Code -Encoding ASCII
$previous = $ErrorActionPreference
try {
$ErrorActionPreference = 'Continue'
$output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $scriptPath 2>&1 | Out-String
$code = $LASTEXITCODE
} finally { $ErrorActionPreference = $previous }
Assert ($code -eq $case.Exit) "Expected exit $($case.Exit), got ${code}: $output"
Assert ($output.Contains($case.Expected)) "Missing diagnostic: $output"
Assert ($output.Contains('launcher-step-reached') -eq ($case.Exit -eq 0)) "Failed check reached launcher replacement: $output"
Assert (Test-Path (Join-Path $root 'startup-check.log')) 'Missing startup log'
}
Write-Host '[startup-check.test] Windows installer startup success, stderr, failure, timeout and Bun diagnostic passed'
} finally {
$env:CLAWGOD_TEST_DIR = $savedDir
$env:CLAWGOD_STARTUP_TIMEOUT_MS = $savedTimeout
Remove-Item -Recurse -Force $root
}
# Expected-failure probes leave LASTEXITCODE nonzero. Actions' PowerShell
# wrapper propagates it even after all assertions pass, so report suite success.
exit 0
+1
View File
@@ -0,0 +1 @@
target/
+670
View File
@@ -0,0 +1,670 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "cc"
version = "1.2.67"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clawgod-import"
version = "0.1.0"
dependencies = [
"serde_json",
"ureq",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "displaydoc"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "icu_collections"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "litemap"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "potential_utf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"libc",
"untrusted",
"windows-sys",
]
[[package]]
name = "rustls"
version = "0.23.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tinystr"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.8",
]
[[package]]
name = "webpki-roots"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "writeable"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "clawgod-import"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "clawgod-import"
path = "src/main.rs"
[dependencies]
ureq = { version = "2", features = ["json"] }
serde_json = "1"
[profile.release]
strip = true
lto = true
opt-level = "z"
+478
View File
@@ -0,0 +1,478 @@
use serde_json::{json, Value};
use std::env;
use std::fs;
use std::io::{self, Write};
use std::path::PathBuf;
use std::process;
fn home_dir() -> PathBuf {
env::var("HOME")
.or_else(|_| env::var("USERPROFILE"))
.map(PathBuf::from)
.expect("Cannot determine home directory")
}
fn clawgod_dir() -> PathBuf {
home_dir().join(".clawgod")
}
fn provider_json_path() -> PathBuf {
clawgod_dir().join("provider.json")
}
// ─── Key discovery ───────────────────────────────────────────
fn detect_grok_key() -> Option<String> {
// 1. GROK_API_KEY env
if let Ok(key) = env::var("GROK_API_KEY") {
if !key.is_empty() {
eprintln!(" Found key in GROK_API_KEY environment variable");
return Some(key);
}
}
// 2. ~/.grok/user-settings.json
let settings_path = home_dir().join(".grok").join("user-settings.json");
if let Ok(contents) = fs::read_to_string(&settings_path) {
if let Ok(parsed) = serde_json::from_str::<Value>(&contents) {
if let Some(key) = parsed["apiKey"].as_str() {
if !key.is_empty() {
eprintln!(" Found key in {}", settings_path.display());
return Some(key.to_string());
}
}
}
}
// 3. XAI_API_KEY env (fallback)
if let Ok(key) = env::var("XAI_API_KEY") {
if !key.is_empty() {
eprintln!(" Found key in XAI_API_KEY environment variable");
return Some(key);
}
}
None
}
// ─── Key validation ──────────────────────────────────────────
struct ValidationResult {
valid: bool,
models: Vec<String>,
error: Option<String>,
}
fn validate_key(key: &str, base_url: &str) -> ValidationResult {
let url = format!("{}/models", base_url.trim_end_matches('/'));
match ureq::get(&url)
.set("Authorization", &format!("Bearer {}", key))
.set("Content-Type", "application/json")
.call()
{
Ok(resp) => {
let body: Value = resp.into_json().unwrap_or(json!({}));
let models: Vec<String> = body["data"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|m| m["id"].as_str().map(String::from))
.collect()
})
.unwrap_or_default();
ValidationResult {
valid: true,
models,
error: None,
}
}
Err(ureq::Error::Status(401, _)) => ValidationResult {
valid: false,
models: vec![],
error: Some("Invalid API key (401 Unauthorized)".into()),
},
Err(ureq::Error::Status(code, resp)) => {
let body = resp.into_string().unwrap_or_default();
ValidationResult {
valid: false,
models: vec![],
error: Some(format!("HTTP {} — {}", code, body)),
}
}
Err(e) => ValidationResult {
valid: false,
models: vec![],
error: Some(format!("Connection failed: {}", e)),
},
}
}
// ─── Provider config ─────────────────────────────────────────
fn write_provider(provider_type: &str, key: &str, base_url: &str, model: &str, small_model: &str) {
let config = json!({
"type": provider_type,
"apiKey": key,
"baseURL": base_url,
"model": model,
"smallModel": small_model,
"effort": "",
"timeoutMs": 3000000
});
let path = provider_json_path();
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let content = serde_json::to_string_pretty(&config).unwrap() + "\n";
fs::write(&path, &content).unwrap_or_else(|e| {
eprintln!(" Failed to write {}: {}", path.display(), e);
process::exit(1);
});
eprintln!(" Wrote {}", path.display());
}
fn read_current_provider() -> Option<Value> {
let path = provider_json_path();
fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
}
// ─── Interactive model selection ─────────────────────────────
fn select_model(models: &[String], default: &str) -> String {
if models.is_empty() {
return default.to_string();
}
eprintln!("\n Available models:");
for (i, m) in models.iter().enumerate() {
let marker = if m == default { " (default)" } else { "" };
eprintln!(" [{}] {}{}", i + 1, m, marker);
}
eprint!("\n Select model [default: {}]: ", default);
io::stderr().flush().unwrap();
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap();
let input = input.trim();
if input.is_empty() {
return default.to_string();
}
if let Ok(idx) = input.parse::<usize>() {
if idx >= 1 && idx <= models.len() {
return models[idx - 1].clone();
}
}
// Treat as literal model name
input.to_string()
}
fn prompt_key() -> String {
eprint!(" Enter API key: ");
io::stderr().flush().unwrap();
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap();
input.trim().to_string()
}
// ─── Subcommands ─────────────────────────────────────────────
fn cmd_grok(args: &[String]) {
eprintln!("\n ── Grok / xAI Import ──\n");
let mut key = None;
let mut model_override = None;
let mut small_model_override = None;
let mut base_url = "https://api.x.ai/v1".to_string();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--key" | "-k" => {
i += 1;
key = args.get(i).cloned();
}
"--model" | "-m" => {
i += 1;
model_override = args.get(i).cloned();
}
"--small-model" => {
i += 1;
small_model_override = args.get(i).cloned();
}
"--base-url" => {
i += 1;
if let Some(u) = args.get(i) {
base_url = u.clone();
}
}
_ => {}
}
i += 1;
}
// Detect key
let api_key = match key {
Some(k) => {
eprintln!(" Using provided key");
k
}
None => {
eprintln!(" Searching for grok-cli credentials...");
match detect_grok_key() {
Some(k) => k,
None => {
eprintln!(" No grok-cli key found.");
let k = prompt_key();
if k.is_empty() {
eprintln!(" Aborted.");
process::exit(1);
}
k
}
}
}
};
// Validate
eprintln!("\n Verifying key with {}...", base_url);
let result = validate_key(&api_key, &base_url);
if !result.valid {
eprintln!(" FAILED: {}", result.error.unwrap_or_default());
process::exit(1);
}
eprintln!(" Key valid.");
// Model selection
let default_model = "grok-4";
let default_small = "grok-3-mini";
let model = match model_override {
Some(m) => m,
None => select_model(&result.models, default_model),
};
let small_model = match small_model_override {
Some(m) => m,
None => {
let candidates: Vec<&str> = vec!["grok-3-mini", "grok-3-mini-fast"];
let auto = result
.models
.iter()
.find(|m| candidates.contains(&m.as_str()))
.map(|s| s.as_str())
.unwrap_or(default_small);
auto.to_string()
}
};
// Write
eprintln!("\n model: {}", model);
eprintln!(" smallModel: {}", small_model);
write_provider("grok", &api_key, &base_url, &model, &small_model);
eprintln!("\n Done. Run `claude` to start.\n");
}
fn cmd_openai_compat(args: &[String]) {
eprintln!("\n ── OpenAI-Compatible Import ──\n");
let mut key = None;
let mut base_url = None;
let mut model = None;
let mut small_model = None;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--key" | "-k" => {
i += 1;
key = args.get(i).cloned();
}
"--base-url" | "-u" => {
i += 1;
base_url = args.get(i).cloned();
}
"--model" | "-m" => {
i += 1;
model = args.get(i).cloned();
}
"--small-model" => {
i += 1;
small_model = args.get(i).cloned();
}
_ => {}
}
i += 1;
}
let api_key = key.unwrap_or_else(|| {
let k = prompt_key();
if k.is_empty() {
eprintln!(" Aborted.");
process::exit(1);
}
k
});
let url = base_url.unwrap_or_else(|| {
eprint!(" Base URL: ");
io::stderr().flush().unwrap();
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap();
let v = input.trim().to_string();
if v.is_empty() {
eprintln!(" Aborted.");
process::exit(1);
}
v
});
// Validate
eprintln!("\n Verifying key with {}...", url);
let result = validate_key(&api_key, &url);
if !result.valid {
eprintln!(" WARNING: {}", result.error.unwrap_or_default());
eprintln!(" Continuing anyway (some APIs don't expose /models)...\n");
} else {
eprintln!(" Key valid.");
}
let selected_model = model.unwrap_or_else(|| {
if result.valid && !result.models.is_empty() {
select_model(&result.models, &result.models[0])
} else {
eprint!(" Model name: ");
io::stderr().flush().unwrap();
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap();
input.trim().to_string()
}
});
let selected_small = small_model.unwrap_or_else(|| selected_model.clone());
eprintln!("\n model: {}", selected_model);
eprintln!(" smallModel: {}", selected_small);
write_provider("openai-compat", &api_key, &url, &selected_model, &selected_small);
eprintln!("\n Done. Run `claude` to start.\n");
}
fn cmd_status() {
eprintln!("\n ── Provider Status ──\n");
match read_current_provider() {
Some(config) => {
let ptype = config["type"].as_str().unwrap_or("anthropic");
let model = config["model"].as_str().unwrap_or("(default)");
let small = config["smallModel"].as_str().unwrap_or("(default)");
let url = config["baseURL"].as_str().unwrap_or("https://api.anthropic.com");
let has_key = config["apiKey"].as_str().map(|k| !k.is_empty()).unwrap_or(false);
eprintln!(" type: {}", ptype);
eprintln!(" model: {}", model);
eprintln!(" smallModel: {}", small);
eprintln!(" baseURL: {}", url);
eprintln!(" apiKey: {}", if has_key { "(set)" } else { "(empty — using OAuth)" });
if has_key && (ptype == "grok" || ptype == "openai-compat") {
let key = config["apiKey"].as_str().unwrap();
eprintln!("\n Verifying...");
let result = validate_key(key, url);
if result.valid {
eprintln!(" Key valid. {} model(s) available.", result.models.len());
} else {
eprintln!(" Key INVALID: {}", result.error.unwrap_or_default());
}
}
}
None => {
eprintln!(" No provider.json found at {}", provider_json_path().display());
eprintln!(" Using default (Anthropic OAuth).");
}
}
eprintln!();
}
fn cmd_reset() {
let default_config = json!({
"apiKey": "",
"baseURL": "https://api.anthropic.com",
"model": "",
"smallModel": "",
"effort": "",
"timeoutMs": 3000000
});
let path = provider_json_path();
let content = serde_json::to_string_pretty(&default_config).unwrap() + "\n";
fs::write(&path, &content).unwrap_or_else(|e| {
eprintln!(" Failed to write {}: {}", path.display(), e);
process::exit(1);
});
eprintln!("\n Reset provider.json to default (Anthropic).\n");
}
// ─── Main ────────────────────────────────────────────────────
fn print_usage() {
eprintln!(
r#"
clawgod-import — Import third-party API providers into clawgod
Usage:
clawgod-import grok [options] Import from grok-cli / xAI
clawgod-import openai-compat [options] Import any OpenAI-compatible API
clawgod-import status Show current provider config
clawgod-import reset Reset to default (Anthropic)
Options (grok):
-k, --key <KEY> API key (auto-detects from ~/.grok/ if omitted)
-m, --model <MODEL> Model name (default: grok-4)
--small-model <MODEL> Small/fast model (default: grok-3-mini)
--base-url <URL> API base URL (default: https://api.x.ai/v1)
Options (openai-compat):
-k, --key <KEY> API key
-u, --base-url <URL> API base URL (required)
-m, --model <MODEL> Model name
--small-model <MODEL> Small/fast model
Examples:
clawgod-import grok # auto-detect grok-cli key
clawgod-import grok -k xai-abc123 # manual key
clawgod-import openai-compat -k sk-xxx \
-u https://api.deepseek.com/v1 -m deepseek-chat
"#
);
}
fn main() {
let args: Vec<String> = env::args().collect();
match args.get(1).map(|s| s.as_str()) {
Some("grok") => cmd_grok(&args[2..]),
Some("openai-compat") | Some("openai") => cmd_openai_compat(&args[2..]),
Some("status") => cmd_status(),
Some("reset") => cmd_reset(),
Some("--help") | Some("-h") | Some("help") => print_usage(),
_ => {
print_usage();
if args.len() > 1 {
process::exit(1);
}
}
}
}