Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"

- name: Bash syntax check (bash -n)
run: bash -n claude-think claude-context claudemax patch-extension.sh test-thinking-display.sh

- name: ShellCheck
# ubuntu-latest ships shellcheck. Gate at warning severity: the only
# findings are info-level SC2015 (A && B || C) notes in the launchers,
# where the C branch firing is the intended best-effort behavior.
run: shellcheck --severity=warning claude-think claude-context claudemax patch-extension.sh test-thinking-display.sh

- name: Node syntax check (node --check)
run: |
for f in claude-think.win.js claude-context.win.js claudemax.win.js proxy.js; do
node --check "$f"
done

- name: Python compile
run: python3 -m py_compile fix-context-icon.py tests/test_regressions.py

- name: Regression tests
run: python3 -m unittest discover -s tests -v
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ node_modules/
# Python bytecode (e.g. from running fix-context-icon.py)
__pycache__/
*.pyc
*.py[cod]

# Editor / OS noise
*.bak.*
Expand Down
7 changes: 4 additions & 3 deletions claude-context
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,11 @@ fi
CC_PATCH_CONTEXT_ICON="${CC_PATCH_CONTEXT_ICON:-1}"

_cc_patch_index_js() {
local f="$1" tmp tmp2
local f="$1" tmp tmp2 old_count
[ -f "$f" ] && [ -w "$f" ] || return 0
if grep -q '>=101)return null}' "$f" 2>/dev/null; then return 0; fi # already patched
if ! grep -q '>=50)return null}' "$f" 2>/dev/null; then return 0; fi # gate absent (version changed)
old_count="$( (grep -o '>=50)return null}' "$f" 2>/dev/null || true) | wc -l | tr -d ' ')"
if [ "$old_count" != "1" ]; then return 0; fi # absent or ambiguous (version changed)
if [ ! -e "$f.bak-context-icon" ]; then cp -p "$f" "$f.bak-context-icon" 2>/dev/null || true; fi
tmp="${f}.ccpatch.$$"
tmp2="${f}.ccpatch2.$$"
Expand All @@ -127,7 +128,7 @@ _cc_patch_index_js() {
# second temp, copy that back over the metadata-preserving temp, then atomically
# replace the original. A failed/partial step leaves the original untouched.
cp -p "$f" "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null || true; return 0; }
if sed 's/>=50)return null}/>=101)return null}/g' "$f" > "$tmp2" 2>/dev/null \
if sed 's/>=50)return null}/>=101)return null}/' "$f" > "$tmp2" 2>/dev/null \
&& [ -s "$tmp2" ] \
&& grep -q '>=101)return null}' "$tmp2" 2>/dev/null; then
cat "$tmp2" > "$tmp" && mv -f "$tmp" "$f" || true
Expand Down
96 changes: 90 additions & 6 deletions claude-context.win.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,89 @@ function findClaude() {
return null;
}

function findExecutableOnPath(name) {
const lookup = process.platform === "win32" ? "where" : "which";
try {
const out = execFileSync(lookup, [name], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
const hit = out
.split(/\r?\n/)
.map((s) => s.trim())
.find((s) => s && fs.existsSync(s));
if (hit) return hit;
} catch (_) {
/* not on PATH */
}
return null;
}

function expandShimPath(raw, shimDir) {
let s = raw.trim().replace(/^["']|["']$/g, "");
s = s.replace(/%~?dp0%?/gi, shimDir + path.sep);
s = s.replace(/%([^%]+)%/g, (m, name) => process.env[name] || m);
return path.isAbsolute(s) ? s : path.resolve(shimDir, s);
}

function resolveShimEntrypoint(shim) {
const shimDir = path.dirname(path.resolve(shim));
const candidates = [
path.join(shimDir, "node_modules", "@anthropic-ai", "claude-code", "cli.js"),
path.resolve(shimDir, "..", "@anthropic-ai", "claude-code", "cli.js"),
path.resolve(
shimDir,
"..",
"node_modules",
"@anthropic-ai",
"claude-code",
"cli.js"
),
];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
try {
const data = fs.readFileSync(shim, "utf8");
const matches = data.matchAll(
/(?:"([^"]+?\.js)"|'([^']+?\.js)'|([^\s"']+?\.js))/gi
);
for (const m of matches) {
const hit = expandShimPath(m[1] || m[2] || m[3], shimDir);
if (fs.existsSync(hit)) return hit;
}
} catch (_) {
/* unreadable shim */
}
return null;
}

function resolveNodeForShim(shim) {
const shimDir = path.dirname(path.resolve(shim));
const candidates = [
process.env.CC_NODE_BIN,
path.join(shimDir, "node.exe"),
path.join(shimDir, "node"),
process.pkg ? null : process.execPath,
findExecutableOnPath("node"),
].filter(Boolean);
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
return null;
}

function resolveClaudeInvocation(command, args) {
if (!/\.(cmd|bat)$/i.test(command)) return { command, args };
const cli = resolveShimEntrypoint(command);
const node = resolveNodeForShim(command);
if (cli && node) return { command: node, args: [cli, ...args] };
console.error(
"claude-context: refusing to launch unresolved .cmd/.bat shim without a shell; set CLAUDE_REAL_BIN to claude.exe or CC_NODE_BIN to node.exe"
);
return null;
}

// Process-wrapper convention: the official VS Code extension invokes the wrapper
// as <wrapper> <REAL_CLAUDE...> <args...>, passing the real CLI ahead of the
// args. <REAL_CLAUDE...> is either a single native-binary path (".../claude.exe")
Expand Down Expand Up @@ -151,7 +234,8 @@ function ccPatchIndexJs(file) {
return; // not readable
}
if (data.indexOf(ICON_NEW) !== -1) return; // already patched
if (data.indexOf(ICON_OLD) === -1) return; // gate absent (version changed)
const oldMatches = data.split(ICON_OLD).length - 1;
if (oldMatches !== 1) return; // gate absent or ambiguous (version changed)
const bak = file + ".bak-context-icon";
if (!fs.existsSync(bak)) {
try {
Expand All @@ -160,7 +244,7 @@ function ccPatchIndexJs(file) {
/* best-effort backup */
}
}
const patched = data.split(ICON_OLD).join(ICON_NEW);
const patched = data.replace(ICON_OLD, ICON_NEW);
if (patched.indexOf(ICON_NEW) === -1) return; // sanity: substitution took
const tmp = file + ".ccpatch." + process.pid;
try {
Expand Down Expand Up @@ -238,11 +322,11 @@ restoreContextIcon(wrapperBin);

// This variant injects nothing into the args - it only patches the webview, then
// forwards every argument through to the real claude unchanged.
// .cmd/.bat (npm install) need a shell; .exe (native install) is exec'd directly.
const useShell = /\.(cmd|bat)$/i.test(claude);
const res = spawnSync(claude, argv, {
const invocation = resolveClaudeInvocation(claude, argv);
if (!invocation) process.exit(1);
const res = spawnSync(invocation.command, invocation.args, {
stdio: "inherit",
env: process.env,
shell: useShell,
shell: false,
});
process.exit(res.status == null ? 1 : res.status);
21 changes: 20 additions & 1 deletion claude-think
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,13 @@ fi

# Set CC_THINKING_DISPLAY=omitted to hide thinking; default shows summaries.
DISPLAY_VALUE="${CC_THINKING_DISPLAY:-summarized}"
case "$DISPLAY_VALUE" in
summarized|omitted) ;;
*)
echo "claude-think: invalid CC_THINKING_DISPLAY=$DISPLAY_VALUE; using summarized" >&2
DISPLAY_VALUE="summarized"
;;
esac

# --- Optional customizations ------------------------------------------------
#
Expand Down Expand Up @@ -129,9 +136,21 @@ prev=""

for a in "$@"; do
case "$a" in
--thinking-display)
--thinking-display|--thinking-display=*)
have_display=true
;;
--thinking=adaptive|--thinking=enabled)
thinking_adaptive=true
;;
--thinking=disabled)
thinking_disabled=true
;;
--max-thinking-tokens=*)
v="${a#*=}"
if [ -n "$v" ] && [ "$v" != "0" ]; then
max_thinking_on=true
fi
;;
-p|--print)
print_mode=true
;;
Expand Down
115 changes: 109 additions & 6 deletions claude-think.win.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,97 @@ function findClaude() {
return null;
}

function findExecutableOnPath(name) {
const lookup = process.platform === "win32" ? "where" : "which";
try {
const out = execFileSync(lookup, [name], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
const hit = out
.split(/\r?\n/)
.map((s) => s.trim())
.find((s) => s && fs.existsSync(s));
if (hit) return hit;
} catch (_) {
/* not on PATH */
}
return null;
}

function expandShimPath(raw, shimDir) {
let s = raw.trim().replace(/^["']|["']$/g, "");
s = s.replace(/%~?dp0%?/gi, shimDir + path.sep);
s = s.replace(/%([^%]+)%/g, (m, name) => process.env[name] || m);
return path.isAbsolute(s) ? s : path.resolve(shimDir, s);
}

function resolveShimEntrypoint(shim) {
const shimDir = path.dirname(path.resolve(shim));
const candidates = [
path.join(shimDir, "node_modules", "@anthropic-ai", "claude-code", "cli.js"),
path.resolve(shimDir, "..", "@anthropic-ai", "claude-code", "cli.js"),
path.resolve(
shimDir,
"..",
"node_modules",
"@anthropic-ai",
"claude-code",
"cli.js"
),
];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
try {
const data = fs.readFileSync(shim, "utf8");
const matches = data.matchAll(
/(?:"([^"]+?\.js)"|'([^']+?\.js)'|([^\s"']+?\.js))/gi
);
for (const m of matches) {
const hit = expandShimPath(m[1] || m[2] || m[3], shimDir);
if (fs.existsSync(hit)) return hit;
}
} catch (_) {
/* unreadable shim */
}
return null;
}

function resolveNodeForShim(shim) {
const shimDir = path.dirname(path.resolve(shim));
const candidates = [
process.env.CC_NODE_BIN,
path.join(shimDir, "node.exe"),
path.join(shimDir, "node"),
process.pkg ? null : process.execPath,
findExecutableOnPath("node"),
].filter(Boolean);
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
return null;
}

function resolveClaudeInvocation(command, args) {
if (!/\.(cmd|bat)$/i.test(command)) return { command, args };
const cli = resolveShimEntrypoint(command);
const node = resolveNodeForShim(command);
if (cli && node) return { command: node, args: [cli, ...args] };
console.error(
"claude-think: refusing to launch unresolved .cmd/.bat shim without a shell; set CLAUDE_REAL_BIN to claude.exe or CC_NODE_BIN to node.exe"
);
return null;
}

function normalizeDisplayValue(value) {
if (value === "summarized" || value === "omitted") return value;
console.error(
`claude-think: invalid CC_THINKING_DISPLAY=${value}; using summarized`
);
return "summarized";
}

// Process-wrapper convention: the official VS Code extension invokes the wrapper
// as <wrapper> <REAL_CLAUDE...> <args...>, passing the real CLI ahead of the
// args. <REAL_CLAUDE...> is either a single native-binary path (".../claude.exe")
Expand Down Expand Up @@ -119,7 +210,9 @@ if (!claude) {

// --- Behavior --------------------------------------------------------------
// Set CC_THINKING_DISPLAY=omitted to hide thinking; default shows summaries.
const displayValue = process.env.CC_THINKING_DISPLAY || "summarized";
const displayValue = normalizeDisplayValue(
process.env.CC_THINKING_DISPLAY || "summarized"
);

// --- Optional customizations -----------------------------------------------
//
Expand Down Expand Up @@ -150,8 +243,18 @@ let haveDisplay = false,
maxThinkingOn = false;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--thinking-display") haveDisplay = true;
if (a === "--thinking-display" || a.startsWith("--thinking-display=")) {
haveDisplay = true;
}
if (a === "-p" || a === "--print") printMode = true;
if (a === "--thinking=adaptive" || a === "--thinking=enabled") {
thinkingAdaptive = true;
}
if (a === "--thinking=disabled") thinkingDisabled = true;
if (a.startsWith("--max-thinking-tokens=")) {
const v = a.slice("--max-thinking-tokens=".length);
if (v && v !== "0") maxThinkingOn = true;
}
if (a === "--max-thinking-tokens") {
const v = argv[i + 1];
if (v && v !== "0") maxThinkingOn = true;
Expand All @@ -171,11 +274,11 @@ if (
args.push("--thinking-display", displayValue);
}

// .cmd/.bat (npm install) need a shell; .exe (native install) is exec'd directly.
const useShell = /\.(cmd|bat)$/i.test(claude);
const res = spawnSync(claude, args, {
const invocation = resolveClaudeInvocation(claude, args);
if (!invocation) process.exit(1);
const res = spawnSync(invocation.command, invocation.args, {
stdio: "inherit",
env: process.env,
shell: useShell,
shell: false,
});
process.exit(res.status == null ? 1 : res.status);
Loading
Loading