cheap-agent 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { providerLabel } from "./credentials.js";
4
+ import { failBlock, renderConsent } from "./report.js";
5
+ import { loadConfig } from "./config.js";
6
+ import { fullRoute, modelName, presetOf, routeModels } from "./routing.js";
7
+ import { projectRoot } from "./workspace.js";
8
+ const file = (root) => join(root, ".cheapag", "consent.json");
9
+ /** The repo's consent, or undefined when there is none (a damaged record counts as none; `cheapag allow` rewrites it). */
10
+ export function readConsent(root) {
11
+ try {
12
+ if (!existsSync(file(root)))
13
+ return undefined;
14
+ const c = JSON.parse(readFileSync(file(root), "utf8"));
15
+ const valid = ["all", "zdr-only", "models"].includes(c.mode) && typeof c.provider === "string" &&
16
+ (c.mode !== "models" || (Array.isArray(c.models) && c.models.every((m) => typeof m === "string")));
17
+ return valid ? c : undefined;
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ }
23
+ export function writeConsent(root, consent) {
24
+ mkdirSync(join(root, ".cheapag"), { recursive: true });
25
+ // an explicit model list bounds the recipients by itself; the other modes are tied to the route they were shown
26
+ const route = consent.mode === "models" ? undefined : routeNames(fullRoute(consent.provider, configuredModels(root)));
27
+ writeFileSync(file(root), JSON.stringify({ ...consent, route }, null, 2) + "\n");
28
+ }
29
+ const routeNames = (r) => routeModels(r).map(modelName);
30
+ /** The models config now routes to other models than the consent was shown (not judged on a damaged config, which the job's own load reports; a record from before routes were kept has none). */
31
+ export function routeMoved(root, consent) {
32
+ const models = configuredModels(root);
33
+ return !!models && !!consent.route && String(consent.route) !== String(routeNames(fullRoute(consent.provider, models)));
34
+ }
35
+ /** `preset=` for ping and status: the preset the effective `models` here match (the repo's config too, inside one). */
36
+ export function presetHere(provider) {
37
+ let root = process.cwd();
38
+ try {
39
+ root = projectRoot(root);
40
+ }
41
+ catch { } // outside a repo only the user's config applies
42
+ return presetOf(provider, configuredModels(root));
43
+ }
44
+ // OpenRouter's provider-routing preference that restricts it to Zero Data Retention endpoints (openrouter.ai/docs, provider routing)
45
+ const ZDR_OPTION = { zdr: true };
46
+ /** What is known about a model's retention: only the spec's facts (A21), never guessed. */
47
+ function retention(provider, m) {
48
+ if (provider === "openrouter")
49
+ return "upstream"; // depends on the endpoint OpenRouter routes to
50
+ // A21: on OpenCode Go, DeepSeek and Qwen models are zero-retention; GLM and Kimi are not listed that way
51
+ if (provider === "opencode-go" && /^(deepseek|qwen)/.test(m.id))
52
+ return "zero";
53
+ return "not stated";
54
+ }
55
+ /** Whether a model may run under the consent; on OpenRouter, zero-retention-only is enforced by its zdr routing instead. */
56
+ function allowed(provider, consent, m) {
57
+ if (consent.mode === "all")
58
+ return true;
59
+ if (consent.mode === "models")
60
+ return consent.models.includes(modelName(m));
61
+ return provider === "openrouter" || retention(provider, m) === "zero";
62
+ }
63
+ /** With zero-retention-only on OpenRouter, every request also asks for ZDR endpoints. */
64
+ function withZdr(m) {
65
+ const options = m.options;
66
+ return { ...m, options: { ...options, provider: { ...options?.provider, ...ZDR_OPTION } } };
67
+ }
68
+ /**
69
+ * The repo's consent gate, for delegate and research. Call it before anything is created or booted:
70
+ * - `block`: the CONSENT block (not approved for this provider) or a FAIL (approved, but no allowed model);
71
+ * - `route`: the allowlisted models the job may use, every role and fallback filtered. A disallowed fallback or escalation
72
+ * model is dropped, not a failure; with no implement model or fallback allowed, an allowed escalation model implements.
73
+ */
74
+ export function consentGate(root, provider) {
75
+ const consent = readConsent(root);
76
+ const full = fullRoute(provider, configuredModels(root));
77
+ // consent names the provider it was given for: a new login elsewhere needs a new opt-in; so does another route
78
+ if (!consent || consent.provider !== provider || routeMoved(root, consent))
79
+ return { block: consentBlock(root, provider, full) };
80
+ const zdr = consent.mode === "zdr-only" && provider === "openrouter";
81
+ const pick = (ms) => ms.filter((m) => !!m && allowed(provider, consent, m)).map((m) => (zdr ? withZdr(m) : m));
82
+ const [implement, ...fallbacks] = pick([full.implement, ...full.fallbacks, full.escalation]);
83
+ if (!implement)
84
+ return { block: failBlock(`no allowed model for ${providerLabel(provider)} under this repo's consent (${describe(consent)}); ask the user to widen it with /cheapag:allow`) };
85
+ const [escalation] = pick([full.escalation]);
86
+ const others = (m) => modelName(m) !== modelName(implement) && modelName(m) !== modelName(escalation ?? implement);
87
+ // research with nothing of its own allowed runs on the implement chain
88
+ const [research, ...researchFallbacks] = pick([full.research, ...full.researchFallbacks]);
89
+ return {
90
+ route: {
91
+ implement,
92
+ // the escalation model never doubles as a fallback; it keeps its own place (and cap check) in the job
93
+ fallbacks: fallbacks.filter(others),
94
+ escalation: escalation && modelName(escalation) !== modelName(implement) ? escalation : undefined,
95
+ research: research ?? implement,
96
+ researchFallbacks: research ? researchFallbacks : fallbacks.filter(others),
97
+ // a disallowed tier model is dropped: that tier then runs on the implement model
98
+ medium: pick([full.medium])[0],
99
+ hard: pick([full.hard])[0],
100
+ },
101
+ };
102
+ }
103
+ /** The config's `models` (E4); a damaged config counts as none here, the job's own config load reports it. */
104
+ function configuredModels(root) {
105
+ try {
106
+ return loadConfig(root).models;
107
+ }
108
+ catch {
109
+ return undefined;
110
+ }
111
+ }
112
+ export const describe = (c) => (c.mode === "all" ? "all models" : c.mode === "zdr-only" ? "zero-retention only" : `models ${c.models.join(", ")}`);
113
+ /** Spec D5's CONSENT block: who would receive the code, their known retention, and how the user approves. */
114
+ function consentBlock(root, provider, full) {
115
+ const models = routeModels(full);
116
+ // D5: the role models, then the fallbacks
117
+ const main = routeModels({ ...full, fallbacks: [], researchFallbacks: [] }).map((m) => m.id);
118
+ const extra = models.map((m) => m.id).filter((id) => !main.includes(id));
119
+ const ids = main.join(", ") + (extra.length ? ` (+fallbacks ${extra.join(", ")})` : "");
120
+ const retentionLine = provider === "openrouter"
121
+ ? `depends on the upstream provider OpenRouter routes to; "deny data collection" is always on; --zdr-only adds OpenRouter's zero-data-retention routing (zdr)`
122
+ : models.map((m) => `${m.id} = ${retention(provider, m) === "zero" ? "zero retention" : "not stated"}`).join("; ");
123
+ return renderConsent({
124
+ repo: basename(root),
125
+ send: `code from this repo → ${providerLabel(provider)}: ${ids}`,
126
+ retention: retentionLine,
127
+ approve: "ask the user to run /cheapag:allow (all) or /cheapag:allow --zdr-only (terminal: npx cheap-agent allow [--zdr-only | --models a,b]); never approve yourself",
128
+ });
129
+ }
@@ -0,0 +1,37 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ /** Credentials come only from saved logins in OpenCode's store, written by `cheapag login` (ADR-0004). */
5
+ /** Providers in detection order; `label` is what `cheapag login` shows. */
6
+ export const PROVIDERS = [
7
+ { id: "opencode-go", label: "OpenCode Go" },
8
+ { id: "openrouter", label: "OpenRouter" },
9
+ { id: "deepseek", label: "DeepSeek" },
10
+ ];
11
+ export const LOGIN_COMMAND = "npx cheap-agent login";
12
+ export const NO_PROVIDER = `FAIL reason: no worker provider; run ${LOGIN_COMMAND}`;
13
+ /** OpenCode keeps logins for every installed copy in one shared file. */
14
+ function authFile() {
15
+ return join(process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "opencode", "auth.json");
16
+ }
17
+ /** Supported providers with a saved login, in detection order. Only the provider ids are used; keys are never kept or passed on. */
18
+ export function savedProviders() {
19
+ let saved;
20
+ try {
21
+ saved = existsSync(authFile()) ? JSON.parse(readFileSync(authFile(), "utf8")) : {};
22
+ }
23
+ catch {
24
+ return []; // an unreadable auth file is the same as no login
25
+ }
26
+ return PROVIDERS.map((p) => p.id).filter((id) => id in saved);
27
+ }
28
+ export const providerLabel = (id) => PROVIDERS.find((p) => p.id === id).label;
29
+ /** The provider the worker uses: the first saved login. */
30
+ export function detectProvider() {
31
+ const [first] = savedProviders();
32
+ return first && { provider: first, via: "login" };
33
+ }
34
+ /** Strips key-shaped strings (OpenRouter's sk-or-v1-…, DeepSeek/OpenAI-style sk-…) from anything that leaves the process. */
35
+ export function redact(text) {
36
+ return text.replace(/\bsk-[A-Za-z0-9_-]{16,}/g, "[redacted]");
37
+ }
package/dist/hook.js ADDED
@@ -0,0 +1,263 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join, resolve, sep } from "node:path";
3
+ import { loadConfig } from "./config.js";
4
+ import { projectRoot } from "./workspace.js";
5
+ const USAGE = "usage: cheapag hook [on | off]";
6
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "Bash"]);
7
+ // Bash forms that write a file: redirects and heredocs into a path, in-place sed/perl, tee, cp/mv targets, and
8
+ // python/node one-liners that open files for writing. The benchmark showed Claude writing whole files this way.
9
+ const BASH_WRITES = [
10
+ /(?:^|[^<>])>{1,2}\s*(?!&)["']?(?<path>[^\s"'|;&]+)/g,
11
+ /\btee\s+(?:-a\s+)?["']?(?<path>[^\s"'|;&]+)/g,
12
+ /\b(?:cp|mv)\s+(?:-\S+\s+)*\S+\s+["']?(?<path>[^\s"'|;&]+)/g,
13
+ ];
14
+ // in-place edits name their files after the script, so they are denied as a whole (plan files are never edited this way)
15
+ const BASH_INPLACE = /\b(?:sed|perl)\s+(?:-[a-zA-Z]*i|--in-place)\b/;
16
+ const BASH_CODE_WRITES = /\b(?:python3?|node)\s+-[ce]\b[\s\S]*(?:open\([^)]*["'][wa]|writeFile|writeFileSync|\.write_text)/;
17
+ // the plugin's server and a `cheapag login` registration give the tool different prefixes, so name both
18
+ const REDIRECT = "write a plan step and use the cheapag server's delegate tool (mcp__plugin_cheapag_cheapag__delegate, or mcp__cheapag__delegate) instead.";
19
+ const CHEAPAG_TOOL = /^mcp__(?:plugin_cheapag_)?cheapag__/;
20
+ // a job block whose first word means the worker did not finish the step (the server's FAIL/PARTIAL/TIMEOUT)
21
+ const NOT_DONE = /^(?:FAIL|PARTIAL|TIMEOUT) j\w+ /m;
22
+ // an OK whose checks passed in the worker's worktree (ticket 30: re-running them caught nothing)
23
+ const OK_PASSED = /^OK j\w+ step=\S+ .*\btests=pass\b/m;
24
+ // shortcut: the usual runners only; add the repo's test_cmd if Claude re-runs tests through a custom script
25
+ const TEST_CMD = /swebench-test\.sh|\bpytest\b|runtests\.py|bin\/test\b|\b(?:npm|pnpm|yarn|bun) (?:run )?test\b|node --test|\btox\b|\bvitest\b|\bjest\b|\bgo test\b|\bcargo test\b|\b(?:mvn|gradle|gradlew) test\b|\brspec\b|\bphpunit\b|-m unittest\b|\bmake test\b/;
26
+ /** Presence of this file under `.cheapag/` turns the guard on for the repo; it ships off (spec A6). */
27
+ const flag = (root) => join(root, ".cheapag", "hook.json");
28
+ /**
29
+ * `cheapag hook [on|off]`: the user-only `/cheapag:hook` toggles the edit guard for the current repo; no argument
30
+ * shows its state. `cheapag hook guard` is the plugin's PreToolUse hook (spec E6); `cheapag hook result` its PostToolUse
31
+ * hook on cheapag's own tools, which starts the take-over budget (ticket 29).
32
+ */
33
+ export function hook(args) {
34
+ if (args.length === 1 && args[0] === "guard")
35
+ return guard();
36
+ if (args.length === 1 && args[0] === "context")
37
+ return context();
38
+ if (args.length === 1 && args[0] === "result")
39
+ return result();
40
+ const out = (line) => process.stdout.write(line + "\n");
41
+ if (args.length > 1 || (args.length && args[0] !== "on" && args[0] !== "off")) {
42
+ out(`✗ ${USAGE}`);
43
+ return 1;
44
+ }
45
+ let root;
46
+ try {
47
+ root = projectRoot(process.cwd());
48
+ }
49
+ catch (err) {
50
+ out(`✗ ${err.message}`);
51
+ return 1;
52
+ }
53
+ if (args[0] === "on") {
54
+ mkdirSync(join(root, ".cheapag"), { recursive: true });
55
+ writeFileSync(flag(root), JSON.stringify({ on: true }) + "\n");
56
+ }
57
+ else if (args[0] === "off")
58
+ rmSync(flag(root), { force: true });
59
+ let lines;
60
+ try {
61
+ lines = loadConfig(root).guard_lines;
62
+ }
63
+ catch (err) {
64
+ lines = `? (${err.message})`;
65
+ }
66
+ out(existsSync(flag(root))
67
+ ? `✓ edit guard on for ${root}: an edit over ${lines} changed lines, a 3rd edit to one file in a turn, or a Bash command writing a file is denied with a pointer to delegate (.plan/ is exempt). CHEAPAG_ALLOW_EDITS=1 bypasses it.`
68
+ : `✓ edit guard off for ${root}: Claude's edits are not limited.`);
69
+ return 0;
70
+ }
71
+ /**
72
+ * The plugin's SessionStart hook: one line pointing at delegate. The rules live in the server instructions only, since
73
+ * this context lands after Claude Code's cache breakpoint and is rewritten every session (ticket 24.2).
74
+ */
75
+ export const SESSION_CONTEXT = `Cheap Agent is connected: send every code change to its delegate tool (task: "<what to change, which files, how to check>"); its server instructions have the rules.`;
76
+ function context() {
77
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: SESSION_CONTEXT } }) + "\n");
78
+ return 0;
79
+ }
80
+ /**
81
+ * Reads Claude Code's PreToolUse JSON on stdin; prints a deny decision or nothing (no decision: the normal permission
82
+ * flow applies, so the guard never approves anything). Any error allows: a broken guard must never block editing.
83
+ */
84
+ function guard() {
85
+ try {
86
+ const input = JSON.parse(readFileSync(0, "utf8"));
87
+ const reason = budget(input) ?? check(input);
88
+ if (reason)
89
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } }) + "\n");
90
+ }
91
+ catch {
92
+ // allow
93
+ }
94
+ return 0;
95
+ }
96
+ /** Per-session state; the repo-wide marker lets the plugin's shell test skip node until a cheapag result was ever seen. */
97
+ const stateFile = (root, session) => join(root, ".cheapag", "hook", `${session.replace(/[^\w-]/g, "_")}.takeover.json`);
98
+ const marker = (root) => join(root, ".cheapag", "hook", "takeover");
99
+ /**
100
+ * The prompt rules (tickets 29, 30), for the main loop only:
101
+ * - after a FAIL, PARTIAL or TIMEOUT, takeover "sonnet" leaves only the Agent tool (to hand the step to a Sonnet subagent)
102
+ * and cheapag's tools; "self" denies the call after limits.takeover_calls (resolved take-overs used at most 13).
103
+ * - after an OK whose checks passed, re-running tests is denied: unverified OKs were as correct as verified ones.
104
+ * A new user prompt clears both. cheapag's own tools and a subagent's calls are never counted or denied.
105
+ */
106
+ function budget(input) {
107
+ if (!input.session_id || !input.prompt_id || input.agent_id || typeof input.cwd !== "string" || CHEAPAG_TOOL.test(input.tool_name ?? ""))
108
+ return;
109
+ const root = projectRoot(input.cwd);
110
+ const file = stateFile(root, input.session_id);
111
+ let state;
112
+ try {
113
+ state = JSON.parse(readFileSync(file, "utf8"));
114
+ }
115
+ catch {
116
+ return; // no cheapag result in this session yet
117
+ }
118
+ if (state.prompt !== input.prompt_id)
119
+ return; // a new user prompt starts fresh
120
+ const config = loadConfig(root);
121
+ if (state.notDone && config.takeover === "sonnet") {
122
+ if (input.tool_name === "Agent" || input.tool_name === "Task")
123
+ return;
124
+ return `The worker did not finish this step: hand it to your Agent tool with model: sonnet (the task, the user's words, the block's found:, test=, error: and worktree) instead of doing it yourself, then report its answer. The user can set takeover: "self" in cheapag.json.`;
125
+ }
126
+ if (state.notDone) {
127
+ const limit = config.limits.takeover_calls;
128
+ if (!limit)
129
+ return;
130
+ if (state.calls >= limit)
131
+ return `Take-over budget spent (${limit} tool calls after a cheapag FAIL or PARTIAL): stop now and tell the user what is fixed, what is left and the failing test. The user can raise limits.takeover_calls in cheapag.json.`;
132
+ writeFileSync(file, JSON.stringify({ ...state, calls: state.calls + 1 }));
133
+ return;
134
+ }
135
+ const command = input.tool_input?.command;
136
+ if (state.okPassed && input.tool_name === "Bash" && typeof command === "string" && TEST_CMD.test(command)) {
137
+ return "An OK is done: its checks already passed in the worker's worktree, so don't re-run tests. Report the result, or delegate the next step.";
138
+ }
139
+ }
140
+ /** Reads Claude Code's PostToolUse JSON for a cheapag tool and records what its result means for this prompt. Never blocks. */
141
+ function result() {
142
+ try {
143
+ const input = JSON.parse(readFileSync(0, "utf8"));
144
+ if (!input.session_id || !input.prompt_id || input.agent_id || typeof input.cwd !== "string")
145
+ return 0;
146
+ const out = text(input.tool_response);
147
+ const notDone = NOT_DONE.test(out), okPassed = OK_PASSED.test(out);
148
+ if (!notDone && !okPassed)
149
+ return 0;
150
+ const root = projectRoot(input.cwd);
151
+ const file = stateFile(root, input.session_id);
152
+ let state = { prompt: input.prompt_id, calls: 0 };
153
+ try {
154
+ const saved = JSON.parse(readFileSync(file, "utf8"));
155
+ if (saved.prompt === input.prompt_id)
156
+ state = saved; // a second failure in the same prompt keeps counting from the first
157
+ }
158
+ catch {
159
+ // first cheapag result of the session
160
+ }
161
+ mkdirSync(join(root, ".cheapag", "hook"), { recursive: true });
162
+ writeFileSync(file, JSON.stringify({ ...state, ...(notDone && { notDone }), ...(okPassed && { okPassed }) }));
163
+ // shortcut: the marker is never removed, so a repo that once had a cheapag result runs node on every tool call (~50 ms); drop it in `cheapag clean` if that shows
164
+ writeFileSync(marker(root), "");
165
+ }
166
+ catch {
167
+ // never block
168
+ }
169
+ return 0;
170
+ }
171
+ /** The text of a tool response: a string, MCP content blocks, or an object holding them. */
172
+ function text(v) {
173
+ if (typeof v === "string")
174
+ return v;
175
+ if (Array.isArray(v))
176
+ return v.map(text).join("\n");
177
+ if (v && typeof v === "object") {
178
+ const o = v;
179
+ return typeof o.text === "string" ? o.text : text(o.content ?? "");
180
+ }
181
+ return "";
182
+ }
183
+ function check(input) {
184
+ if (process.env.CHEAPAG_ALLOW_EDITS === "1" || !EDIT_TOOLS.has(input.tool_name ?? ""))
185
+ return;
186
+ const tool = input.tool_input ?? {};
187
+ if (typeof input.cwd !== "string")
188
+ return;
189
+ if (input.tool_name === "Bash")
190
+ return typeof tool.command === "string" ? bashCheck(input.cwd, tool.command) : undefined;
191
+ if (typeof tool.file_path !== "string")
192
+ return;
193
+ const root = projectRoot(input.cwd);
194
+ if (!existsSync(flag(root)))
195
+ return;
196
+ const file = resolve(input.cwd, tool.file_path);
197
+ if (isPlan(root, file))
198
+ return; // Claude must be able to write plans of any size
199
+ const limit = loadConfig(root).guard_lines;
200
+ const lines = changed(input.tool_name, tool, file);
201
+ if (lines > limit)
202
+ return `Large edit (${lines} changed lines, over ${limit}): ${REDIRECT}`;
203
+ // A turn is one user prompt: Claude Code's prompt_id. Without it (older Claude Code) only the size rule applies.
204
+ if (!input.prompt_id || !input.session_id)
205
+ return;
206
+ // shortcut: one small file per session, never pruned and not locked against parallel edits; prune in `cheapag clean` if they pile up
207
+ const state = join(root, ".cheapag", "hook", `${input.session_id.replace(/[^\w-]/g, "_")}.json`);
208
+ let turn = { prompt: input.prompt_id, edits: {} };
209
+ try {
210
+ const saved = JSON.parse(readFileSync(state, "utf8"));
211
+ if (saved.prompt === input.prompt_id)
212
+ turn = saved;
213
+ }
214
+ catch {
215
+ // first edit of the session
216
+ }
217
+ if ((turn.edits[file] ?? 0) >= 2)
218
+ return `3rd edit to ${tool.file_path} this turn: ${REDIRECT}`;
219
+ turn.edits[file] = (turn.edits[file] ?? 0) + 1;
220
+ mkdirSync(join(root, ".cheapag", "hook"), { recursive: true });
221
+ writeFileSync(state, JSON.stringify(turn));
222
+ }
223
+ /** Plans live in .plan/ and are how Claude delegates, so the guard never blocks them. */
224
+ const isPlan = (root, file) => file.startsWith(join(root, ".plan") + sep);
225
+ /** A Bash command is denied when it writes a file inside the repo (any size: the lines can't be counted), unless the file is a plan. */
226
+ function bashCheck(cwd, command) {
227
+ const root = projectRoot(cwd);
228
+ if (!existsSync(flag(root)))
229
+ return;
230
+ const targets = [];
231
+ for (const re of BASH_WRITES)
232
+ for (const m of command.matchAll(re))
233
+ if (m.groups?.path)
234
+ targets.push(m.groups.path);
235
+ const inRepo = (p) => { const abs = resolve(cwd, p); return abs.startsWith(root + sep) && !isPlan(root, abs); };
236
+ const written = targets.filter((p) => p !== "/dev/null" && inRepo(p));
237
+ if (written.length)
238
+ return `Bash writing ${written[0]}: ${REDIRECT}`;
239
+ if (BASH_INPLACE.test(command))
240
+ return `Bash in-place edit (sed/perl -i): ${REDIRECT}`;
241
+ if (BASH_CODE_WRITES.test(command))
242
+ return `Bash running code that writes files: ${REDIRECT}`;
243
+ }
244
+ /** Changed lines of an edit: the larger side once the lines both sides share at the start and end are dropped. */
245
+ function changed(name, tool, file) {
246
+ const text = (v) => (typeof v === "string" ? v : "");
247
+ // shortcut: replace_all counts one occurrence; count matches in the file if guarded edits slip through that way
248
+ if (name === "Edit")
249
+ return diff(text(tool.old_string), text(tool.new_string));
250
+ if (name === "MultiEdit")
251
+ return (Array.isArray(tool.edits) ? tool.edits : []).reduce((n, e) => n + diff(text(e?.old_string), text(e?.new_string)), 0);
252
+ return diff(existsSync(file) ? readFileSync(file, "utf8") : "", text(tool.content));
253
+ }
254
+ function diff(before, after) {
255
+ const a = before.split("\n"), b = after.split("\n");
256
+ let start = 0;
257
+ while (start < a.length && start < b.length && a[start] === b[start])
258
+ start++;
259
+ let end = 0;
260
+ while (end < a.length - start && end < b.length - start && a[a.length - 1 - end] === b[b.length - 1 - end])
261
+ end++;
262
+ return Math.max(a.length - start - end, b.length - start - end);
263
+ }
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ // `cheapag login` (or no arguments in a terminal) sets up a provider and Claude Code, `cheapag allow` records the repo's consent, `cheapag clean` tidies .cheapag/, `cheapag status` reports jobs and spend, `cheapag hook` toggles or runs the edit guard; anything else (including no arguments) runs the MCP server
3
+ // `npx cheap-agent` typed in a terminal is setup (ticket 31); Claude Code starts the server with no arguments and a piped stdin
4
+ if (process.argv[2] === "login" || (process.argv[2] === undefined && process.stdin.isTTY))
5
+ process.exit(await (await import("./login.js")).login(process.argv.slice(3)));
6
+ else if (process.argv[2] === "allow")
7
+ process.exit((await import("./allow.js")).allow(process.argv.slice(3)));
8
+ else if (process.argv[2] === "clean")
9
+ process.exit((await import("./cleanup.js")).clean(process.argv.slice(3)));
10
+ else if (process.argv[2] === "status")
11
+ process.exit((await import("./status.js")).status(process.argv.slice(3)));
12
+ else if (process.argv[2] === "hook")
13
+ process.exit((await import("./hook.js")).hook(process.argv.slice(3)));
14
+ else
15
+ await (await import("./server.js")).serve();
16
+ export {};
@@ -0,0 +1,119 @@
1
+ import { cpSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { delimiter, dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { userConfigFile } from "./config.js";
6
+ import { normalisePath, runCli, which } from "./platform.js";
7
+ /**
8
+ * The Claude Code plugin (hooks and slash commands), installed by setup (ticket 31). It is generated from this package's
9
+ * own plugin/ so it always matches the installed version, needs no public marketplace, and leaves out .mcp.json (setup
10
+ * registers the server at user scope). Its hooks run on every tool call once a job has run, so they call a launcher that
11
+ * starts this install with node (~0.1 s) instead of npx (~0.4 s), falling back to npx if this install is gone.
12
+ */
13
+ const { version } = createRequire(import.meta.url)("../package.json");
14
+ const PLUGIN_SRC = fileURLToPath(new URL("../plugin", import.meta.url));
15
+ const NPX = `npx -y cheap-agent@${version}`;
16
+ export const MARKETPLACE = "cheapag";
17
+ /** Where setup keeps the launcher and the generated marketplace: next to the user config. */
18
+ export const installPaths = () => {
19
+ const home = dirname(userConfigFile());
20
+ return { launcher: join(home, "bin", "cheapag.cjs"), marketplace: join(home, "marketplace") };
21
+ };
22
+ /**
23
+ * The launcher every hook and command runs as `node <launcher> <args>`. Windows has no /bin/sh, so one CommonJS file
24
+ * runs on every platform: it gates the guard hook (exit 0 unless the repo opted in), then runs this install when it is
25
+ * still there, else the pinned npx.
26
+ */
27
+ const launcherScript = (entry) => `// cheapag: gates the guard hook, runs this install, else the fallback npx -y cheap-agent@${version}
28
+ "use strict";
29
+ const { execFileSync, spawnSync } = require("node:child_process");
30
+ const { existsSync } = require("node:fs");
31
+ const { join } = require("node:path");
32
+ const { pathToFileURL } = require("node:url");
33
+ const entry = ${JSON.stringify(entry)};
34
+ if (process.argv[2] === "hook" && process.argv[3] === "guard") {
35
+ let root;
36
+ try {
37
+ root = execFileSync("git", ["rev-parse", "--show-toplevel"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
38
+ } catch {
39
+ process.exit(0);
40
+ }
41
+ if (!existsSync(join(root, ".cheapag", "hook.json")) && !existsSync(join(root, ".cheapag", "hook", "takeover"))) process.exit(0);
42
+ }
43
+ if (existsSync(entry)) {
44
+ process.argv[1] = entry;
45
+ import(pathToFileURL(entry).href);
46
+ } else {
47
+ const r = spawnSync("npx", ["-y", "cheap-agent@${version}", ...process.argv.slice(2)], { stdio: "inherit", shell: process.platform === "win32" });
48
+ process.exit(r.status ?? 1);
49
+ }
50
+ `;
51
+ /** Writes the launcher and the plugin's marketplace; returns the marketplace directory. */
52
+ export function writePlugin(entry, _node, platform = process.platform) {
53
+ const { launcher, marketplace } = installPaths();
54
+ mkdirSync(dirname(launcher), { recursive: true });
55
+ writeFileSync(launcher, launcherScript(entry));
56
+ // Windows commands run in Git Bash or PowerShell, so they take double quotes and forward slashes; POSIX keeps single quotes
57
+ const ref = platform === "win32" ? `node "${normalisePath(launcher)}"` : `node '${launcher}'`;
58
+ rmSync(marketplace, { recursive: true, force: true });
59
+ const plugin = join(marketplace, "cheapag");
60
+ cpSync(PLUGIN_SRC, plugin, { recursive: true });
61
+ rmSync(join(plugin, ".mcp.json"), { force: true });
62
+ const skills = readdirSync(join(plugin, "skills")).map((s) => join(plugin, "skills", s, "SKILL.md"));
63
+ for (const f of skills)
64
+ writeFileSync(f, readFileSync(f, "utf8").replaceAll(NPX, ref));
65
+ const hooksFile = join(plugin, "hooks", "hooks.json");
66
+ if (platform === "win32") {
67
+ // rewrite the commands as JSON: a raw replace would put unescaped double quotes inside the strings; no sh prefix, since the launcher gates and exits 0
68
+ const json = JSON.parse(readFileSync(hooksFile, "utf8"));
69
+ const command = (action) => `${ref} hook ${action}`;
70
+ json.hooks.PreToolUse[0].hooks[0].command = command("guard");
71
+ json.hooks.PostToolUse[0].hooks[0].command = command("result");
72
+ json.hooks.SessionStart[0].hooks[0].command = command("context");
73
+ writeFileSync(hooksFile, JSON.stringify(json, null, 2) + "\n");
74
+ }
75
+ else {
76
+ writeFileSync(hooksFile, readFileSync(hooksFile, "utf8").replaceAll(NPX, ref));
77
+ }
78
+ mkdirSync(join(marketplace, ".claude-plugin"), { recursive: true });
79
+ writeFileSync(join(marketplace, ".claude-plugin", "marketplace.json"), JSON.stringify({
80
+ name: MARKETPLACE, owner: { name: "muaaz9329" }, description: "Cheap Agent, generated by its setup from the installed package",
81
+ plugins: [{ name: "cheapag", source: "./cheapag", description: "Claude plans, a cheap model writes the code" }],
82
+ }, null, 2) + "\n");
83
+ return marketplace;
84
+ }
85
+ /** Generates the plugin and adds it to Claude Code (or updates it); prints the manual commands when a step fails. */
86
+ export function installPlugin(out) {
87
+ const marketplace = writePlugin(realpathSync(process.argv[1]), stableNode());
88
+ const claude = (...args) => runCli("claude", ["plugin", ...args], { encoding: "utf8" });
89
+ const listed = (args, name) => (claude(...args)?.stdout ?? "").includes(name);
90
+ const steps = [
91
+ listed(["marketplace", "list"], MARKETPLACE) ? ["marketplace", "update", MARKETPLACE] : ["marketplace", "add", marketplace],
92
+ listed(["list"], `cheapag@${MARKETPLACE}`) ? ["update", `cheapag@${MARKETPLACE}`] : ["install", `cheapag@${MARKETPLACE}`],
93
+ ];
94
+ for (const step of steps) {
95
+ const r = claude(...step);
96
+ if (!r || r.status !== 0) {
97
+ out(`✗ claude plugin ${step.join(" ")} failed (${((r?.stderr || r?.stdout) ?? "").trim().split("\n")[0]}). Run these yourself:`);
98
+ out(` claude plugin marketplace add ${marketplace}\n claude plugin install cheapag@${MARKETPLACE}`);
99
+ return false;
100
+ }
101
+ }
102
+ out("✓ Added the cheapag plugin to Claude Code (its /cheapag commands and hooks)");
103
+ return true;
104
+ }
105
+ /** A `node` on PATH resolving to this same binary (e.g. Homebrew's bin symlink) survives upgrades; the versioned execPath doesn't. */
106
+ export function stableNode() {
107
+ const self = realpathSync(process.execPath);
108
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
109
+ if (!dir)
110
+ continue;
111
+ const candidate = which("node", process.platform, { PATH: dir, PATHEXT: process.env.PATHEXT });
112
+ try {
113
+ if (candidate && realpathSync(candidate) === self)
114
+ return candidate;
115
+ }
116
+ catch { } // vanished between lookup and realpath
117
+ }
118
+ return process.execPath;
119
+ }