Installs the latest rtk token-optimizing CLI proxy (https://github.com/rtk-ai/rtk) release into ~/.local/bin on first use (Linux x86_64) and routes Bash…

LINUX-ONLY TOOLING. This plugin's rtk binary — installed into ~/.local/bin — and the codebase-memory-mcp binary it downloads are Linux x86_64 executables. On macOS, Windows or any other operating system every hook no-ops and none of that tooling runs; the always-on terse output style (below) is the one component that still applies.
/plugin install linux-token-efficiency@kwitsch-plugins
Installs the rtk token-optimizing CLI proxy — the latest release, asset rtk-x86_64-unknown-linux-musl.tar.gz — into ~/.local/bin/rtk on first use (only when not already present there), and routes Bash tool calls through it automatically.
At SessionStart the plugin installs rtk to ~/.local/bin/rtk (when absent); it is invocable by hand as rtk … even when ~/.local/bin itself is not on your PATH, via a small bridge wrapper the plugin also puts on PATH (a small wrapper under the plugin's own bin/, always added while the plugin is enabled), and the rewrite gracefully no-ops when neither resolves. On top of that, an in-process hook (a mods tool.call hook, no process per call) routes each Bash command: a conservatively classified read-only gather command (a bare curl GET, a ≥3-command chain of text tools like grep/cat/wc, or a ≥3-stage text pipeline) is denied with a ready-to-use replacement call on context-mode's ctx_fetch_and_index / ctx_batch_execute / ctx_execute (see "context-mode steering" below); every other command is piped into the bundled binary's own rtk hook claude protocol and the rewritten, token-optimized command handed back to the harness — no rtk prefix to remember. The split is deliberate: git/gh and anything with side effects stay in Bash under rtk compression; only output-heavy read-only gathering moves to the context-mode sandbox, where output is indexed instead of entering the context window.
Set via /plugin manage, stored in settings.json under pluginConfigs["linux-token-efficiency"].options.
| Option | Default | Effect / Value |
|---|---|---|
auto_rewrite | true | true (or unset): rewrite every Bash command through rtk. false: no rewriting. Only literal false disables. |
cbm_enabled | true | true (or unset): run the codebase-memory MCP server (downloading the latest cbm release, verified against its checksums.txt, into the plugin data dir on first start) and enable its four mcp_tool context hooks. false: neither runs and nothing is downloaded. Only the literal value false disables. |
rtk_enabled | true | true (or unset): at SessionStart, install the latest rtk release into ~/.local/bin/rtk when absent (download + verify against the release's checksums.txt, atomic). false: nothing is installed or downloaded. Only the literal value false disables. |
steer_enabled | true | true (or unset): deny-steer WebFetch and read-only Bash gather commands to context-mode's ctx_* tools (see "context-mode steering" below). false: no steering — the escape hatch when the context-mode server is unavailable. Does not affect the rtk rewrite. Only the literal value false disables. |
The context-mode server (below) has no toggle — it is always enabled. steer_enabled gates only the two steering hooks, never the server itself. Changing an option reloads the hooks module.
The rewrite is an optimization and fails open everywhere — a command that stays in Bash is never broken by it (the steering branch above is the only path that denies, and only with a working replacement in hand). The rewrite deliberately no-ops when:
auto_rewrite is set to false;HOME is unset or blank;~/.local/bin/rtk is not yet installed (SessionStart install pending or rtk_enabled=false), or does not resolve — following a symlink — to a regular file (a symlinked install is fine; a dangling symlink or a directory is not);rtk install appears earlier on PATH — that install and its own hook already own the rewrite, and this plugin never double-wires (its own PATH-bridge wrapper under bin/ is not treated as a competing install). On such a machine (a maintainer's own, typically) the plugin looks inert by design;rtk resolves neither on PATH nor through the plugin's own bridge — the hook never emits a command it has no evidence can run;git and the session runs in a linked git worktree (the harness's worktree-isolation guard refuses an rtk git … call there, so the plain git … runs instead);Ships codebase-memory-mcp — the latest release, asset codebase-memory-mcp-linux-amd64-portable.tar.gz — as the MCP stdio server codebase-memory registered through .mcp.json. Its 15 graph tools appear as mcp__plugin_linux-token-efficiency_codebase-memory__* (the plugin-namespaced tool-name form; a bare mcp__codebase-memory__* matcher never fires for a plugin-bundled server), named after upstream's own tool names.
Nothing cbm-related is committed to this repo. mcp/server.mjs is a small Node proxy: on its first start it downloads the latest release asset from GitHub Releases, verifies its sha256 against the release's own checksums.txt, extracts it, and only then moves it into the cache with an atomic rename. It then spawns that binary once as a long-lived child and forwards every tool call to it, so all 15 upstream tools keep working unchanged. The tarball's sha256 must match its checksums.txt entry or nothing is cached — enough to catch a corrupted or truncated download, though a checksums.txt served by the same origin as the asset is not an independent trust anchor. The download happens once per release per cache root, is bounded by a 5-minute timeout, and is attempted at most once per server process: on an offline or firewalled host the server degrades silently instead of hanging your session.
Download cache. ${CLAUDE_PLUGIN_DATA}/cbm/<first-16-hex-of-asset-sha256>/codebase-memory-mcp. It is content-addressed, so a new release lands in a new directory and old ones are simply left behind — there is no pruning. Reclaim the space by hand:
rm -rf "$HOME/.claude/plugins/data/linux-token-efficiency/cbm" # path per your CLAUDE_PLUGIN_DATA
This is the plugin's own download cache only. cbm's graph database lives at its own upstream default (~/.cache/codebase-memory-mcp, CBM_CACHE_DIR), which this plugin never sets or touches.
The four context hooks are mcp_tool hooks served by that same warm server — no process is spawned per event. They inject text only: they never block, deny, rewrite or replace a tool call.
| Event | Hook tool | What it injects |
|---|---|---|
SessionStart | hook_session_context | the graph project covering this repo, its index state, and a nudge to prefer the graph tools over text search |
SubagentStart | hook_subagent_context | the same, shorter, plus "pass qualified symbols/paths through" |
PreToolUse Grep/Glob | hook_symbol_context | up to 10 matching qualified symbols with files/lines |
PostToolUse Read | hook_coverage_context | a warning only when the graph reports incomplete coverage for that file |
Nothing is injected when no graph project matches the session's directory (indexing is never suggested and never started), when the result is empty or unrecognized, when a graph read exceeds its 4-second budget, or while the first-run download is still in flight. On the very first session after a fresh install the SessionStart hook in particular injects nothing: an mcp_tool hook needs an already-connected server, and the download is still running. Later sessions (and every resume / clear / compact) run against a warm cache and a connected server.
Enabled by default. Enabling the plugin is the consent step: there is no separate /mcp approval prompt for a plugin-provided server. Set cbm_enabled to false to opt out — the server then exits 0 immediately, never reaches the network, and shows up as not connected in /mcp, and all four hooks stay silent (only the literal value false disables). Disabling does not delete an already-downloaded cache.
After a plugin update, restart your sessions. cbm requires every active process to run the exact same executable build; a session whose server started on the previous build keeps running it.
Registers the external context-mode npm package (Elastic License 2.0) — pinned to 1.0.169 — as the MCP stdio server context-mode. Its eleven ctx_* tools (ctx_execute, ctx_execute_file, ctx_batch_execute, ctx_index, ctx_search, ctx_fetch_and_index, ctx_stats, ctx_doctor, ctx_upgrade, ctx_purge, ctx_insight) appear as mcp__plugin_linux-token-efficiency_context-mode__* (the plugin-namespaced tool-name form — not the bare mcp__context-mode__*, which never fires for a plugin-bundled server).
bin/context-mode-launch.sh starts it: bunx context-mode@1.0.169 when bunx resolves, otherwise npx --yes context-mode@1.0.169, and an error on stderr with exit 1 when neither is installed. The package is fetched from the npm registry on first start into bun's or npm's own global package cache (not this plugin's data directory), so the first handshake is slower, and on an offline host the server simply shows as not connected in /mcp — nothing else in the plugin depends on it. Unlike the cbm download, a registry fetch cannot be sha256-verified: the version pin buys reproducibility, not integrity. context-mode keeps its own SQLite knowledge base at its upstream default location.
Session-start routing rules. hooks/SessionStart.md is upstream's own routing-rules document, copied byte-for-byte, and a SessionStart hook literally runs cat on it, so its ~5 KB of rules are injected on every startup, resume, clear and compact.
context-mode steering (steer_enabled, default on). Two in-process tool-call hooks (on Bash and WebFetch) turn the routing rules from advice into enforcement, each deny carrying a complete, ready-to-use replacement call:
| Denied call | Replacement in the deny reason |
|---|---|
every WebFetch | ctx_fetch_and_index(url, source) then ctx_search |
Bash: a single bare curl GET | ctx_fetch_and_index(url, source) then ctx_search |
Bash: ≥3 chained read-only text-tool commands (grep, cat, wc, find, jq, …) | ctx_batch_execute(commands, queries) |
| Bash: a ≥3-stage all-read-only pipeline | ctx_execute(language: "shell", code) |
The Bash classifier is deliberately conservative: anything with side effects, redirects, command substitution, unknown programs, backgrounding, git/gh, or wget (whose default is a file download) stays in Bash and gets the rtk rewrite instead. Steering never depends on the rtk binary and works even with auto_rewrite off. If the context-mode server is not connected, a denied call has no working replacement — set steer_enabled to false as the escape hatch.
The document's "BLOCKED" sections are upstream's claims — as of 0.4.0 the steering hooks make some of them real. WebFetch and bare curl GETs are now actually denied (by this plugin's own hooks, not by upstream's routing engine, which is still not ported). Still not intercepted: wget and inline HTTP inside sandboxed code. The file is kept verbatim so that re-syncing with upstream stays a plain copy.
ctx_execute and ctx_batch_execute run code and shell commands inside context-mode's sandbox, which does not pass through Bash PreToolUse hooks — neither this plugin's rtk rewrite nor another plugin's deny gates see them. There is no toggle to disable this — the server is always registered, and the steering hooks knowingly route more work through this bypass.
Three token-efficiency signals now coexist in this one plugin: rtk's Bash rewriting, cbm's graph nudges, and context-mode's "prefer ctx_* over Bash/Grep/Read" rules. They never conflict mechanically (an MCP tool call cannot match a Bash matcher) but they do compete for the model's attention.
Native Windows. bin/context-mode-launch.sh is a #!/usr/bin/env bash script and the SessionStart hook runs a bare cat — neither bash nor cat is on PATH by default on native Windows (outside WSL or Git Bash), so both fail to start there, unlike the rtk/codebase-memory features which are already Linux-only for a different reason (bundled Linux binaries).
Registers the output style terse (output-styles/terse.md). Its frontmatter carries force-for-plugin: true, so Claude Code applies it automatically whenever the plugin is enabled and overrides whatever style you picked via /config — there is no activation step and no toggle. keep-coding-instructions: true keeps Claude Code's built-in software-engineering instructions active, so only response formatting changes, never coding behavior.
What it enforces: responses as short as their content allows — multi-level bullet lists instead of prose, no preamble, no postamble, no restating the task, no narrating routine work, and only the feedback you genuinely need, once.
It takes effect in a new session or after /clear, and it governs the main conversation only (a subagent has its own system prompt). It is also this plugin's one OS-independent component: it applies on macOS and Windows too, where every hook and bundled binary stays inert.
rtk and cbm always install the latest upstream release, each verified against that release's own checksums.txt at download time — there is no committed version pin and no maintainer skill to refresh one. hooks/rtk-install.mjs provisions rtk into ~/.local/bin/rtk, and mcp/server.mjs downloads and caches the cbm binary; neither commits or replaces an artifact in git.
The only hand-maintained cbm artifact is cbm-tools.json, a snapshot of the advertised tool list served on the MCP handshake so it never waits on a download. Call-time forwarding is name-agnostic, so a drift there costs advertisement, never a working call — refresh it by hand if upstream's tool set changes.
hooks/register.ts 107 lines1import type { EngineInterface, Register } from "claude-code";
2import {
3 GIT_WORKTREE_CHECK_TIMEOUT_MS,
4 INPUT_CAP,
5 RTK_SPAWN_TIMEOUT_MS,
6 buildSteerDeny,
7 buildWebFetchDeny,
8 classifyBashCommand,
9 gitDirs,
10 hasGitOperand,
11 isAutoRewriteEnabled,
12 isSteerEnabled,
13 parseRtkRewrite,
14 resolveManagedRtk,
15 rtkCandidates,
16} from "./rtk-rewrite.mjs";
17
18// The two per-call PreToolUse command hooks (Bash router, WebFetch steer) as in-process tool.call hooks.
19// Fail-open everywhere: every helper turns a failure into "no rewrite", and each hook calls next once, outside any try.
20// Chain composition: another plugin's tool.call rewrite above this one hands its command in as e.command; this hook
21// classifies and rewrites that command, and whatever sits beneath receives this hook's result. rtk answers nothing for
22// a command it already rewrote (`rtk git status`), so an rtk prefix added above is left as it is.
23
24let linux: Promise<boolean> | undefined; // the platform never changes within a process: probed once
25const isLinux = ($: EngineInterface): Promise<boolean> => (linux ??= $.fs.exists("/proc/version").catch(() => false));
26
27// Where a path lands (every link followed), or undefined when it leads nowhere.
28const realPath = async ($: EngineInterface, path: string): Promise<string | undefined> => (await $.fs.stat(path, { resolve: true }).catch(() => undefined))?.realPath;
29
30// Whether cwd is inside a LINKED git worktree; false (rewrite applies) on any failure or outside a repo.
31const isLinkedWorktree = async ($: EngineInterface, cwd: string): Promise<boolean> => {
32 try {
33 const r = await $.process.run(["git", "rev-parse", "--git-dir", "--git-common-dir"], { cwd, timeoutMs: GIT_WORKTREE_CHECK_TIMEOUT_MS });
34 const dirs = gitDirs(r.exitCode, r.stdout, cwd);
35 if (dirs === null) return false;
36 const [gitDir, commonDir] = await Promise.all([realPath($, dirs[0]), realPath($, dirs[1])]);
37 return gitDir !== undefined && commonDir !== undefined && gitDir !== commonDir;
38 } catch {
39 return false;
40 }
41};
42
43// rtk's rewrite of `command`, or null. Preflight: ~/.local/bin/rtk is a regular file (links followed), the first `rtk`
44// a bare word resolves to in the Bash tool is that binary or this plugin's own bin/rtk bridge (a global rtk install owns
45// the rewrite otherwise: never double-wire), then `rtk hook claude` by absolute path with the PreToolUse JSON on stdin.
46const rtkRewrite = async ($: EngineInterface, command: string, toolInput: Record<string, unknown>): Promise<string | null> => {
47 try {
48 const managed = resolveManagedRtk({ HOME: await $.env.get("HOME") });
49 if (managed === null) return null;
50 const own = await $.fs.stat(managed, { resolve: true }).catch(() => undefined);
51 if (own?.kind !== "file" || own.realPath === undefined) return null; // not installed yet, dangling, or no regular file
52 let hit: string | undefined;
53 // shortcut: a candidate counts when it is a regular file; the fs API has no execute bit, so a non-executable `rtk`
54 // ahead on PATH ends the walk where bash would skip it (a no-op, or a failed spawn: fail-open either way).
55 for (const candidate of rtkCandidates(await $.env.get("PATH"), `${$.plugin.root}/bin`)) {
56 const st = await $.fs.stat(candidate, { resolve: true }).catch(() => undefined);
57 if (st?.kind === "file") {
58 hit = st.realPath;
59 break;
60 }
61 }
62 if (hit === undefined) return null;
63 if (hit !== own.realPath && hit !== (await realPath($, `${$.plugin.root}/bin/rtk`))) return null;
64 const cwd = await $.session.cwd(); // read inside the hook: a subagent's own directory
65 const stdin = JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "Bash", cwd, tool_input: toolInput });
66 const r = await $.process.run([managed, "hook", "claude"], { cwd, stdin, timeoutMs: RTK_SPAWN_TIMEOUT_MS });
67 if (r.exitCode !== 0 || r.isStdoutTruncated) return null; // a signal reads as exit 1
68 const rewritten = parseRtkRewrite(r.stdout, command);
69 if (rewritten === null) return null;
70 // In a linked worktree the harness's isolation guard refuses `rtk git …` (it cannot see through the wrapper):
71 // withhold a rewrite that names git there so the original `git …` runs under the guard's own git-aware check.
72 if (hasGitOperand(rewritten) && (await isLinkedWorktree($, cwd))) return null;
73 return rewritten;
74 } catch {
75 return null;
76 }
77};
78
79export const register: Register = (on, options) => {
80 const steerOn = isSteerEnabled(options.steer_enabled);
81 const rewriteOn = isAutoRewriteEnabled(options.auto_rewrite);
82
83 if (steerOn || rewriteOn) {
84 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
85 const { tool: _tool, tool_use_id: _id, consent: _consent, agentId: _agent, requestMeta: _meta, ...toolInput } = e;
86 const command = e.command;
87 if (typeof command !== "string" || command === "" || command.length > INPUT_CAP || !(await isLinux($))) return next(e);
88 // Never steer a backgrounded call: the ctx_* tools cannot background, so a deny would strand the model.
89 if (steerOn && String(e.run_in_background) !== "true") {
90 const deny = buildSteerDeny(classifyBashCommand(command))?.hookSpecificOutput?.permissionDecisionReason;
91 if (deny !== undefined) return { deny };
92 }
93 const rewritten = rewriteOn ? await rtkRewrite($, command, toolInput) : null;
94 return next(rewritten === null ? e : { ...e, command: rewritten });
95 });
96 }
97
98 if (steerOn) {
99 on("tool.call", { tool: "WebFetch" }, async ($, e, next) => {
100 const url = e.url;
101 if (typeof url !== "string" || url === "" || !(await isLinux($))) return next(e);
102 const deny = buildWebFetchDeny(url).hookSpecificOutput?.permissionDecisionReason;
103 return deny === undefined ? next(e) : { deny };
104 });
105 }
106};
107hooks/rtk-rewrite.mjs 406 lines1// hooks/rtk-rewrite.mjs — linux-token-efficiency plugin: the pure policy behind the
2// mods hooks module's PreToolUse-equivalent `tool.call` hooks (hooks/register.ts).
3// Zero-dep, no `node:` import and never the mods `$`: the engine refuses passing `$`
4// into an imported function, and a mods module is not known to load node built-ins.
5// Every file system, environment and process access lives in register.ts.
6//
7// The Bash hook is a router with two exits, in this order:
8//
9// 1. STEER (context-mode): a conservatively classified read-only gather command —
10// a bare curl GET, a >=3-segment chain of text tools, or a >=3-stage text
11// pipeline — is denied with a copy-ready replacement call on context-mode's
12// ctx_fetch_and_index / ctx_batch_execute / ctx_execute. Gated by steer_enabled.
13// 2. REWRITE (rtk): everything else is piped into the managed binary's own
14// `rtk hook claude` protocol (spawned by ABSOLUTE path) and rtk's rewritten command
15// replaces `command`. The command keeps rtk's bare `rtk …` word — no path
16// rewriting, no PATH prefix. Gated by auto_rewrite.
17//
18// The split is the deliberate rtk/context-mode balance: git/gh and other side-effect
19// or single commands STAY in Bash under rtk compression (the measured winner for that
20// class); only the read-only gather class rtk does not rewrite is steered into the
21// context-mode sandbox, where output is indexed instead of entering context.
22
23/** Same cap the command hook applied to its stdin: a larger command is left alone. */
24export const INPUT_CAP = 1024 * 1024;
25/** `rtk hook claude` round-trip bound; a slower rtk means no rewrite. */
26export const RTK_SPAWN_TIMEOUT_MS = 5000;
27/** One `git rev-parse --git-dir --git-common-dir` call. */
28export const GIT_WORKTREE_CHECK_TIMEOUT_MS = 2000;
29
30/**
31 * auto_rewrite toggle, fail-open per .claude/rules/plugin-userconfig.md: unset, empty,
32 * true/"true" and an uninterpolated `${user_config.auto_rewrite}` placeholder all count
33 * as enabled; only false / the literal string "false" disables.
34 * @param {unknown} value
35 * @returns {boolean}
36 */
37export function isAutoRewriteEnabled(value) {
38 return String(value ?? "").trim() !== "false";
39}
40
41/**
42 * steer_enabled toggle, same fail-open semantics as auto_rewrite: only false / the
43 * literal string "false" disables the context-mode steering.
44 * @param {unknown} value
45 * @returns {boolean}
46 */
47export function isSteerEnabled(value) {
48 return String(value ?? "").trim() !== "false";
49}
50// Full MCP tool-name prefix for the plugin-bundled context-mode server. Deny reasons
51// spell it out once so the model can call the tool without guessing the namespace.
52const CTX_TOOL_PREFIX = "mcp__plugin_linux-token-efficiency_context-mode__";
53
54// Heads that make a pipe stage / chain segment steerable: read-only text/gather tools
55// whose raw output floods context and that rtk has no rewrite for. Anything not listed
56// (git, gh, rm, sed, xargs, echo, unknown binaries, …) keeps the command in Bash —
57// conservative by construction: an unknown head means "do not steer".
58const READ_ONLY_HEADS = new Set([
59 "grep",
60 "egrep",
61 "fgrep",
62 "rg",
63 "find",
64 "cat",
65 "head",
66 "tail",
67 "awk",
68 "sort",
69 "uniq",
70 "wc",
71 "cut",
72 "tr",
73 "jq",
74 "ls",
75 "stat",
76 "du",
77 "df",
78 "diff",
79 "comm",
80 "column",
81 "nl",
82 "file",
83 "basename",
84 "dirname",
85 "realpath",
86]);
87
88/**
89 * Split a command into top-level segments (on `&&`, `||`, `;`) and each segment into
90 * pipe stages, honoring single/double quotes and backslash escapes. Returns null —
91 * meaning "too complex, do not steer" — on anything beyond that flat shape: command
92 * substitution, subshells/brace groups, redirects/heredocs, background `&`, comments,
93 * or newlines. Null is always safe: the command simply stays in Bash.
94 * @param {string} command
95 * @returns {{text: string, stages: string[]}[]|null}
96 */
97export function splitTopLevel(command) {
98 /** @type {{text: string, stages: string[]}[]} */
99 const segments = [];
100 /** @type {string[]} */
101 let stages = [];
102 let stage = "";
103 let text = "";
104 let inSingle = false;
105 let inDouble = false;
106 const endStage = () => {
107 const t = stage.trim();
108 if (t === "") return false;
109 stages.push(t);
110 stage = "";
111 return true;
112 };
113 const endSegment = () => {
114 if (!endStage()) return false;
115 const t = text.trim();
116 if (t === "") return false;
117 segments.push({ text: t, stages });
118 stages = [];
119 text = "";
120 return true;
121 };
122 for (let i = 0; i < command.length; i++) {
123 const ch = command[i];
124 if (inSingle) {
125 if (ch === "'") inSingle = false;
126 stage += ch;
127 text += ch;
128 continue;
129 }
130 if (ch === "\\") {
131 const next = command[i + 1] ?? "";
132 stage += ch + next;
133 text += ch + next;
134 i++;
135 continue;
136 }
137 if (inDouble) {
138 if (ch === '"') inDouble = false;
139 else if (ch === "`" || (ch === "$" && command[i + 1] === "(")) return null;
140 stage += ch;
141 text += ch;
142 continue;
143 }
144 if (ch === "'") inSingle = true;
145 else if (ch === '"') inDouble = true;
146 else if (ch === "`" || ch === ">" || ch === "<" || ch === "(" || ch === ")" || ch === "#" || ch === "\n") return null;
147 else if (ch === "$" && command[i + 1] === "(") return null;
148 else if ((ch === "{" || ch === "}") && command[i - 1] !== "$" && !/[{,]/.test(command[i - 1] ?? "")) return null;
149 else if (ch === "&") {
150 if (command[i + 1] !== "&") return null; // background job / fd duplication
151 if (!endSegment()) return null;
152 i++;
153 continue;
154 } else if (ch === ";") {
155 if (!endSegment()) return null;
156 continue;
157 } else if (ch === "|") {
158 if (command[i + 1] === "|") {
159 if (!endSegment()) return null;
160 i++;
161 } else if (!endStage()) {
162 return null;
163 } else {
164 text += ch;
165 }
166 continue;
167 }
168 stage += ch;
169 text += ch;
170 }
171 if (inSingle || inDouble) return null;
172 if (stage.trim() !== "" || stages.length > 0) {
173 if (!endSegment()) return null;
174 }
175 return segments.length > 0 ? segments : null;
176}
177
178/**
179 * The executable word of one pipe stage, skipping leading VAR=VALUE assignments and a
180 * leading `command`. Empty string when none is found.
181 * @param {string} stage
182 * @returns {string}
183 */
184export function commandHead(stage) {
185 for (const token of stage.trim().split(/\s+/)) {
186 if (token === "command") continue;
187 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) continue;
188 return token;
189 }
190 return "";
191}
192
193/**
194 * True when a curl token indicates an upload, an explicit method, or a file
195 * write — those fetches legitimately stay in Bash. Combined short flags (-sSo) are
196 * caught by the character test.
197 * @param {string} token
198 * @returns {boolean}
199 */
200function isFetchWriteFlag(token) {
201 if (/^--(data|form|upload-file|output|remote-name|method|post-data|post-file|json|request)/.test(token)) return true;
202 return /^-[A-Za-z]+$/.test(token) && /[odOFTXD]/.test(token);
203}
204
205/**
206 * Conservative steer classifier. Steers ONLY three shapes, everything else stays:
207 * - fetch: a single bare curl GET of an http(s) URL -> ctx_fetch_and_index (wget
208 * stays: its default is a file download, a side effect the steer cannot replicate)
209 * - batch: >=3 chained segments whose every stage head is a read-only text tool -> ctx_batch_execute
210 * - pipeline: a >=3-stage all-read-only pipe (<=2 segments) -> ctx_execute
211 * @param {string} command
212 * @returns {{kind: "stay"}|{kind: "fetch", url: string}|{kind: "batch", commands: string[]}|{kind: "pipeline", command: string}}
213 */
214export function classifyBashCommand(command) {
215 /** @type {{kind: "stay"}} */
216 const stay = { kind: "stay" };
217 const segments = splitTopLevel(command);
218 if (!segments) return stay;
219 const heads = segments.map((seg) => seg.stages.map(commandHead));
220 if (heads.some((seg) => seg.some((h) => h === ""))) return stay;
221 if (segments.length === 1 && segments[0].stages.length === 1) {
222 // curl only: a bare curl prints the response to stdout (pure context flood), while
223 // wget's default is a file download — a side effect ctx_fetch_and_index cannot
224 // replicate, so wget always stays in Bash.
225 const head = heads[0][0];
226 if (head === "curl") {
227 const tokens = segments[0].stages[0].split(/\s+/).map((t) => t.replace(/^["']|["']$/g, ""));
228 const url = tokens.find((t) => /^https?:\/\//.test(t));
229 if (url !== undefined && !tokens.some(isFetchWriteFlag)) return { kind: "fetch", url };
230 return stay;
231 }
232 }
233 if (!heads.every((seg) => seg.every((h) => READ_ONLY_HEADS.has(h)))) return stay;
234 if (segments.length >= 3) return { kind: "batch", commands: segments.map((s) => s.text) };
235 if (segments.some((s) => s.stages.length >= 3)) return { kind: "pipeline", command: command.trim() };
236 return stay;
237}
238
239/**
240 * The PreToolUse deny result steering a classified command to context-mode, or null
241 * for kind "stay". The reason is a complete, copy-ready replacement call plus an
242 * explicit "do not retry via Bash" so the model cannot loop on the denial.
243 * @param {ReturnType<typeof classifyBashCommand>} classification
244 * @returns {HookResult|null}
245 */
246export function buildSteerDeny(classification) {
247 let reason;
248 if (classification.kind === "fetch") {
249 let source = "web";
250 try {
251 source = new URL(classification.url).hostname || "web";
252 } catch {
253 /* keep the fallback label */
254 }
255 reason =
256 `context-mode steer: do not fetch URLs through Bash and do not retry this command. ` +
257 `Call ctx_fetch_and_index (${CTX_TOOL_PREFIX}ctx_fetch_and_index) with ` +
258 `{"url": ${JSON.stringify(classification.url)}, "source": ${JSON.stringify(source)}}, ` +
259 `then ctx_search(queries: [...]) to read the indexed content — the raw response never enters the context window.`;
260 } else if (classification.kind === "batch") {
261 const commands = classification.commands.map((c, i) => ({ label: `${i + 1}: ${commandHead(c) || "cmd"}`, command: c }));
262 reason =
263 `context-mode steer: this read-only multi-command gather floods the context window via Bash. ` +
264 `Do not retry it as a Bash call. Call ctx_batch_execute (${CTX_TOOL_PREFIX}ctx_batch_execute) with ` +
265 `commands: ${JSON.stringify(commands)} and queries for what you actually need — ` +
266 `output is indexed and only search results enter context.`;
267 } else if (classification.kind === "pipeline") {
268 reason =
269 `context-mode steer: this read-only pipeline floods the context window via Bash. ` +
270 `Do not retry it as a Bash call. Call ctx_execute (${CTX_TOOL_PREFIX}ctx_execute) with ` +
271 `{"language": "shell", "code": ${JSON.stringify(classification.command)}} — only stdout you print enters context.`;
272 } else {
273 return null;
274 }
275 reason += " (Set the linux-token-efficiency plugin option steer_enabled to false to disable this routing.)";
276 return {
277 hookSpecificOutput: {
278 hookEventName: "PreToolUse",
279 permissionDecision: "deny",
280 permissionDecisionReason: reason,
281 },
282 };
283}
284
285/**
286 * True when a rtk-rewritten command still carries a bare `git` token among its
287 * operands (e.g. `rtk git status`) -- the exact shape the harness's own
288 * worktree-isolation guard refuses outright in a linked worktree, because it cannot
289 * verify cwd/root through the opaque rtk wrapper the way it can for a direct
290 * `git ...` invocation. Whitespace-token match only, matching this file's other
291 * simple lexing (commandHead) rather than a full shell parse.
292 * @param {string} command
293 * @returns {boolean}
294 */
295export function hasGitOperand(command) {
296 return command.split(/\s+/).includes("git");
297}
298
299/**
300 * The git dir and the common dir from one `git rev-parse --git-dir --git-common-dir`
301 * run in `cwd`, each made absolute against `cwd` (git prints a relative `.git` from
302 * a worktree root). Null unless git exited 0 with exactly those two lines. register.ts
303 * resolves both through the file system: a linked worktree is a checkout whose
304 * resolved git dir differs from its resolved common dir — the same `-ef` comparison
305 * coding-toolbox's fresh-pr/finish-pr git-context blocks use in shell form.
306 * @param {number} exitCode
307 * @param {string} stdout
308 * @param {string} cwd absolute
309 * @returns {[string, string]|null}
310 */
311export function gitDirs(exitCode, stdout, cwd) {
312 if (exitCode !== 0) return null;
313 const lines = stdout.trim().split("\n");
314 if (lines.length !== 2 || lines.some((l) => l.trim() === "")) return null;
315 const abs = (/** @type {string} */ p) => (p.startsWith("/") ? p : `${cwd.replace(/\/+$/, "")}/${p}`);
316 return [abs(lines[0].trim()), abs(lines[1].trim())];
317}
318
319/**
320 * A string is usable as a home directory only when non-empty and free of an
321 * uninterpolated `${` placeholder.
322 * @param {string|undefined} value
323 * @returns {boolean}
324 */
325function usableHome(value) {
326 return typeof value === "string" && value.trim() !== "" && !value.includes("${");
327}
328
329/**
330 * The plugin-managed rtk install path (${HOME}/.local/bin/rtk), or null when HOME is
331 * unusable (unset, blank, or a `${`-bearing placeholder). Never returns a `${`-bearing
332 * path. shortcut: no os.homedir() passwd fallback (a mods module has no such API), so
333 * an unset HOME means no rewrite; upgrade if a host without HOME ever matters.
334 * @param {{HOME?: string}} env
335 * @returns {string|null}
336 */
337export function resolveManagedRtk(env) {
338 if (!usableHome(env.HOME)) return null;
339 return `${String(env.HOME).trim()}/.local/bin/rtk`;
340}
341
342/**
343 * Where a bare `rtk` can resolve in the Bash tool, in lookup order: `<dir>/rtk` for
344 * every non-empty PATH entry, then this plugin's own `bin/rtk` PATH bridge — Claude
345 * Code appends each enabled plugin's `bin/` to the Bash PATH, not to its own.
346 * @param {string|undefined} pathEnv
347 * @param {string} pluginBin absolute `<plugin root>/bin`
348 * @returns {string[]}
349 */
350export function rtkCandidates(pathEnv, pluginBin) {
351 return [...(pathEnv || "").split(":").filter((d) => d !== ""), pluginBin].map((d) => `${d.replace(/\/+$/, "")}/rtk`);
352}
353
354/**
355 * rtk's rewritten command from `rtk hook claude` stdout, or null when there is none to
356 * apply: empty output (no rewrite for this command), unparseable output, any shape
357 * other than a plain `hookSpecificOutput.updatedInput.command` string (e.g. rtk's own
358 * permissionDecision — never forwarded), or a command identical to the original.
359 * @param {string} stdout
360 * @param {string} original
361 * @returns {string|null}
362 */
363export function parseRtkRewrite(stdout, original) {
364 const out = stdout.trim();
365 if (out === "") return null;
366 /** @type {any} */
367 let parsed;
368 try {
369 parsed = JSON.parse(out);
370 } catch {
371 return null;
372 }
373 const rewritten = parsed?.hookSpecificOutput?.updatedInput?.command;
374 if (typeof rewritten !== "string" || rewritten === original) return null;
375 return rewritten;
376}
377
378/**
379 * The deny steering one WebFetch URL to ctx_fetch_and_index. The reason is a complete
380 * replacement call plus an explicit "do not retry WebFetch" so the model cannot loop on
381 * the denial. Every URL steers: WebFetch has no comparable non-context-mode equivalent.
382 * @param {string} url
383 * @returns {HookResult}
384 */
385export function buildWebFetchDeny(url) {
386 let source = "web";
387 try {
388 source = new URL(url).hostname || "web";
389 } catch {
390 /* keep the fallback label */
391 }
392 const reason =
393 `context-mode steer: WebFetch is routed through the context-mode MCP server. ` +
394 `Do not retry WebFetch for this URL. Call ctx_fetch_and_index (${CTX_TOOL_PREFIX}ctx_fetch_and_index) with ` +
395 `{"url": ${JSON.stringify(url)}, "source": ${JSON.stringify(source)}}, then ctx_search(queries: [...]) ` +
396 `to read the indexed content — the raw page never enters the context window. ` +
397 `(Set the linux-token-efficiency plugin option steer_enabled to false to disable this routing.)`;
398 return {
399 hookSpecificOutput: {
400 hookEventName: "PreToolUse",
401 permissionDecision: "deny",
402 permissionDecisionReason: reason,
403 },
404 };
405}
406