@compaction/cli 0.2.0 → 0.3.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.
Files changed (153) hide show
  1. package/README.md +41 -12
  2. package/dist/cli/commands/activity.d.ts +8 -0
  3. package/dist/cli/commands/activity.js +35 -0
  4. package/dist/cli/commands/apply-context.js +68 -0
  5. package/dist/cli/commands/billing-delta.js +12 -1
  6. package/dist/cli/commands/capture-claude-code.d.ts +44 -0
  7. package/dist/cli/commands/capture-claude-code.js +206 -0
  8. package/dist/cli/commands/capture.js +283 -1
  9. package/dist/cli/commands/compact.js +9 -5
  10. package/dist/cli/commands/dev.d.ts +17 -0
  11. package/dist/cli/commands/dev.js +131 -0
  12. package/dist/cli/commands/gateway.d.ts +31 -0
  13. package/dist/cli/commands/gateway.js +312 -0
  14. package/dist/cli/commands/hooks.d.ts +5 -0
  15. package/dist/cli/commands/hooks.js +181 -0
  16. package/dist/cli/commands/init.js +422 -17
  17. package/dist/cli/commands/input-compaction-ab.d.ts +2 -0
  18. package/dist/cli/commands/input-compaction-ab.js +125 -0
  19. package/dist/cli/commands/optimize-hosted.d.ts +54 -0
  20. package/dist/cli/commands/optimize-hosted.js +123 -0
  21. package/dist/cli/commands/optimize.js +8 -0
  22. package/dist/cli/commands/output-shaping-ab.d.ts +2 -0
  23. package/dist/cli/commands/output-shaping-ab.js +132 -0
  24. package/dist/cli/commands/output-shaping.d.ts +7 -0
  25. package/dist/cli/commands/output-shaping.js +43 -0
  26. package/dist/cli/commands/policies.d.ts +2 -0
  27. package/dist/cli/commands/policies.js +80 -0
  28. package/dist/cli/commands/precall.d.ts +19 -0
  29. package/dist/cli/commands/precall.js +436 -0
  30. package/dist/cli/commands/recommend.js +1 -1
  31. package/dist/cli/commands/run.js +297 -12
  32. package/dist/cli/commands/upgrade-status.d.ts +42 -0
  33. package/dist/cli/commands/upgrade-status.js +152 -0
  34. package/dist/cli/cursor-export-read.d.ts +13 -0
  35. package/dist/cli/cursor-export-read.js +53 -0
  36. package/dist/cli/cursor-live-preflight.d.ts +19 -0
  37. package/dist/cli/cursor-live-preflight.js +46 -0
  38. package/dist/cli/index.js +36 -2
  39. package/dist/cli/onboarding/GatewayTui.d.ts +33 -0
  40. package/dist/cli/onboarding/GatewayTui.js +113 -0
  41. package/dist/cli/onboarding/model.d.ts +32 -1
  42. package/dist/cli/onboarding/model.js +27 -2
  43. package/dist/cli/terminal-logo.d.ts +27 -0
  44. package/dist/cli/terminal-logo.js +49 -0
  45. package/dist/core/activity-event.d.ts +107 -0
  46. package/dist/core/activity-event.js +182 -0
  47. package/dist/core/activity-store.d.ts +63 -0
  48. package/dist/core/activity-store.js +254 -0
  49. package/dist/core/activity-view.d.ts +76 -0
  50. package/dist/core/activity-view.js +120 -0
  51. package/dist/core/api-client/index.d.ts +3 -1
  52. package/dist/core/api-client/index.js +4 -0
  53. package/dist/core/api-client/payload.d.ts +5 -1
  54. package/dist/core/api-client/payload.js +8 -6
  55. package/dist/core/api-client/persisted-config.d.ts +57 -0
  56. package/dist/core/api-client/persisted-config.js +156 -0
  57. package/dist/core/api-client/tool.d.ts +29 -0
  58. package/dist/core/api-client/tool.js +50 -0
  59. package/dist/core/api-client/types.d.ts +15 -4
  60. package/dist/core/auto-apply-ask.d.ts +58 -0
  61. package/dist/core/auto-apply-ask.js +105 -0
  62. package/dist/core/auto-apply-gates.d.ts +76 -0
  63. package/dist/core/auto-apply-gates.js +113 -0
  64. package/dist/core/before-call-activity.d.ts +35 -0
  65. package/dist/core/before-call-activity.js +103 -0
  66. package/dist/core/before-call-recovery.d.ts +19 -0
  67. package/dist/core/before-call-recovery.js +46 -0
  68. package/dist/core/before-call-stdin.d.ts +59 -0
  69. package/dist/core/before-call-stdin.js +78 -0
  70. package/dist/core/before-call.d.ts +149 -0
  71. package/dist/core/before-call.js +358 -0
  72. package/dist/core/billing-delta/billing-delta-record.d.ts +28 -0
  73. package/dist/core/billing-delta/billing-delta-record.js +26 -0
  74. package/dist/core/capture-record.d.ts +40 -0
  75. package/dist/core/capture-record.js +55 -0
  76. package/dist/core/claude-code-before-call.d.ts +37 -0
  77. package/dist/core/claude-code-before-call.js +120 -0
  78. package/dist/core/claude-code-connect.d.ts +31 -0
  79. package/dist/core/claude-code-connect.js +87 -0
  80. package/dist/core/claude-code-hook-record.d.ts +71 -0
  81. package/dist/core/claude-code-hook-record.js +0 -0
  82. package/dist/core/claude-code-hooks.d.ts +77 -0
  83. package/dist/core/claude-code-hooks.js +141 -0
  84. package/dist/core/codex-capture.d.ts +45 -0
  85. package/dist/core/codex-capture.js +204 -0
  86. package/dist/core/command-runner.js +5 -1
  87. package/dist/core/compaction-artifacts.d.ts +51 -0
  88. package/dist/core/compaction-artifacts.js +242 -0
  89. package/dist/core/compactor.d.ts +9 -0
  90. package/dist/core/compactor.js +95 -0
  91. package/dist/core/cross-surface-event.d.ts +306 -0
  92. package/dist/core/cross-surface-event.js +330 -0
  93. package/dist/core/cursor-capture.d.ts +54 -0
  94. package/dist/core/cursor-capture.js +215 -0
  95. package/dist/core/cursor-preflight-probe.d.ts +8 -0
  96. package/dist/core/cursor-preflight-probe.js +88 -0
  97. package/dist/core/cursor-preflight.d.ts +86 -0
  98. package/dist/core/cursor-preflight.js +126 -0
  99. package/dist/core/gateway/apply-activation.d.ts +39 -0
  100. package/dist/core/gateway/apply-activation.js +84 -0
  101. package/dist/core/gateway/apply-policy.d.ts +64 -0
  102. package/dist/core/gateway/apply-policy.js +221 -0
  103. package/dist/core/gateway/apply-receipt.d.ts +36 -0
  104. package/dist/core/gateway/apply-receipt.js +75 -0
  105. package/dist/core/gateway/cache-proof.d.ts +45 -0
  106. package/dist/core/gateway/cache-proof.js +65 -0
  107. package/dist/core/gateway/configure.d.ts +50 -0
  108. package/dist/core/gateway/configure.js +169 -0
  109. package/dist/core/gateway/openai-usage.d.ts +56 -0
  110. package/dist/core/gateway/openai-usage.js +128 -0
  111. package/dist/core/gateway/receipt.d.ts +138 -0
  112. package/dist/core/gateway/receipt.js +120 -0
  113. package/dist/core/gateway/recovery.d.ts +23 -0
  114. package/dist/core/gateway/recovery.js +68 -0
  115. package/dist/core/gateway/server.d.ts +51 -0
  116. package/dist/core/gateway/server.js +276 -0
  117. package/dist/core/gateway/status.d.ts +45 -0
  118. package/dist/core/gateway/status.js +109 -0
  119. package/dist/core/hook-usage-aggregate.d.ts +47 -0
  120. package/dist/core/hook-usage-aggregate.js +161 -0
  121. package/dist/core/input-compaction-ab.d.ts +111 -0
  122. package/dist/core/input-compaction-ab.js +158 -0
  123. package/dist/core/local-run-record.d.ts +109 -0
  124. package/dist/core/local-run-record.js +223 -0
  125. package/dist/core/output-shaping-ab.d.ts +140 -0
  126. package/dist/core/output-shaping-ab.js +146 -0
  127. package/dist/core/output-shaping-attach.d.ts +31 -0
  128. package/dist/core/output-shaping-attach.js +57 -0
  129. package/dist/core/output-shaping.d.ts +56 -0
  130. package/dist/core/output-shaping.js +89 -0
  131. package/dist/core/policy-middleware.d.ts +121 -0
  132. package/dist/core/policy-middleware.js +919 -0
  133. package/dist/core/policy-preferences.d.ts +99 -0
  134. package/dist/core/policy-preferences.js +232 -0
  135. package/dist/core/run-aggregator.d.ts +11 -1
  136. package/dist/core/run-aggregator.js +29 -2
  137. package/dist/core/run-flow-report.d.ts +82 -0
  138. package/dist/core/run-flow-report.js +71 -0
  139. package/dist/core/safety-report.js +8 -1
  140. package/dist/core/shim-capture-bridge.d.ts +32 -0
  141. package/dist/core/shim-capture-bridge.js +88 -0
  142. package/dist/core/skill-injection-policy.d.ts +72 -0
  143. package/dist/core/skill-injection-policy.js +183 -0
  144. package/dist/core/spend-attribution.js +1 -1
  145. package/dist/core/token-accounting.d.ts +1 -1
  146. package/dist/core/tool-shim.d.ts +129 -0
  147. package/dist/core/tool-shim.js +447 -0
  148. package/dist/core/trace-parser.d.ts +13 -13
  149. package/dist/core/trace-parser.js +6 -6
  150. package/dist/core/types.d.ts +12 -2
  151. package/dist/core/waste-detector.d.ts +20 -0
  152. package/dist/core/waste-detector.js +160 -6
  153. package/package.json +1 -1
@@ -0,0 +1,312 @@
1
+ /**
2
+ * `compaction gateway start|status|stop` — the local Compaction Gateway (PUBLIC CLI). Charter run-15
3
+ * (record mode) + run-17 (lifecycle + guided onboarding).
4
+ *
5
+ * RECORD MODE only: a byte-safe, OpenAI-compatible local reverse proxy. Point your OpenAI client's base
6
+ * URL at the gateway; it forwards the request + response to the real provider BYTE-FOR-BYTE and records
7
+ * ONE content-free token/cache/cost receipt per call. No mutation, no cache optimization, no apply, no
8
+ * LCM. The client's API key rides straight through to the provider and is NEVER read, stored, or logged.
9
+ *
10
+ * `runGatewayStart` is exported so the guided onboarding TUI (`GatewayTui` → `init`) can start the same
11
+ * gateway with a config picked interactively — no duplicate server, no new behavior.
12
+ */
13
+ import { URL } from "node:url";
14
+ import path from "node:path";
15
+ import { writeFileSync } from "node:fs";
16
+ import { startGatewayServer } from "../../core/gateway/server.js";
17
+ import { DEFAULT_GATEWAY_RECEIPTS_DIR, GATEWAY_RECEIPTS_FILE, formatFreshBilledInputReduction } from "../../core/gateway/receipt.js";
18
+ import { writeGatewayPid, removeGatewayPid, readGatewayPid, getGatewayStatus, isProcessAlive } from "../../core/gateway/status.js";
19
+ import { planGatewayConfigure, applyGatewayConfigure, formatConfigurePlan } from "../../core/gateway/configure.js";
20
+ import { DEDUPE_POLICY } from "../../core/gateway/apply-policy.js";
21
+ import { GATEWAY_RECOVERY_DIR, readRecovery } from "../../core/gateway/recovery.js";
22
+ import { runThroughGateway } from "./dev.js";
23
+ /** Parse `--listen http://127.0.0.1:8787` (or `127.0.0.1:8787`) into host + port. */
24
+ export function parseListen(listen) {
25
+ const raw = listen.includes("://") ? listen : `http://${listen}`;
26
+ const u = new URL(raw);
27
+ const port = u.port ? Number.parseInt(u.port, 10) : 8787;
28
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
29
+ throw new Error(`invalid --listen port in '${listen}'`);
30
+ }
31
+ return { host: u.hostname || "127.0.0.1", port };
32
+ }
33
+ /**
34
+ * Start the gateway from a validated config: bind the server, write the content-free pidfile (so
35
+ * `status`/`stop` work), print the honest banner, and install shutdown handlers that remove the pidfile.
36
+ * Reused by both `gateway start` and the guided onboarding. Throws on bind failure (the caller reports it).
37
+ */
38
+ export async function runGatewayStart(config) {
39
+ if (config.mode !== "record" && config.mode !== "apply" && config.mode !== "dry-run") {
40
+ throw new Error(`gateway mode '${config.mode}' is not implemented (record | apply | dry-run)`);
41
+ }
42
+ const mutating = config.mode === "apply" || config.mode === "dry-run";
43
+ if (mutating && config.policy !== DEDUPE_POLICY) {
44
+ throw new Error(`gateway --mode ${config.mode} requires --policy ${DEDUPE_POLICY} (the only deterministic policy implemented)`);
45
+ }
46
+ const cwd = config.cwd ?? process.cwd();
47
+ const log = config.log ?? ((line) => console.log(line));
48
+ const started = await startGatewayServer({
49
+ provider: config.provider,
50
+ upstream: config.upstream,
51
+ mode: config.mode,
52
+ ...(config.policy ? { policy: config.policy } : {}),
53
+ cwd,
54
+ log,
55
+ host: config.host,
56
+ port: config.port
57
+ });
58
+ const base = `http://${started.address.host}:${started.address.port}`;
59
+ writeGatewayPid({
60
+ pid: process.pid,
61
+ host: started.address.host,
62
+ port: started.address.port,
63
+ upstream: config.upstream,
64
+ provider: config.provider,
65
+ mode: config.mode,
66
+ startedAt: new Date().toISOString()
67
+ }, cwd);
68
+ const receiptsPath = path.join(cwd, DEFAULT_GATEWAY_RECEIPTS_DIR, GATEWAY_RECEIPTS_FILE);
69
+ if (config.mode === "record") {
70
+ log("compaction gateway — RECORD mode (byte-safe; no mutation; content-free receipts)");
71
+ }
72
+ else if (config.mode === "dry-run") {
73
+ log(`compaction gateway — DRY-RUN mode (policy ${config.policy}; computes a candidate but forwards the ORIGINAL request unchanged)`);
74
+ }
75
+ else {
76
+ log(`compaction gateway — APPLY mode · EXPERIMENTAL (policy ${config.policy}; DETERMINISTIC only)`);
77
+ log(" Apply changes model-visible input for KNOWN-SAFE shapes only; the original is retained locally;");
78
+ log(" unknown/complex shapes FAIL CLOSED (forwarded unchanged). System/developer instructions & tool schemas are never touched.");
79
+ }
80
+ log(` provider: ${config.provider} → upstream ${config.upstream}`);
81
+ log(` receipts: ${receiptsPath} (local-only, gitignored; token/cache counts only — no prompt/response content)`);
82
+ if (mutating)
83
+ log(` recovery: ${path.join(cwd, GATEWAY_RECOVERY_DIR)} (local-only; the ORIGINAL request body when apply changes it — recover with 'compaction gateway recover <id>')`);
84
+ log("");
85
+ log(`Gateway running at ${base}`);
86
+ log("");
87
+ log("Run one command through Compaction:");
88
+ log(" compaction gateway run -- npm run dev");
89
+ log("");
90
+ log("Inspect traffic:");
91
+ log(" compaction gateway status");
92
+ log("");
93
+ log("If manual setup is needed:");
94
+ log(` compaction gateway configure (or point your OpenAI client at baseURL = ${base}/v1 — keep your key on the client; the gateway never stores it)`);
95
+ log("");
96
+ log(" Stop from another terminal: compaction gateway stop · or press Ctrl-C here.");
97
+ const close = async () => {
98
+ removeGatewayPid(cwd);
99
+ await started.close();
100
+ };
101
+ if (config.installSignals !== false) {
102
+ const shutdown = async () => {
103
+ await close();
104
+ process.exit(0);
105
+ };
106
+ process.on("SIGINT", () => void shutdown());
107
+ process.on("SIGTERM", () => void shutdown());
108
+ }
109
+ return { base, close };
110
+ }
111
+ export function registerGatewayCommand(program) {
112
+ const gateway = program
113
+ .command("gateway")
114
+ .description("Local Compaction Gateway — a byte-safe, OpenAI-compatible reverse proxy that records a content-free " +
115
+ "token/cache/cost receipt per call. Default RECORD mode (no mutation); EXPERIMENTAL deterministic " +
116
+ "apply/dry-run modes are explicit opt-in (no cache-opt, no LCM, no semantic compaction).");
117
+ gateway
118
+ .command("start")
119
+ .description("Start the local gateway. Point your OpenAI client's base URL at --listen. RECORD forwards byte-for-byte; APPLY (experimental, explicit) deterministically dedupes known-safe shapes and retains the original locally.")
120
+ .option("--provider <name>", "Provider (OpenAI-compatible only).", "openai")
121
+ .option("--upstream <url>", "Upstream provider base URL (the request path is forwarded to its origin).", "https://api.openai.com/v1")
122
+ .option("--listen <url>", "Local address to listen on (e.g. http://127.0.0.1:8787).", "http://127.0.0.1:8787")
123
+ .option("--mode <mode>", "Gateway mode: record (default, byte-safe) | apply (experimental, deterministic) | dry-run (preview, forwards original).", "record")
124
+ .option("--policy <name>", `Deterministic apply policy (required for apply/dry-run). Only '${DEDUPE_POLICY}' is implemented.`)
125
+ .option("--approval <mode>", "Apply approval model (informational in run-17.2: starting apply mode IS the explicit approval; per-request interactive approval is deferred).", "ask")
126
+ .action(async (options) => {
127
+ const mode = (options.mode ?? "record");
128
+ if (mode !== "record" && mode !== "apply" && mode !== "dry-run") {
129
+ console.error(`error: gateway mode '${mode}' is not implemented (record | apply | dry-run).`);
130
+ process.exitCode = 1;
131
+ return;
132
+ }
133
+ if ((mode === "apply" || mode === "dry-run") && options.policy !== DEDUPE_POLICY) {
134
+ console.error(`error: --mode ${mode} requires --policy ${DEDUPE_POLICY} (the only deterministic policy implemented). ` +
135
+ `Apply is deterministic-only — no semantic compaction, no LCM.`);
136
+ process.exitCode = 1;
137
+ return;
138
+ }
139
+ const provider = options.provider ?? "openai";
140
+ const upstream = options.upstream ?? "https://api.openai.com/v1";
141
+ try {
142
+ new URL(upstream);
143
+ }
144
+ catch {
145
+ console.error(`error: --upstream '${upstream}' is not a valid URL.`);
146
+ process.exitCode = 1;
147
+ return;
148
+ }
149
+ let listen;
150
+ try {
151
+ listen = parseListen(options.listen ?? "http://127.0.0.1:8787");
152
+ }
153
+ catch (err) {
154
+ console.error(`error: ${err.message}`);
155
+ process.exitCode = 1;
156
+ return;
157
+ }
158
+ try {
159
+ await runGatewayStart({ provider, upstream, mode, ...(options.policy ? { policy: options.policy } : {}), host: listen.host, port: listen.port });
160
+ }
161
+ catch (err) {
162
+ console.error(`error: could not start the gateway on ${listen.host}:${listen.port} — ${err.message}`);
163
+ process.exitCode = 1;
164
+ }
165
+ });
166
+ gateway
167
+ .command("status")
168
+ .description("Show whether the local gateway is running + a content-free rollup of its receipts (token/cache; no content).")
169
+ .option("--json", "Print the content-free status as JSON.")
170
+ .action(async (options) => {
171
+ const status = await getGatewayStatus(process.cwd());
172
+ if (options.json) {
173
+ console.log(JSON.stringify(status, null, 2));
174
+ return;
175
+ }
176
+ console.log("compaction gateway status");
177
+ if (status.running) {
178
+ console.log(` gateway running: yes ${status.base ?? ""} (pid ${status.pid})`);
179
+ console.log(` provider / mode: ${status.provider} / ${status.mode}`);
180
+ console.log(` listen URL: ${status.base ?? "(unknown)"}`);
181
+ }
182
+ else if (status.pid) {
183
+ console.log(` gateway running: no (stale pidfile for pid ${status.pid} — run 'compaction gateway stop' to clear it)`);
184
+ }
185
+ else {
186
+ console.log(" gateway running: no (start it with 'compaction gateway start', or 'compaction' → Gateway setup)");
187
+ }
188
+ console.log(` requests observed: ${status.receiptsCount} (local-only, content-free)`);
189
+ console.log(` last request: ${status.lastRequestAt ?? "none observed yet"}`);
190
+ console.log(` cached input seen: ${status.receiptsCount > 0 ? (status.cachedObserved ? "yes" : "no") : "unknown (no requests yet)"}`);
191
+ if (status.receiptsCount > 0) {
192
+ console.log(` best fresh/billed input reduction: ${formatFreshBilledInputReduction(status.summary.bestReduction)}`);
193
+ console.log(` ${status.summary.modelVisibleBytesUnchanged ? "model-visible bytes unchanged" : "model-visible bytes: mixed — check receipts"}`);
194
+ }
195
+ });
196
+ gateway
197
+ .command("run")
198
+ .description("Run a command through the local Compaction Gateway. Usage: compaction gateway run -- <command>. Starts/reuses the gateway, injects the endpoint into the child (never a key), streams its output unchanged, preserves its exit code, and prints a content-free traffic summary.")
199
+ .option("--provider <name>", "Provider (OpenAI-compatible only).", "openai")
200
+ .option("--upstream <url>", "Upstream provider base URL.", "https://api.openai.com/v1")
201
+ .option("--listen <url>", "Local listen address when starting a gateway (default: an ephemeral local port).")
202
+ .argument("[command...]", "The command to run through the gateway (after --), e.g. -- npm run dev")
203
+ .allowUnknownOption(true)
204
+ .action(async (command, options) => {
205
+ await runThroughGateway(command, options, "compaction gateway run");
206
+ });
207
+ gateway
208
+ .command("activate")
209
+ .description("Print shell export lines that point THIS shell's OpenAI base URL at the local gateway. Use: eval \"$(compaction gateway activate)\". It only PRINTS — it never edits files or mutates your shell, and it never attaches to a running process.")
210
+ .option("--listen <url>", "Gateway listen address to point at (default: the running gateway, else http://127.0.0.1:8787).")
211
+ .action(async (options) => {
212
+ const status = await getGatewayStatus(process.cwd());
213
+ let base;
214
+ if (options.listen) {
215
+ const l = parseListen(options.listen);
216
+ base = `http://${l.host}:${l.port}`;
217
+ }
218
+ else if (status.running && status.base) {
219
+ base = status.base;
220
+ }
221
+ else {
222
+ base = "http://127.0.0.1:8787";
223
+ }
224
+ const v1 = `${base}/v1`;
225
+ // Guidance to STDERR (not captured by eval); the exports go to STDOUT (captured by eval).
226
+ console.error('# compaction gateway activate — apply to this shell with: eval "$(compaction gateway activate)"');
227
+ if (!(status.running && status.base) && !options.listen) {
228
+ console.error("# note: no running gateway detected; start one with 'compaction gateway start' (these exports point at the default local address).");
229
+ }
230
+ console.error("# your provider API key stays in your shell — the gateway never reads or stores it.");
231
+ console.log(`export OPENAI_BASE_URL=${v1}`);
232
+ console.log(`export OPENAI_API_BASE=${v1}`);
233
+ });
234
+ gateway
235
+ .command("configure")
236
+ .description("Propose pointing this project's OpenAI base URL at the local gateway (approval-gated: shows a diff; writes nothing without --apply; backs up first; never overwrites an existing base URL without --force).")
237
+ .option("--listen <url>", "Gateway listen address to route to (default: http://127.0.0.1:8787).", "http://127.0.0.1:8787")
238
+ .option("--apply", "Actually write the change (creates a .bak backup first).")
239
+ .option("--force", "Overwrite an existing provider base URL (only with --apply).")
240
+ .action((options) => {
241
+ let listen;
242
+ try {
243
+ listen = parseListen(options.listen ?? "http://127.0.0.1:8787");
244
+ }
245
+ catch (err) {
246
+ console.error(`error: ${err.message}`);
247
+ process.exitCode = 1;
248
+ return;
249
+ }
250
+ const baseUrl = `http://${listen.host}:${listen.port}/v1`;
251
+ const cwd = process.cwd();
252
+ const plan = planGatewayConfigure(cwd, baseUrl);
253
+ for (const line of formatConfigurePlan(plan))
254
+ console.log(line);
255
+ if (!options.apply)
256
+ return;
257
+ const result = applyGatewayConfigure(cwd, plan, options.force ?? false);
258
+ console.log("");
259
+ if (result.wrote) {
260
+ console.log(`compaction gateway configure: wrote ${result.targetFile}${result.backupFile ? ` (backup: ${result.backupFile})` : ""}.`);
261
+ console.log(" restart your app so it picks up the new base URL, or run: compaction gateway run -- <command>");
262
+ }
263
+ else {
264
+ console.log(`compaction gateway configure: not written — ${result.reason}`);
265
+ process.exitCode = 1;
266
+ }
267
+ });
268
+ gateway
269
+ .command("recover")
270
+ .description("Recover the ORIGINAL request body that apply mode changed (retained locally under .compaction/gateway/recovery). Prints to stdout or writes to --out.")
271
+ .argument("<recovery_id>", "The recovery_id shown on the apply receipt.")
272
+ .option("--out <file>", "Write the original body to this file instead of stdout.")
273
+ .action((recoveryId, options) => {
274
+ const rec = readRecovery(process.cwd(), recoveryId);
275
+ if (!rec) {
276
+ console.error(`compaction gateway recover: no retained original found for id '${recoveryId}' under ${GATEWAY_RECOVERY_DIR}.`);
277
+ process.exitCode = 1;
278
+ return;
279
+ }
280
+ if (options.out) {
281
+ writeFileSync(options.out, rec.original_body, "utf8");
282
+ console.error(`compaction gateway recover: wrote the original request body (${rec.endpoint}, policy ${rec.policy}, captured ${rec.captured_at}) to ${options.out}.`);
283
+ return;
284
+ }
285
+ // The original body IS request content — it prints to stdout only on explicit recovery, never stored in a receipt.
286
+ process.stdout.write(rec.original_body.endsWith("\n") ? rec.original_body : `${rec.original_body}\n`);
287
+ });
288
+ gateway
289
+ .command("stop")
290
+ .description("Stop a running local gateway (started in another terminal) via its pidfile.")
291
+ .action(() => {
292
+ const rec = readGatewayPid(process.cwd());
293
+ if (!rec) {
294
+ console.log("compaction gateway stop: no gateway pidfile found — nothing to stop.");
295
+ return;
296
+ }
297
+ if (!isProcessAlive(rec.pid)) {
298
+ removeGatewayPid(process.cwd());
299
+ console.log(`compaction gateway stop: pid ${rec.pid} is not running — cleared the stale pidfile.`);
300
+ return;
301
+ }
302
+ try {
303
+ process.kill(rec.pid, "SIGTERM");
304
+ console.log(`compaction gateway stop: sent SIGTERM to pid ${rec.pid} (${rec.host}:${rec.port}). It removes its own pidfile on exit.`);
305
+ }
306
+ catch (err) {
307
+ console.error(`compaction gateway stop: could not signal pid ${rec.pid} — ${err.message}`);
308
+ process.exitCode = 1;
309
+ }
310
+ });
311
+ }
312
+ //# sourceMappingURL=gateway.js.map
@@ -0,0 +1,5 @@
1
+ import { Command } from "commander";
2
+ import { type HookUsageAggregate } from "../../core/hook-usage-aggregate.js";
3
+ export declare function registerHooksCommand(program: Command): void;
4
+ /** Render the content-free source-status aggregate as a readable block (provider-reported vs unavailable). */
5
+ export declare function printHookUsageAggregate(aggregate: HookUsageAggregate): string;
@@ -0,0 +1,181 @@
1
+ /**
2
+ * `compaction hooks install|uninstall|status` — consented Claude Code Stop-hook management + a content-free
3
+ * source-status view of the records the hook produces (PUBLIC CLI).
4
+ * Installs a Stop hook that runs `compaction capture claude-code --from-hook` (content-free usage capture).
5
+ *
6
+ * Consent + safety: running this command IS the consent — it prints exactly what it will write and where,
7
+ * MERGES (never replaces) existing settings, is idempotent, supports `--dry-run` (writes nothing), and only
8
+ * ever removes Compaction's own hook on uninstall. `init` may suggest this command but must never write
9
+ * settings silently. `status` is read-only, local-first, content-free.
10
+ */
11
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
12
+ import { homedir } from "node:os";
13
+ import path from "node:path";
14
+ import chalk from "chalk";
15
+ import { CLAUDE_CODE_HOOK_COMMAND, CLAUDE_CODE_BEFORE_CALL_HOOK_COMMAND, installStopHook, installBeforeCallHook, uninstallStopHook, uninstallBeforeCallHook } from "../../core/claude-code-hooks.js";
16
+ import { aggregateHookUsageRecords, loadHookUsageRecords } from "../../core/hook-usage-aggregate.js";
17
+ /** Resolve the target settings file from the scope flags. Default: project `.claude/settings.json`. */
18
+ function resolveSettingsPath(options) {
19
+ if (options.settings)
20
+ return options.settings;
21
+ if (options.user)
22
+ return path.join(homedir(), ".claude", "settings.json");
23
+ if (options.local)
24
+ return path.join(process.cwd(), ".claude", "settings.local.json");
25
+ return path.join(process.cwd(), ".claude", "settings.json");
26
+ }
27
+ async function readSettings(file) {
28
+ try {
29
+ const raw = await readFile(file, "utf8");
30
+ const parsed = JSON.parse(raw);
31
+ return { settings: parsed && typeof parsed === "object" ? parsed : {}, existed: true };
32
+ }
33
+ catch {
34
+ return { settings: {}, existed: false };
35
+ }
36
+ }
37
+ async function writeSettings(file, settings) {
38
+ await mkdir(path.dirname(file), { recursive: true });
39
+ await writeFile(file, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
40
+ }
41
+ export function registerHooksCommand(program) {
42
+ const hooks = program
43
+ .command("hooks")
44
+ .description("Manage the Claude Code Stop hook that records CONTENT-FREE usage via `capture claude-code --from-hook`. " +
45
+ "Consented, merge-not-replace, idempotent, dry-run supported. Never writes settings without you running it.");
46
+ hooks
47
+ .command("install")
48
+ .description("Install the Compaction Stop (measurement) hook into Claude Code settings (merge-not-replace, idempotent). " +
49
+ "Add --before-call to ALSO install the UserPromptSubmit before-call RECOMMENDATION hook (run-13).")
50
+ .option("--settings <path>", "Explicit settings file to edit (overrides --local/--user).")
51
+ .option("--local", "Target project .claude/settings.local.json (personal, untracked).")
52
+ .option("--user", "Target ~/.claude/settings.json (all your projects).")
53
+ .option("--before-call", "ALSO install the UserPromptSubmit before-call RECOMMENDATION hook (a genuine pre-call event; content-free, fail-open, silent; recommendation-only — apply is a proven blocker on Claude Code hooks).")
54
+ .option("--dry-run", "Print the resulting settings without writing anything.")
55
+ .action(async (options) => {
56
+ console.log(chalk.cyan("compaction hooks install"));
57
+ const file = resolveSettingsPath(options);
58
+ const { settings, existed } = await readSettings(file);
59
+ let result;
60
+ try {
61
+ result = installStopHook(settings);
62
+ // The before-call hook merges on TOP of the Stop result (both merge-not-replace, both idempotent).
63
+ if (options.beforeCall)
64
+ result = installBeforeCallHook(result.settings);
65
+ }
66
+ catch (error) {
67
+ console.error(chalk.red(` Not installed: ${error instanceof Error ? error.message : String(error)}`));
68
+ process.exitCode = 1;
69
+ return;
70
+ }
71
+ console.log(` target settings: ${file}${existed ? "" : " (will be created)"}`);
72
+ console.log(` Stop hook: ${CLAUDE_CODE_HOOK_COMMAND} (measurement, content-free, post-session)`);
73
+ if (options.beforeCall) {
74
+ console.log(` before-call: ${CLAUDE_CODE_BEFORE_CALL_HOOK_COMMAND} (UserPromptSubmit; recommendation-only, content-free, silent)`);
75
+ }
76
+ console.log(" posture: content-free; fail-open (never breaks Claude Code); merge-not-replace; nothing is applied to your prompts.");
77
+ // `result.settings` reflects the FULL merge; `changed` reflects whether the last merge changed
78
+ // anything. Recompute "did anything change" honestly across both hooks by comparing to the read.
79
+ const changed = JSON.stringify(result.settings) !== JSON.stringify(settings);
80
+ if (!changed) {
81
+ console.log(chalk.green(" Already installed — no change (idempotent)."));
82
+ return;
83
+ }
84
+ if (options.dryRun) {
85
+ console.log(chalk.yellow(" --dry-run: NOT written. Resulting settings would be:"));
86
+ console.log(JSON.stringify(result.settings, null, 2));
87
+ return;
88
+ }
89
+ await writeSettings(file, result.settings);
90
+ console.log(chalk.green(` Installed. Open Claude Code's /hooks once (or restart) to load it.`));
91
+ console.log(chalk.gray(" Uninstall anytime: compaction hooks uninstall" + (options.settings ? ` --settings ${file}` : options.user ? " --user" : options.local ? " --local" : "")));
92
+ });
93
+ hooks
94
+ .command("uninstall")
95
+ .description("Remove ONLY Compaction's hooks (Stop measurement + UserPromptSubmit before-call) from Claude Code settings (preserves all other settings/hooks).")
96
+ .option("--settings <path>", "Explicit settings file to edit (overrides --local/--user).")
97
+ .option("--local", "Target project .claude/settings.local.json.")
98
+ .option("--user", "Target ~/.claude/settings.json.")
99
+ .option("--dry-run", "Print the resulting settings without writing anything.")
100
+ .action(async (options) => {
101
+ console.log(chalk.cyan("compaction hooks uninstall"));
102
+ const file = resolveSettingsPath(options);
103
+ const { settings, existed } = await readSettings(file);
104
+ if (!existed) {
105
+ console.log(` No settings file at ${file} — nothing to uninstall.`);
106
+ return;
107
+ }
108
+ // Remove BOTH Compaction hooks (only ours; each is scoped to its own event + command identity).
109
+ const stop = uninstallStopHook(settings);
110
+ const beforeCall = uninstallBeforeCallHook(stop.settings);
111
+ const removedCount = stop.removedCount + beforeCall.removedCount;
112
+ const finalSettings = beforeCall.settings;
113
+ console.log(` target settings: ${file}`);
114
+ if (removedCount === 0) {
115
+ console.log(" No Compaction hooks present — no change.");
116
+ return;
117
+ }
118
+ if (options.dryRun) {
119
+ console.log(chalk.yellow(` --dry-run: NOT written. Would remove ${removedCount} hook entr(y/ies). Resulting settings:`));
120
+ console.log(JSON.stringify(finalSettings, null, 2));
121
+ return;
122
+ }
123
+ await writeSettings(file, finalSettings);
124
+ console.log(chalk.green(` Removed ${removedCount} Compaction hook entr(y/ies) (Stop + before-call). Other settings preserved.`));
125
+ });
126
+ hooks
127
+ .command("status")
128
+ .description("Source-status view of the CONTENT-FREE usage records produced by `capture claude-code --from-hook`. " +
129
+ "Aggregates by tool over your local records (by source/session/time). Read-only, local-first, no network. " +
130
+ "Distinguishes provider-reported usage from unavailable; makes NO savings claim.")
131
+ .option("--records-dir <dir>", "Base directory holding hook records", ".compaction/hooks")
132
+ .option("--since <iso>", "Only include records recorded at or after this ISO timestamp (time window).")
133
+ .option("--json", "Print the raw content-free aggregate JSON instead of the table.")
134
+ .action(async (options) => {
135
+ const records = await loadHookUsageRecords(options.recordsDir ?? ".compaction/hooks");
136
+ const aggregate = aggregateHookUsageRecords(records, options.since ? { since: options.since } : {});
137
+ if (options.json) {
138
+ console.log(JSON.stringify(aggregate, null, 2));
139
+ return;
140
+ }
141
+ console.log(chalk.cyan("compaction hooks status"));
142
+ console.log(" Local, content-free source status from Claude Code Stop-hook records. No content; no savings claim.");
143
+ console.log(printHookUsageAggregate(aggregate));
144
+ });
145
+ }
146
+ const dash = chalk.gray("—");
147
+ function tok(value) {
148
+ return value === null ? chalk.gray("unavailable") : String(value);
149
+ }
150
+ /** Render the content-free source-status aggregate as a readable block (provider-reported vs unavailable). */
151
+ export function printHookUsageAggregate(aggregate) {
152
+ const lines = [];
153
+ lines.push(` records: ${aggregate.dedupedRecords} (deduped from ${aggregate.totalRecords})${aggregate.since ? ` · since ${aggregate.since}` : ""}`);
154
+ if (aggregate.tools.length === 0) {
155
+ lines.push(chalk.gray(" No hook records yet. Install the hook and let a Claude Code turn finish:"));
156
+ lines.push(chalk.gray(" compaction hooks install → (run Claude Code) → compaction hooks status"));
157
+ return lines.join("\n");
158
+ }
159
+ for (const t of aggregate.tools) {
160
+ // Headline never overstates: full provider-reported only when EVERY event is; mixed says "partial".
161
+ const status = t.providerReportedEvents === t.events && t.events > 0
162
+ ? chalk.green("live · provider-reported")
163
+ : t.providerReportedEvents > 0
164
+ ? chalk.yellow("live · provider-reported (partial)")
165
+ : t.unavailableEvents === t.events
166
+ ? chalk.gray("usage unavailable")
167
+ : chalk.yellow(t.tokenSources.join(" / ") || "recorded");
168
+ lines.push("");
169
+ lines.push(` ${chalk.bold(t.tool)} — ${status}`);
170
+ lines.push(` events: ${t.events} sessions: ${t.sessions}`);
171
+ lines.push(` input tokens: ${tok(t.inputTokens)}`);
172
+ lines.push(` output tokens: ${tok(t.outputTokens)}${t.outputRecorded < t.events ? chalk.gray(` (${t.events - t.outputRecorded} record(s) without output)`) : ""}`);
173
+ lines.push(` reasoning: ${chalk.gray("unavailable (Claude Code session usage does not report it separately)")}`);
174
+ lines.push(` token source: ${t.tokenSources.length ? t.tokenSources.join(" / ") : dash}`);
175
+ lines.push(` provider/model:${t.providers.length || t.models.length ? ` ${[t.providers.join(", "), t.models.join(", ")].filter(Boolean).join(" · ")}` : ` ${dash}`}`);
176
+ lines.push(` provider-reported / unavailable: ${t.providerReportedEvents} / ${t.unavailableEvents}`);
177
+ lines.push(` window: ${t.firstRecordedAt ?? dash} → ${t.lastRecordedAt ?? dash}`);
178
+ }
179
+ return lines.join("\n");
180
+ }
181
+ //# sourceMappingURL=hooks.js.map