Compare commits
92
Commits
secure-main
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1d322f970 | ||
|
|
59488807cc | ||
|
|
23ff76a1e6 | ||
|
|
d7e839d973 | ||
|
|
677e642637 | ||
|
|
35ed8d8b2f | ||
|
|
0432b2b6ca | ||
|
|
f9d2dcde3d | ||
|
|
7bf3b15b10 | ||
|
|
ec43a48dfb | ||
|
|
3808af3ea1 | ||
|
|
919338321c | ||
|
|
8364074087 | ||
|
|
ee324c94b8 | ||
|
|
a039481e9b | ||
|
|
9ec57f5aac | ||
|
|
e0c7dc7c75 | ||
|
|
9acc631fa9 | ||
|
|
1233e8085f | ||
|
|
c5074a96dd | ||
|
|
5b1549abde | ||
|
|
8286158233 | ||
|
|
96309e5a07 | ||
|
|
5aceee55c9 | ||
|
|
dee5fdc531 | ||
|
|
55b2478ab8 | ||
|
|
e41e4ea2f1 | ||
|
|
fcc2d3f480 | ||
|
|
0e9e550ef2 | ||
|
|
e252261f8e | ||
|
|
5819197a59 | ||
|
|
106ba6745a | ||
|
|
97de06fb3c | ||
|
|
f12547ec55 | ||
|
|
38cec0c5f2 | ||
|
|
c8235e2354 | ||
|
|
aba6be8948 | ||
|
|
4198018a8d | ||
|
|
87d23ba2ee | ||
|
|
87621db142 | ||
|
|
0501b68254 | ||
|
|
7888abab9f | ||
|
|
c04d1e501a | ||
|
|
23cc72dd29 | ||
|
|
4401fdb005 | ||
|
|
936e8c5684 | ||
|
|
20253023b8 | ||
|
|
d0947549de | ||
|
|
a8f8495495 | ||
|
|
497773a24f | ||
|
|
96b87086eb | ||
|
|
507405a053 | ||
|
|
5f5f070750 | ||
|
|
5fb2572422 | ||
|
|
b75e608a79 | ||
|
|
01efa1c470 | ||
|
|
94582f4edf | ||
|
|
9ff9563db1 | ||
|
|
570986475d | ||
|
|
b9f135f44a | ||
|
|
bbf2eca462 | ||
|
|
f61ff7dc11 | ||
|
|
b3797eaaf8 | ||
|
|
84f1d1768a | ||
|
|
2ea55bbbfe | ||
|
|
27c609823c | ||
|
|
2c8ae78b76 | ||
|
|
aa0f4a3cdb | ||
|
|
f78ae50391 | ||
|
|
185d61ad11 | ||
|
|
b9e6498b0d | ||
|
|
7995d12495 | ||
|
|
24f405f7ae | ||
|
|
6ec137b293 | ||
|
|
5a380b8f92 | ||
|
|
c7b76320a7 | ||
|
|
cb0315b215 | ||
|
|
181ffcc675 | ||
|
|
d20dead4b9 | ||
|
|
1274aa743c | ||
|
|
42f005e107 | ||
|
|
2ebe1a88c3 | ||
|
|
c69165fffa | ||
|
|
f8c7ede22f | ||
|
|
70511668d3 | ||
|
|
099f7c1b78 | ||
|
|
0e659f29cd | ||
|
|
f5527edbdf | ||
|
|
c5ba3baa6b | ||
|
|
28bdfe61a2 | ||
|
|
1f2dfde554 | ||
|
|
e7d290e0f4 |
@@ -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
|
||||
@@ -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; });
|
||||
@@ -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' });
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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
|
||||
@@ -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 \
|
||||
|
||||
@@ -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号>`。
|
||||
@@ -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
|
||||
|
||||
[](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
@@ -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
|
||||
|
||||
[](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
@@ -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
|
||||
|
||||
[](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>
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+2711
-533
File diff suppressed because it is too large
Load Diff
+161
@@ -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.
|
||||
@@ -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...]');
|
||||
@@ -0,0 +1,2 @@
|
||||
pyte==0.8.2
|
||||
pywinpty==3.0.5; sys_platform == 'win32'
|
||||
@@ -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()
|
||||
@@ -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');
|
||||
}
|
||||
Executable
+465
@@ -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);
|
||||
@@ -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');
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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)}`);
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 ""
|
||||
@@ -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 ""
|
||||
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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}`);
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
target/
|
||||
Generated
+670
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user