@etiennepasteur/jean-claude 0.1.0 → 0.2.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 (3) hide show
  1. package/README.md +76 -7
  2. package/dist/cli.mjs +255 -22
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -92,23 +92,30 @@ cp ./captures/api.anthropic.com/api/claude_code/settings.GET.json \
92
92
  jean-claude run -- claude
93
93
  ```
94
94
 
95
- Add `-v` the first time, to see every request rather than only the ones a rule
95
+ Claude Code keeps the terminal to itself: jean-claude's log goes to
96
+ `~/.config/jean-claude/jean-claude.log` instead. Follow it from another terminal,
97
+ with `-v` the first time to see every request rather than only the ones a rule
96
98
  touched:
97
99
 
100
+ ```bash
101
+ tail -f ~/.config/jean-claude/jean-claude.log
102
+ ```
103
+
98
104
  ```
99
105
  GET api.anthropic.com/api/claude_code/settings 200 → stub responses/settings.GET.json
100
106
  POST api.anthropic.com/v1/messages 200
101
107
  ```
102
108
 
103
- A `→ stub` on the settings line means it worked. If you would rather keep the log
104
- out of Claude Code's output, run the proxy in its own terminal —
105
- see [Two terminals](#two-terminals-start--env).
109
+ A `→ stub` on the settings line means it worked. See
110
+ [Where jean-claude's own log goes](#where-jean-claudes-own-log-goes) to send it
111
+ somewhere else, or back to the screen.
106
112
 
107
113
  ## Where everything lives
108
114
 
109
115
  ```
110
116
  ~/.config/jean-claude/ # $XDG_CONFIG_HOME/jean-claude if set
111
117
  ├── jean-claude.yaml
118
+ ├── jean-claude.log # where `run` sends its log, see below
112
119
  ├── responses/
113
120
  ├── ca/ ca.pem ca.key bundle.pem
114
121
  └── session.json # only while `start` is running
@@ -282,6 +289,11 @@ The bundle exists because `SSL_CERT_FILE` and `CURL_CA_BUNDLE` **replace** the
282
289
  trust store rather than adding to it — handing a target only our own CA would cut
283
290
  it off from every other authority.
284
291
 
292
+ The system trust store is read through `tls.getCACertificates('system')`, so a CA
293
+ your administrators installed machine-wide is picked up on **Windows and macOS**
294
+ too, where the store is an OS API and not a file. jean-claude trusts the same set
295
+ on its own outbound connections.
296
+
285
297
  `jean-claude run` points the child at the bundle, so **no root access and no
286
298
  system-wide trust change is needed**. If you do want the CA in the system store
287
299
  (for a GUI app, say), `jean-claude ca --install` prints the commands for you to
@@ -296,7 +308,23 @@ onto whatever it finds:
296
308
  your corporate proxy. Override with `upstream:` in the config (`auto`, `off`,
297
309
  or an explicit URL).
298
310
  - `NODE_EXTRA_CA_CERTS` → your corporate CA is trusted on outbound connections
299
- _and_ folded into `bundle.pem` for the child.
311
+ _and_ folded into `bundle.pem` for the child. Only needed for a CA that is
312
+ **not** in the machine's trust store: that one is read anyway.
313
+
314
+ ### When jean-claude cannot verify the real server
315
+
316
+ The interception can be perfect and the relay leg still fail, which shows up as
317
+ a 502 the target never asked for:
318
+
319
+ ```
320
+ GET api.example.com/v1/things upstream UNABLE_TO_GET_ISSUER_CERT_LOCALLY
321
+ ! jean-claude could not verify the real server's certificate. …
322
+ ```
323
+
324
+ That means the authority signing the traffic — usually your network's TLS
325
+ inspection appliance — is in neither the machine trust store nor
326
+ `NODE_EXTRA_CA_CERTS`. Export it and point `NODE_EXTRA_CA_CERTS` at it before
327
+ starting jean-claude.
300
328
 
301
329
  ### When a target refuses the CA
302
330
 
@@ -343,6 +371,47 @@ Known gaps worth knowing rather than fighting: Java uses its own keystore
343
371
  environment variable, and anything with pinned certificates needs
344
372
  `tlsPassthrough`.
345
373
 
374
+ ## Where jean-claude's own log goes
375
+
376
+ `run` gives the terminal to the tool it spawns. A request line landing in the
377
+ middle of a full-screen TUI — Claude Code's, for one — corrupts the display, so
378
+ by default the log goes to a file instead, announced in the banner:
379
+
380
+ ```
381
+ $ jean-claude run -- claude
382
+
383
+ proxy http://127.0.0.1:8001
384
+ config ~/.config/jean-claude/jean-claude.yaml 1 rule(s)
385
+ log ~/.config/jean-claude/jean-claude.log (tail -f to follow)
386
+ command claude
387
+
388
+ … Claude Code owns the screen from here …
389
+
390
+ 12 requests, 1 rule hit, 2 upstream errors → ~/.config/jean-claude/jean-claude.log
391
+ ```
392
+
393
+ The banner is printed before the child starts, and the summary after it exits, so
394
+ neither can clobber anything. The summary is what tells you a redirected run did
395
+ something — errors are counted in red.
396
+
397
+ Destination, first match wins:
398
+
399
+ | Setting | Effect |
400
+ | ------------------------- | --------------------------------------------- |
401
+ | `--log <path>` | that file (relative to the current directory) |
402
+ | `--log terminal` | on screen, interleaved |
403
+ | `logFile:` in the config | that file (relative to the config file) |
404
+ | stdout is a terminal | `<home>/jean-claude.log` |
405
+ | anything else (pipes, CI) | on screen, unchanged |
406
+
407
+ The file is appended to, never truncated, and each session opens with a header
408
+ carrying the timestamp, pid and command — two concurrent runs cannot wipe each
409
+ other. `-q/--quiet` still means "log nothing", wherever the log would have gone.
410
+
411
+ `start` never redirects on its own and ignores `logFile:`: giving the log a
412
+ terminal of its own is what it is for. Pass `--log <path>` if you want it in a
413
+ file there too.
414
+
346
415
  ## Two terminals: `start` + `env`
347
416
 
348
417
  For a GUI app, a service that is already running, or simply to keep jean-claude's
@@ -382,7 +451,7 @@ jean-claude init set up the jean-claude directory: config, stub, C
382
451
  ```
383
452
 
384
453
  Shared flags: `-c/--config`, `-p/--port`, `-r/--record`, `--home`,
385
- `-v/--verbose`, `-q/--quiet`, `--no-watch`.
454
+ `--log <path|terminal>`, `-v/--verbose`, `-q/--quiet`, `--no-watch`.
386
455
 
387
456
  `init` takes `--home <dir>` and `--claude-code`.
388
457
 
@@ -396,7 +465,7 @@ one setting that needs a restart.
396
465
 
397
466
  ```bash
398
467
  npm install
399
- npm test # 130 tests, including an end-to-end MITM suite
468
+ npm test # 155 tests, including an end-to-end MITM suite
400
469
  npm run typecheck
401
470
  npm run lint
402
471
  npm run build
package/dist/cli.mjs CHANGED
@@ -10,11 +10,16 @@ import { YAMLParseError, parse } from "yaml";
10
10
  import { z } from "zod";
11
11
  import { match } from "path-to-regexp";
12
12
  import { execa } from "execa";
13
+ import { createWriteStream } from "node:fs";
14
+ import { stripVTControlCharacters } from "node:util";
13
15
  import { watch } from "chokidar";
14
16
  import { createHash } from "node:crypto";
15
17
  import { setTimeout } from "node:timers/promises";
16
18
  //#region src/ca/store.ts
17
- /** Candidate system trust stores, by distribution family. */
19
+ /**
20
+ * Candidate system trust stores, by distribution family. Only a fallback: see
21
+ * `readSystemTrust` for why the OS API comes first.
22
+ */
18
23
  const SYSTEM_CA_BUNDLES = [
19
24
  "/etc/ssl/certs/ca-certificates.crt",
20
25
  "/etc/pki/tls/certs/ca-bundle.crt",
@@ -41,6 +46,37 @@ async function findSystemBundle() {
41
46
  for (const candidate of SYSTEM_CA_BUNDLES) if (await exists(candidate)) return candidate;
42
47
  }
43
48
  /**
49
+ * The machine's own trust store.
50
+ *
51
+ * `tls.getCACertificates('system')` is tried first because on Windows and macOS
52
+ * the store is an OS API, not a file: the Unix paths above find nothing there,
53
+ * and falling back to Node's bundled Mozilla roots silently drops every CA the
54
+ * machine was given by its administrators. That is exactly the CA an
55
+ * intercepting corporate proxy signs with, so losing it makes the relay leg fail
56
+ * with `unable to get local issuer certificate` while the client side keeps
57
+ * working - a failure that looks like a jean-claude bug and is not.
58
+ *
59
+ * The API landed in Node 22.15 / 24.0, hence the feature detection.
60
+ */
61
+ async function readSystemTrust() {
62
+ if (typeof tls.getCACertificates === "function") try {
63
+ const certs = tls.getCACertificates("system");
64
+ if (certs.length > 0) return {
65
+ certs: certs.join("\n"),
66
+ source: `the OS trust store (${certs.length} certs)`
67
+ };
68
+ } catch {}
69
+ const bundlePath = await findSystemBundle();
70
+ if (bundlePath !== void 0) return {
71
+ certs: await readFile(bundlePath, "utf8"),
72
+ source: bundlePath
73
+ };
74
+ return {
75
+ certs: tls.rootCertificates.join("\n"),
76
+ source: "Node's built-in roots"
77
+ };
78
+ }
79
+ /**
44
80
  * Make sure the CA exists, and (re)generate the trust bundle.
45
81
  *
46
82
  * The bundle is rewritten on every call: the system store or the corporate CA
@@ -62,29 +98,34 @@ async function ensureCa({ dir, inheritedCa }) {
62
98
  await writeFile(paths.keyPath, key, { mode: 384 });
63
99
  }
64
100
  const ourCert = await readFile(paths.certPath, "utf8");
65
- const systemBundle = await findSystemBundle();
66
- const systemCerts = systemBundle ? await readFile(systemBundle, "utf8") : tls.rootCertificates.join("\n");
101
+ const system = await readSystemTrust();
67
102
  const inheritedCerts = inheritedCa && await exists(inheritedCa) ? await readFile(inheritedCa, "utf8") : "";
68
103
  const resolvedInherited = inheritedCerts === "" ? void 0 : inheritedCa;
104
+ const inheritedSection = resolvedInherited ? [`# CA inherited from NODE_EXTRA_CA_CERTS (${resolvedInherited})`, inheritedCerts.trim()] : [];
105
+ const systemSection = [`# System trust store (${system.source})`, system.certs.trim()];
69
106
  const bundle = [
70
107
  "# Generated by jean-claude - do not edit by hand.",
71
108
  "# jean-claude CA",
72
109
  ourCert.trim(),
73
- ...resolvedInherited ? [`# CA inherited from NODE_EXTRA_CA_CERTS (${resolvedInherited})`, inheritedCerts.trim()] : [],
74
- `# System trust store (${systemBundle ?? "Node's built-in roots"})`,
75
- systemCerts.trim(),
110
+ ...inheritedSection,
111
+ ...systemSection,
76
112
  ""
77
113
  ].join("\n");
78
114
  await writeFile(paths.bundlePath, bundle, { mode: 420 });
79
115
  return {
80
116
  ...paths,
81
117
  created,
82
- systemBundle,
83
- inheritedCa: resolvedInherited
118
+ systemTrust: system.source,
119
+ inheritedCa: resolvedInherited,
120
+ outboundTrust: [
121
+ ...inheritedSection,
122
+ ...systemSection,
123
+ ""
124
+ ].join("\n")
84
125
  };
85
126
  }
86
127
  /**
87
- * Commands to install the CA into the system trust store, per distribution family.
128
+ * Commands to install the CA into the system trust store, per platform.
88
129
  * Returned as text on purpose: jean-claude never runs `sudo` on its own.
89
130
  */
90
131
  function systemTrustInstructions(certPath) {
@@ -97,7 +138,14 @@ function systemTrustInstructions(certPath) {
97
138
  `sudo cp ${certPath} /etc/pki/ca-trust/source/anchors/jean-claude.crt`,
98
139
  "sudo update-ca-trust extract",
99
140
  "",
100
- "# To uninstall: delete the copied file, then re-run the matching update command."
141
+ "# Windows, current user only (PowerShell, no elevation needed)",
142
+ `certutil -user -addstore Root "${certPath}"`,
143
+ "",
144
+ "# Windows, machine-wide (PowerShell as administrator)",
145
+ `Import-Certificate -FilePath "${certPath}" -CertStoreLocation Cert:\\LocalMachine\\Root`,
146
+ "",
147
+ "# To uninstall: delete the copied file and re-run the matching update command,",
148
+ "# or on Windows remove the entry from the Root store (certmgr.msc)."
101
149
  ];
102
150
  }
103
151
  //#endregion
@@ -111,6 +159,7 @@ function systemTrustInstructions(certPath) {
111
159
  * and one path to remember beats XDG purity here. `--home` relocates the lot.
112
160
  *
113
161
  * <home>/jean-claude.yaml
162
+ * <home>/jean-claude.log
114
163
  * <home>/responses/
115
164
  * <home>/ca/{ca.pem,ca.key,bundle.pem}
116
165
  * <home>/session.json
@@ -126,6 +175,10 @@ function caDirIn(home) {
126
175
  function responsesDirIn(home) {
127
176
  return path.join(home, "responses");
128
177
  }
178
+ /** Where `run` sends its own log when a spawned tool owns the terminal. */
179
+ function logFileIn(home) {
180
+ return path.join(home, "jean-claude.log");
181
+ }
129
182
  //#endregion
130
183
  //#region src/env/upstream.ts
131
184
  function firstDefined(env, names) {
@@ -187,7 +240,7 @@ async function caCommand(options) {
187
240
  console.log(` ${pc.dim("cert ")}${ca.certPath}${ca.created ? pc.dim(" (just generated)") : ""}`);
188
241
  console.log(` ${pc.dim("key ")}${ca.keyPath}`);
189
242
  console.log(` ${pc.dim("bundle ")}${ca.bundlePath}`);
190
- console.log(` ${pc.dim("system ")}${ca.systemBundle ?? "Node's built-in roots"}`);
243
+ console.log(` ${pc.dim("system ")}${ca.systemTrust}`);
191
244
  if (ca.inheritedCa !== void 0) console.log(` ${pc.dim("corp ")}${ca.inheritedCa} ${pc.dim("(inherited from NODE_EXTRA_CA_CERTS)")}`);
192
245
  if (options.install) {
193
246
  console.log(`\n ${pc.bold("Installing into the system trust store")} ${pc.dim("(run these yourself)")}\n`);
@@ -369,6 +422,11 @@ const configSchema = z.strictObject({
369
422
  noProxy: z.array(z.string()).optional(),
370
423
  /** Hosts tunnelled without interception, for clients that pin certificates. */
371
424
  tlsPassthrough: z.array(z.string()).optional(),
425
+ /**
426
+ * Where `run` writes its own log, relative to this file. The literal
427
+ * `terminal` keeps it interleaved with the target's output.
428
+ */
429
+ logFile: z.string().optional(),
372
430
  rules: z.array(ruleSchema).default([])
373
431
  });
374
432
  //#endregion
@@ -449,6 +507,10 @@ async function loadConfig(explicitPath, options = {}) {
449
507
  if (raw === null || raw === void 0) raw = {};
450
508
  return compile(raw, path.dirname(filePath), filePath);
451
509
  }
510
+ /** Resolve a path declared in the config, relative to the config file. */
511
+ function resolveFromConfig(loaded, target) {
512
+ return path.resolve(loaded.baseDir, target);
513
+ }
452
514
  //#endregion
453
515
  //#region src/commands/check.ts
454
516
  /** Describes what a rule does, in one word. */
@@ -677,6 +739,12 @@ host: api.example.com
677
739
  # <url> an explicit proxy
678
740
  upstream: auto
679
741
 
742
+ # Where jean-claude's own log goes while \`run\` has a child, so a full-screen
743
+ # tool keeps the terminal to itself. A path relative to this file, or 'terminal'
744
+ # to leave the log interleaved with your tool's output.
745
+ # Default: <home>/jean-claude.log
746
+ # logFile: jean-claude.log
747
+
680
748
  rules:
681
749
  # 1 - Replace the response with a file. The server is never contacted.
682
750
  - name: frozen todos
@@ -707,6 +775,12 @@ host: api.anthropic.com
707
775
  # <url> an explicit proxy
708
776
  upstream: auto
709
777
 
778
+ # Where jean-claude's own log goes while \`run\` has a child, so a full-screen
779
+ # tool keeps the terminal to itself. A path relative to this file, or 'terminal'
780
+ # to leave the log interleaved with your tool's output.
781
+ # Default: <home>/jean-claude.log
782
+ # logFile: jean-claude.log
783
+
710
784
  rules:
711
785
  # 1 - Freeze the settings Claude Code fetches at startup: it gets this file
712
786
  # instead, and the server is never contacted for it. Edit the 'settings'
@@ -770,6 +844,42 @@ async function initCommand(options) {
770
844
  return 0;
771
845
  }
772
846
  //#endregion
847
+ //#region src/log/sink.ts
848
+ function redirect(stream, sink) {
849
+ const original = stream.write;
850
+ stream.write = ((chunk, encoding, callback) => {
851
+ const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
852
+ sink.write(stripVTControlCharacters(text));
853
+ const done = typeof encoding === "function" ? encoding : callback;
854
+ if (typeof done === "function") done();
855
+ return true;
856
+ });
857
+ return () => {
858
+ stream.write = original;
859
+ };
860
+ }
861
+ /**
862
+ * Start capturing. `header` opens the session in the file: the log is appended
863
+ * to, never truncated, so two concurrent runs cannot wipe each other's output.
864
+ */
865
+ async function captureOutputTo(filePath, header) {
866
+ await mkdir(path.dirname(filePath), { recursive: true });
867
+ const sink = createWriteStream(filePath, { flags: "a" });
868
+ sink.write(`\n${header}\n`);
869
+ const restore = [redirect(process.stdout, sink), redirect(process.stderr, sink)];
870
+ return {
871
+ path: filePath,
872
+ close: async () => {
873
+ for (const undo of restore) undo();
874
+ await new Promise((resolve) => sink.end(resolve));
875
+ }
876
+ };
877
+ }
878
+ /** Opening line of a captured session, so a shared log stays readable. */
879
+ function sessionHeader(command, now = /* @__PURE__ */ new Date()) {
880
+ return `=== jean-claude ${now.toISOString()} pid ${process.pid} - ${command} ===`;
881
+ }
882
+ //#endregion
773
883
  //#region src/config/watch.ts
774
884
  /**
775
885
  * Watches the config file and calls `onChange` after each save.
@@ -791,6 +901,19 @@ function watchConfig(filePath, onChange) {
791
901
  }
792
902
  //#endregion
793
903
  //#region src/log/reporter.ts
904
+ /**
905
+ * OpenSSL verification failures, as opposed to the dozens of other ways a relay
906
+ * can die. They all mean the same thing operationally: jean-claude was not given
907
+ * the authority that signed what it was talking to.
908
+ */
909
+ const TRUST_ERROR_CODES = /* @__PURE__ */ new Set([
910
+ "UNABLE_TO_GET_ISSUER_CERT",
911
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
912
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
913
+ "SELF_SIGNED_CERT_IN_CHAIN",
914
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
915
+ "CERT_UNTRUSTED"
916
+ ]);
794
917
  const KIND_LABELS = {
795
918
  stub: "stub",
796
919
  patch: "patched",
@@ -819,6 +942,9 @@ function pad(text, width) {
819
942
  function truncate(text, width) {
820
943
  return text.length <= width ? text : `${text.slice(0, width - 1)}…`;
821
944
  }
945
+ function plural(count, noun) {
946
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
947
+ }
822
948
  /**
823
949
  * Console log. One line per request, emitted on response so that the status
824
950
  * actually delivered to the client can be shown.
@@ -826,6 +952,24 @@ function truncate(text, width) {
826
952
  var Reporter = class {
827
953
  options;
828
954
  entries = /* @__PURE__ */ new Map();
955
+ /** The trust hint is the same every time; once per run is enough. */
956
+ trustHintShown = false;
957
+ /**
958
+ * Counted even under `--quiet`, and even when the log went to a file: the
959
+ * summary is the only thing a redirected run says on the terminal.
960
+ */
961
+ counts = {
962
+ requests: 0,
963
+ ruleHits: 0,
964
+ upstreamErrors: 0
965
+ };
966
+ /**
967
+ * Requests already reported as an upstream failure. mockttp still hands the
968
+ * client a synthetic 502 afterwards, which would otherwise show up as a second
969
+ * line - with no method and no URL, since the entry is already gone - and
970
+ * count the same request twice.
971
+ */
972
+ failed = /* @__PURE__ */ new Set();
829
973
  constructor(options) {
830
974
  this.options = options;
831
975
  }
@@ -843,6 +987,7 @@ var Reporter = class {
843
987
  });
844
988
  }
845
989
  action(id, kind, label, detail) {
990
+ if (kind !== "passthrough") this.counts.ruleHits += 1;
846
991
  this.upsert(id, {
847
992
  kind,
848
993
  label,
@@ -853,8 +998,10 @@ var Reporter = class {
853
998
  this.upsert(id, { recorded: filePath });
854
999
  }
855
1000
  response(id, statusCode) {
1001
+ if (this.failed.delete(id)) return;
856
1002
  const entry = this.entries.get(id) ?? {};
857
1003
  this.entries.delete(id);
1004
+ this.counts.requests += 1;
858
1005
  if (this.options.quiet) return;
859
1006
  if ((entry.kind ?? "passthrough") === "passthrough" && entry.recorded === void 0 && !this.options.verbose) return;
860
1007
  console.log(` ${pad(entry.method ?? "???", 6)}${pad(truncate(shortTarget(entry.url), 52), 54)}${colorStatus(statusCode)}${this.describeAction(entry)}`);
@@ -871,11 +1018,31 @@ var Reporter = class {
871
1018
  aborted(id, reason) {
872
1019
  const entry = this.entries.get(id) ?? {};
873
1020
  this.entries.delete(id);
1021
+ this.counts.requests += 1;
874
1022
  if (this.options.quiet) return;
875
1023
  const detail = reason !== void 0 ? ` ${pc.dim(`(${reason})`)}` : "";
876
1024
  console.log(` ${pad(entry.method ?? "???", 6)}${pad(truncate(shortTarget(entry.url), 52), 54)}${pc.red("aborted")}${detail}`);
877
1025
  }
878
1026
  /**
1027
+ * jean-claude reached the target but could not talk to the real server. Worth
1028
+ * a line of its own: the client is handed a 502 it did not ask for, and the
1029
+ * reason lives on the relay leg, which neither `response` nor `abort` covers.
1030
+ */
1031
+ upstreamFailed(id, error) {
1032
+ const entry = this.entries.get(id) ?? {};
1033
+ this.entries.delete(id);
1034
+ this.failed.add(id);
1035
+ this.counts.requests += 1;
1036
+ this.counts.upstreamErrors += 1;
1037
+ if (this.options.quiet) return;
1038
+ const reason = error.code ?? error.message ?? "unknown error";
1039
+ console.log(` ${pad(entry.method ?? "???", 6)}${pad(truncate(shortTarget(entry.url), 52), 54)}${pc.red("upstream")} ${pc.dim(reason)}`);
1040
+ if (TRUST_ERROR_CODES.has(error.code ?? "") && !this.trustHintShown) {
1041
+ this.trustHintShown = true;
1042
+ this.warn("jean-claude could not verify the real server's certificate. On the way out it trusts the OS certificate store plus NODE_EXTRA_CA_CERTS - if your network intercepts TLS with a CA that is in neither, export that CA and point NODE_EXTRA_CA_CERTS at it before running jean-claude.");
1043
+ }
1044
+ }
1045
+ /**
879
1046
  * A client-side TLS failure almost always means the target pins its
880
1047
  * certificates. Say so, rather than leaving the user to guess.
881
1048
  */
@@ -884,6 +1051,20 @@ var Reporter = class {
884
1051
  const where = hostname ?? "unknown host";
885
1052
  this.warn(`TLS handshake with ${where} failed - the target rejected jean-claude's CA (certificate pinning?). Add "tlsPassthrough: [${where}]" to the config to tunnel it untouched.`);
886
1053
  }
1054
+ /**
1055
+ * What happened, in one line, `undefined` when nothing did.
1056
+ *
1057
+ * This is what a run whose log went to a file gets to say on the terminal, so
1058
+ * a redirect never reads as "jean-claude did nothing".
1059
+ */
1060
+ summary() {
1061
+ const { requests, ruleHits, upstreamErrors } = this.counts;
1062
+ if (requests === 0) return void 0;
1063
+ const parts = [plural(requests, "request")];
1064
+ if (ruleHits > 0) parts.push(`${plural(ruleHits, "rule hit")}`);
1065
+ if (upstreamErrors > 0) parts.push(pc.red(plural(upstreamErrors, "upstream error")));
1066
+ return parts.join(", ");
1067
+ }
887
1068
  banner(lines) {
888
1069
  if (this.options.quiet) return;
889
1070
  const width = Math.max(...lines.map(([key]) => key.length));
@@ -1338,14 +1519,20 @@ function makeBeforeResponse(compiled, ctx) {
1338
1519
  /**
1339
1520
  * Options shared by every passthrough: where to send relayed traffic, and which
1340
1521
  * extra authorities to trust on the way out.
1522
+ *
1523
+ * `additionalTrustedCAs` is not additive on the Node side: mockttp turns it into
1524
+ * an explicit `ca` list (its own bundled roots plus ours), and `ca` *replaces*
1525
+ * the default store. So this has to carry the OS trust store as well as the
1526
+ * corporate CA, or passing a corporate CA would narrow the trust set instead of
1527
+ * widening it.
1341
1528
  */
1342
- function buildConnectionOptions({ upstream, inheritedCa }) {
1529
+ function buildConnectionOptions({ upstream, outboundTrust }) {
1343
1530
  return {
1344
1531
  ...upstream !== void 0 ? { proxyConfig: {
1345
1532
  proxyUrl: upstream.proxyUrl,
1346
1533
  ...upstream.noProxy !== void 0 ? { noProxy: upstream.noProxy } : {}
1347
1534
  } } : {},
1348
- ...inheritedCa !== void 0 ? { additionalTrustedCAs: [{ certPath: inheritedCa }] } : {}
1535
+ ...outboundTrust !== void 0 ? { additionalTrustedCAs: [{ cert: outboundTrust }] } : {}
1349
1536
  };
1350
1537
  }
1351
1538
  async function startProxy(options) {
@@ -1367,6 +1554,10 @@ async function startProxy(options) {
1367
1554
  await proxy.on("response", (response) => reporter.response(response.id, response.statusCode));
1368
1555
  await proxy.on("abort", (request) => reporter.aborted(request.id, request.error?.message));
1369
1556
  await proxy.on("tls-client-error", (failure) => reporter.tlsError(failure.tlsMetadata.sniHostname ?? failure.destination?.hostname));
1557
+ await proxy.on("rule-event", (event) => {
1558
+ if (event.eventType !== "passthrough-abort") return;
1559
+ reporter.upstreamFailed(event.requestId, event.eventData.error);
1560
+ });
1370
1561
  }
1371
1562
  async function registerRules() {
1372
1563
  const ctx = {
@@ -1405,8 +1596,28 @@ async function startProxy(options) {
1405
1596
  stop: () => proxy.stop()
1406
1597
  };
1407
1598
  }
1408
- //#endregion
1409
- //#region src/commands/shared.ts
1599
+ /**
1600
+ * Where jean-claude's own output should go, `undefined` meaning the terminal.
1601
+ *
1602
+ * `--log` wins over the config, which wins over the default. The default is a
1603
+ * file only when a child is about to take the terminal over *and* that terminal
1604
+ * is interactive: piping `run` into something else, or running it in CI, keeps
1605
+ * the log on the stream where it is expected.
1606
+ *
1607
+ * `logFile:` is read only when a child is spawned. `start` exists precisely to
1608
+ * give the log a terminal of its own, so a global `logFile:` silently muting it
1609
+ * would be a trap - there, only an explicit `--log` redirects.
1610
+ */
1611
+ function resolveLogDestination(session, options, cwd = process.cwd()) {
1612
+ if (options.log === "terminal") return void 0;
1613
+ if (options.log !== void 0) return path.resolve(cwd, options.log);
1614
+ if (options.spawnsChild) {
1615
+ const fromConfig = session.loaded.config.logFile;
1616
+ if (fromConfig === "terminal") return void 0;
1617
+ if (fromConfig !== void 0) return resolveFromConfig(session.loaded, fromConfig);
1618
+ if (process.stdout.isTTY === true) return logFileIn(session.home);
1619
+ }
1620
+ }
1410
1621
  /**
1411
1622
  * Brings up everything `run` and `start` need: config, CA, upstream detection,
1412
1623
  * the proxy itself, and optional config hot-reload.
@@ -1424,13 +1635,14 @@ async function openSession(options) {
1424
1635
  dir: caDirIn(home),
1425
1636
  inheritedCa
1426
1637
  });
1638
+ const recorder = options.record !== void 0 ? new Recorder(path.resolve(options.record)) : void 0;
1427
1639
  const proxy = await startProxy({
1428
1640
  loaded,
1429
1641
  ca,
1430
1642
  upstream,
1431
- inheritedCa,
1643
+ outboundTrust: ca.outboundTrust,
1432
1644
  reporter,
1433
- recorder: options.record !== void 0 ? new Recorder(path.resolve(options.record)) : void 0,
1645
+ recorder,
1434
1646
  port: options.port
1435
1647
  });
1436
1648
  const childEnvOptions = {
@@ -1492,10 +1704,15 @@ async function runCommand(command, options) {
1492
1704
  const [file, ...args] = command;
1493
1705
  if (file === void 0) throw new Error("nothing to run: pass the target command after `--`, e.g. `jean-claude run -- npx my-tool`.");
1494
1706
  const session = await openSession(options);
1495
- printBanner(session, [["command", command.join(" ")]]);
1707
+ const logFile = resolveLogDestination(session, {
1708
+ log: options.log,
1709
+ spawnsChild: true
1710
+ });
1711
+ printBanner(session, [...logFile !== void 0 ? [["log", `${logFile} ${pc.dim("(tail -f to follow)")}`]] : [], ["command", command.join(" ")]]);
1496
1712
  const swallow = () => {};
1497
1713
  process.on("SIGINT", swallow);
1498
1714
  process.on("SIGTERM", swallow);
1715
+ const capture = logFile !== void 0 ? await captureOutputTo(logFile, sessionHeader(command.join(" "))) : void 0;
1499
1716
  try {
1500
1717
  return (await execa(file, args, {
1501
1718
  env: buildChildEnv(process.env, {
@@ -1510,6 +1727,12 @@ async function runCommand(command, options) {
1510
1727
  process.off("SIGINT", swallow);
1511
1728
  process.off("SIGTERM", swallow);
1512
1729
  await session.stop();
1730
+ await capture?.close();
1731
+ const summary = session.reporter.summary();
1732
+ if (summary !== void 0 && !options.quiet) {
1733
+ const where = capture !== void 0 ? ` ${pc.dim(`→ ${capture.path}`)}` : "";
1734
+ console.log(`\n ${summary}${where}\n`);
1735
+ }
1513
1736
  }
1514
1737
  }
1515
1738
  //#endregion
@@ -1517,6 +1740,10 @@ async function runCommand(command, options) {
1517
1740
  /** Runs the proxy in the foreground until interrupted, for targets we cannot spawn. */
1518
1741
  async function startCommand(options) {
1519
1742
  const session = await openSession(options);
1743
+ const logFile = resolveLogDestination(session, {
1744
+ log: options.log,
1745
+ spawnsChild: false
1746
+ });
1520
1747
  await writeSessionFile(session.home, {
1521
1748
  proxy: session.proxy.url,
1522
1749
  port: session.proxy.port,
@@ -1539,12 +1766,17 @@ async function startCommand(options) {
1539
1766
  }, null, 2));
1540
1767
  else if (options.export) console.log(formatShellExports(session.env, session.unset));
1541
1768
  else {
1542
- printBanner(session);
1769
+ printBanner(session, logFile !== void 0 ? [["log", logFile]] : []);
1543
1770
  console.log(` ${pc.dim("In the shell that runs your tool:")}\n`);
1544
1771
  console.log(` ${pc.bold("eval \"$(jean-claude env)\"")}\n`);
1545
1772
  console.log(` ${pc.dim("Ctrl-C to stop.")}\n`);
1546
1773
  }
1547
- await waitForInterrupt();
1774
+ const capture = logFile !== void 0 ? await captureOutputTo(logFile, sessionHeader("start")) : void 0;
1775
+ try {
1776
+ await waitForInterrupt();
1777
+ } finally {
1778
+ await capture?.close();
1779
+ }
1548
1780
  } finally {
1549
1781
  await removeSessionFile(session.home);
1550
1782
  await session.stop();
@@ -1577,6 +1809,7 @@ function toSessionOptions(flags) {
1577
1809
  port: flags.port,
1578
1810
  record: flags.record,
1579
1811
  home: flags.home,
1812
+ log: flags.log,
1580
1813
  verbose: flags.verbose ?? false,
1581
1814
  quiet: flags.quiet ?? false,
1582
1815
  watch: flags.watch ?? true
@@ -1584,10 +1817,10 @@ function toSessionOptions(flags) {
1584
1817
  }
1585
1818
  /** Flags common to `run` and `start`. */
1586
1819
  function withSessionFlags(command) {
1587
- return command.option("-c, --config <path>", "path to the config file (default: nearest jean-claude.yaml, then the home one)").option("-p, --port <port>", "port to listen on (default: a free port)", parsePort).option("-r, --record <dir>", "write real responses to this directory, ready to reuse as stubs").option("--home <dir>", HOME_DESCRIPTION).option("-v, --verbose", "also log traffic that matches no rule").option("-q, --quiet", "suppress the per-request log").option("--no-watch", "do not reload the config when it changes");
1820
+ return command.option("-c, --config <path>", "path to the config file (default: nearest jean-claude.yaml, then the home one)").option("-p, --port <port>", "port to listen on (default: a free port)", parsePort).option("-r, --record <dir>", "write real responses to this directory, ready to reuse as stubs").option("--home <dir>", HOME_DESCRIPTION).option("--log <path|terminal>", "where to write jean-claude's own log (default: a file while `run` has a child)").option("-v, --verbose", "also log traffic that matches no rule").option("-q, --quiet", "suppress the per-request log").option("--no-watch", "do not reload the config when it changes");
1588
1821
  }
1589
1822
  const program = new Command();
1590
- program.name("jean-claude").description("MITM HTTPS proxy that rewrites another tool's API traffic, driven by a YAML file.").version("0.1.0");
1823
+ program.name("jean-claude").description("MITM HTTPS proxy that rewrites another tool's API traffic, driven by a YAML file.").version("0.2.0");
1591
1824
  withSessionFlags(program.command("run", { isDefault: true }).description("run a command with its HTTPS traffic intercepted").argument("<command...>", "the command to run, after `--`")).action(async (command, flags) => {
1592
1825
  process.exitCode = await runCommand(command, toSessionOptions(flags));
1593
1826
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@etiennepasteur/jean-claude",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A CLI MITM HTTPS proxy that intercepts and rewrites another tool's API traffic, driven by a YAML file.",
5
5
  "keywords": [
6
6
  "proxy",