@drakulavich/zapara 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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ All notable changes to this project are documented here. The format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.2.0] - 2026-09-18
9
+
10
+ ### Changed
11
+ - The command line is the window and the view, not names for windows: `zapara` is the grid of the last 7 days, `zapara --days 30`, `zapara --to 2026-09-14` or `zapara --from 2026-09-01 --to 2026-09-14` any window up to 90 days; `zapara today`, `zapara yesterday` or `zapara 2026-09-14` one day, hour by hour; `zapara card` the picture, with the same window flags. `week` and `day` are gone. A date is `YYYY-MM-DD`, `today` or `yesterday` everywhere; `--days=30` works beside `--days 30`; `-V` beside `--version`.
12
+ - A usage error prints its line and `run 'zapara --help' for usage`, exit 2, instead of the whole usage screen; `--help` is one screen under 80 columns.
13
+
14
+ ### Fixed
15
+ - `--days -1` is answered by the range message (`--days must be 1..90, got -1`) instead of `--days needs a value`: a negative number is a value, not a flag.
16
+ - A projects directory that exists but cannot be read says so (`projects directory cannot be read (check its permissions)`, exit 1) instead of `not found`; the message still names no path.
17
+ - The README's screencast and card show on npmjs.com: both images use absolute raw.githubusercontent.com URLs, and the demo files left Git LFS, whose objects the raw endpoint serves as pointer text.
18
+
8
19
  ## [0.1.0] - 2026-09-18
9
20
 
10
21
  ### Added
package/README.md CHANGED
@@ -3,12 +3,13 @@
3
3
  <p align="center">
4
4
  <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
5
5
  <a href="https://bun.sh"><img src="https://img.shields.io/badge/runtime-Bun-f9f1e1?logo=bun" alt="Bun"></a>
6
+ <a href="https://www.npmjs.com/package/@drakulavich/zapara"><img src="https://img.shields.io/npm/v/@drakulavich/zapara?logo=npm&color=cb3837" alt="npm"></a>
6
7
  </p>
7
8
 
8
9
  <p align="center"><b>How hard was today?</b> zapara reads the transcripts Claude Code already writes on your machine and scores every hour 0–100 from parallel sessions, prompt pace, agent supervision, model output, streak length and late-night work. Nothing leaves the machine, no message text is kept.</p>
9
10
 
10
11
  <p align="center">
11
- <img src="assets/demo.webp" alt="zapara demo: week heatmap, day table, JSON" width="800">
12
+ <img src="https://raw.githubusercontent.com/drakulavich/zapara/main/assets/demo.webp" alt="zapara demo: week heatmap, day table, JSON" width="800">
12
13
  </p>
13
14
 
14
15
  Claude Code writes a JSONL transcript for every session under `~/.claude/projects`. zapara reads those files, puts each record in the local hour it happened in, and turns the hour into one number. A week is a heatmap of seven rows by 24 cells; a day is a table with one row per hour and, with `--explain`, the weighted contribution of each component. Nothing is installed into Claude Code, no hook is registered, and no network call is made.
@@ -19,11 +20,11 @@ Claude Code writes a JSONL transcript for every session under `~/.claude/project
19
20
  # Install Bun if you do not have it (zapara needs 1.4 or newer)
20
21
  curl -fsSL https://bun.sh/install | bash
21
22
 
22
- # Your week, straight from the registry
23
+ # The last 7 days, straight from the registry
23
24
  bunx @drakulavich/zapara@latest
24
25
 
25
- # A day, with the components behind each index
26
- bunx @drakulavich/zapara@latest day --explain
26
+ # Yesterday, hour by hour, with the components behind each index
27
+ bunx @drakulavich/zapara@latest yesterday --explain
27
28
 
28
29
  # The card
29
30
  bunx @drakulavich/zapara@latest card
@@ -69,8 +70,8 @@ hour index level sess prompts intr rej quest plan mode ctx-sw stre
69
70
  Two commands in a terminal reproduce them. The fixture's timestamps are UTC and zapara buckets by local time, so pin the zone to get these exact hours:
70
71
 
71
72
  ```bash
72
- TZ=UTC bun src/index.ts week --projects tests/fixtures/busy-week --to 2026-09-20 --no-color
73
- TZ=UTC bun src/index.ts day 2026-09-14 --projects tests/fixtures/busy-week --explain --no-color
73
+ TZ=UTC bun src/index.ts --projects tests/fixtures/busy-week --to 2026-09-20 --no-color
74
+ TZ=UTC bun src/index.ts 2026-09-14 --projects tests/fixtures/busy-week --explain --no-color
74
75
  ```
75
76
 
76
77
  The grid is a fixed 98 columns wide, 100 with its hour header, and does not reflow, so it needs a terminal at least that wide.
@@ -86,12 +87,18 @@ zapara card # writes zapara-card.png in the current directory
86
87
  zapara card --out card.webp # WebP instead; --out card.html writes the page itself
87
88
  ```
88
89
 
90
+ On macOS, one more command puts the picture on the clipboard, ready to paste into a chat:
91
+
92
+ ```bash
93
+ zapara card && osascript -e 'set the clipboard to (read (POSIX file "zapara-card.png") as «class PNGf»)'
94
+ ```
95
+
89
96
  ```
90
97
  The Marathoner: Longest streak 7h53m without a break, 68% of your hours calm.
91
98
  wrote zapara-card.png
92
99
  ```
93
100
 
94
- <p align="center"><img src="assets/card.webp" alt="zapara card: The Marathoner, longest streak 7h53m, 68% of hours calm" width="800"></p>
101
+ <p align="center"><img src="https://raw.githubusercontent.com/drakulavich/zapara/main/assets/card.webp" alt="zapara card: The Marathoner, longest streak 7h53m, 68% of hours calm" width="800"></p>
95
102
 
96
103
  This one comes from the same `busy-week` fixture as the pictures above. The picture is taken by a headless browser that Bun drives: WebKit on macOS, an installed Google Chrome elsewhere, so on Linux or Windows install Chrome, or write `--out card.html` and open the page in any browser.
97
104
 
@@ -99,28 +106,30 @@ This one comes from the same `busy-week` fixture as the pictures above. The pict
99
106
 
100
107
  | Command | What it does |
101
108
  |---|---|
102
- | `zapara` | Same as `zapara week`. |
103
- | `zapara week` | The last 7 days ending today, in local time. |
104
- | `zapara week --days 14 --to 2026-09-17` | Any window. `--days` takes an integer from 1 to 90. |
105
- | `zapara day` | Today, one row per hour that had activity. |
106
- | `zapara day 2026-09-14 --explain` | One day, with the six weighted components behind each index. |
109
+ | `zapara` | The last 7 days ending today, one cell per hour, in local time. |
110
+ | `zapara --days 30` | The last 30 days. `--days` takes an integer from 1 to 90. |
111
+ | `zapara --from 2026-09-01 --to 2026-09-14` | Any window, both days inclusive, at most 90 days. `--to` alone is 7 days ending there, `--days 30 --to 2026-09-14` is 30 days ending there. |
112
+ | `zapara today` | Today, one row per hour that had activity. `yesterday` likewise. |
113
+ | `zapara 2026-09-14 --explain` | One day, with the six weighted components behind each index. |
107
114
  | `zapara card` | The last 14 days as one shareable picture, `zapara-card.png` in the current directory. |
108
115
  | `zapara card --days 30 --out me.webp` | Any window from 1 to 90 days; `.png`, `.webp` or `.html` by extension. `--json` prints the card's data instead. |
109
116
 
110
117
  | Flag | What it does |
111
118
  |---|---|
119
+ | `--days <N>`, `--from <date>`, `--to <date>` | The window. A date is `YYYY-MM-DD`, `today` or `yesterday`. `--days=30` works as well as `--days 30`. |
120
+ | `--explain` | With a day: the six weighted parts behind each index. |
112
121
  | `--json` | Print the whole window as one JSON document instead of a table. |
113
122
  | `--projects <dir>` | Read this directory instead of `~/.claude/projects`. |
114
123
  | `--out <path>` | Where `card` writes; the extension picks the format. |
115
124
  | `--no-color` | Plain glyphs with no ANSI codes. `NO_COLOR` in the environment does the same. |
116
- | `--help` | Usage, exit 0. |
117
- | `--version` | The version from `package.json`, exit 0. |
125
+ | `-h`, `--help` | Usage, exit 0. |
126
+ | `-V`, `--version` | The version from `package.json`, exit 0. |
118
127
 
119
128
  Levels: calm 0–29, warming 30–59, heating 60–84, fried 85–100.
120
129
 
121
- `week` and `day` print a text table when stdout is a terminal and JSON otherwise, so `zapara week | cat` prints JSON; there is no flag to force text in a pipe yet. `card` always writes its file and prints its two lines, piped or not, and only `card --json` prints JSON.
130
+ The grid and the day print a text table when stdout is a terminal and JSON otherwise, so `zapara | cat` prints JSON; there is no flag to force text in a pipe yet. `card` always writes its file and prints its two lines, piped or not, and only `card --json` prints JSON.
122
131
 
123
- Exit codes are 0 on success, 1 when the projects directory cannot be read, and 2 for a usage error such as a bad date or an unknown flag. A window with no data prints an empty grid and exits 0.
132
+ Exit codes are 0 on success, 1 when the projects directory is missing or cannot be read (two different messages, neither with a path), and 2 for a usage error such as a bad date or an unknown flag, which prints one line and a hint to `--help`. A window with no data prints an empty grid and exits 0.
124
133
 
125
134
  ## Privacy
126
135
 
@@ -136,7 +145,7 @@ No message text, prompt length, file path or session title is kept, written or p
136
145
  - The transcript format is Claude Code's private format, built against version 2.1.274, and it may drift. `bun run stats` shows when it has.
137
146
  - The norms come from two machines of one user working in auto mode. They are a starting point for a conversation about the metric, not a study.
138
147
  - The 98-column grid does not adapt to a narrow terminal.
139
- - For `week` and `day` a pipe always gets JSON, and there is no flag to ask for text instead.
148
+ - For the grid and the day a pipe always gets JSON, and there is no flag to ask for text instead.
140
149
  - The card needs a browser engine: WebKit comes with macOS, elsewhere Google Chrome must be installed. `--out card.html` works everywhere.
141
150
 
142
151
  ## Under the hood
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/zapara",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Cognitive load index for people driving Claude Code, computed locally from transcripts",
5
5
  "license": "MIT",
6
6
  "author": "Anton Yakutovich",
package/src/index.ts CHANGED
@@ -22,13 +22,28 @@ function version(): string {
22
22
  throw new Error("package.json has no version");
23
23
  }
24
24
 
25
- const USAGE = `usage: zapara [week] [--days N] [--to YYYY-MM-DD]
26
- zapara day [YYYY-MM-DD] [--explain]
27
- zapara card [--days N] [--to YYYY-MM-DD] [--out PATH.png|.webp|.html]
28
- flags: --json --projects <dir> --no-color --help --version
25
+ const USAGE = `usage: zapara [window] the last 7 days, one cell per hour
26
+ zapara today|yesterday|<date> one day, one row per active hour
27
+ zapara card [window] [--out] the last 14 days as one picture
28
+
29
+ window:
30
+ --days <N> the last N days, 1..90; with --to, N days ending there
31
+ --from <date> first day; --to <date> last day, default today
32
+ a date is YYYY-MM-DD, today or yesterday
33
+
34
+ options:
35
+ --explain with a day: the six weighted parts behind each index
36
+ --out <path> with card: .png, .webp or .html (default zapara-card.png)
37
+ --json the same data as JSON; a pipe gets JSON without asking
38
+ --projects <dir> read this directory instead of ~/.claude/projects
39
+ --no-color no ANSI colors; NO_COLOR does the same
40
+ -h, --help -V, --version
41
+
29
42
  levels: calm 0-29 warming 30-59 heating 60-84 fried 85-100`;
43
+ const HINT = "run 'zapara --help' for usage";
30
44
 
31
- type Args = { command: "week" | "day" | "card"; to: string; days: number; date: string | null; explain: boolean; json: boolean; out: string; projects: string; color: boolean };
45
+ // A grid is the window as one cell per hour; a day is one date as one row per hour.
46
+ type Args = { command: "grid" | "day" | "card"; to: string; days: number; explain: boolean; json: boolean; out: string; projects: string; color: boolean };
32
47
 
33
48
  class UsageError extends Error {}
34
49
  // Thrown only at a flag position (never when a token was consumed as another
@@ -37,58 +52,115 @@ class UsageError extends Error {}
37
52
  class HelpRequested extends Error {}
38
53
  class VersionRequested extends Error {}
39
54
 
55
+ const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
56
+
57
+ // A usage error quotes the offending value only when it is short, printable ASCII
58
+ // with no path separator: the CLI never prints a filesystem path or an escape,
59
+ // not even one the person typed.
60
+ const quotable = (v: string): boolean => /^[\x21-\x7e]{1,24}$/.test(v) && !/[\/\\]/.test(v);
61
+ const got = (v: string): string => (quotable(v) ? `, got ${v}` : "");
62
+ const named = (v: string): string => (quotable(v) ? ` ${v}` : "");
63
+
40
64
  function validDate(s: string): boolean {
41
- const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
65
+ const m = DATE.exec(s);
42
66
  if (!m) return false;
43
67
  const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
44
68
  const dt = new Date(y, mo - 1, d);
45
69
  return dt.getFullYear() === y && dt.getMonth() === mo - 1 && dt.getDate() === d;
46
70
  }
47
71
 
72
+ // A date on the command line: YYYY-MM-DD, today or yesterday, in local time.
73
+ function resolveDate(what: string, s: string, now: Date): string {
74
+ if (s === "today") return localDate(now);
75
+ if (s === "yesterday") return localDate(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1));
76
+ if (!validDate(s)) throw new UsageError(`${what} must be YYYY-MM-DD, today or yesterday${got(s)}`);
77
+ return s;
78
+ }
79
+
80
+ // Calendar days from one date to another, inclusive; UTC arithmetic so a DST day is still one day.
81
+ function spanDays(from: string, to: string): number {
82
+ const utc = (s: string): number => { const [y = 0, m = 0, d = 0] = s.split("-").map(Number); return Date.UTC(y, m - 1, d); };
83
+ return Math.round((utc(to) - utc(from)) / 86_400_000) + 1;
84
+ }
85
+
86
+ // The window from its flags: --days ending today or at --to, or --from/--to, both
87
+ // inclusive. --from with --days is one length too many. Checks run in the order
88
+ // a reader meets the flags in --help.
89
+ function windowOf(days: string | null, from: string | null, to: string | null, defaultDays: number, now: Date): { to: string; days: number } {
90
+ if (from !== null && days !== null) throw new UsageError("--from sets the length; drop --days");
91
+ if (days !== null && (!/^\d+$/.test(days) || Number(days) < 1 || Number(days) > 90)) throw new UsageError(`--days must be 1..90${got(days)}`);
92
+ const last = to === null ? localDate(now) : resolveDate("--to", to, now);
93
+ if (from === null) return { to: last, days: days === null ? defaultDays : Number(days) };
94
+ const first = resolveDate("--from", from, now);
95
+ const span = spanDays(first, last);
96
+ if (span < 1) throw new UsageError(`--from ${first} is after --to ${last}`);
97
+ if (span > 90) throw new UsageError(`--from ${first} to ${last} is ${span} days; the most is 90`);
98
+ return { to: last, days: span };
99
+ }
100
+
48
101
  function parseArgs(argv: string[], now: Date, env: NodeJS.ProcessEnv, isTTY: boolean): Args {
49
- const a: Args = { command: "week", to: localDate(now), days: 7, date: null, explain: false, json: false, out: "zapara-card.png", projects: join(homedir(), ".claude", "projects"), color: isTTY && !env.NO_COLOR };
50
- let days: number | null = null;
102
+ const a: Args = { command: "grid", to: localDate(now), days: 7, explain: false, json: false, out: "zapara-card.png", projects: join(homedir(), ".claude", "projects"), color: isTTY && !env.NO_COLOR };
103
+ let days: string | null = null;
104
+ let from: string | null = null;
105
+ let to: string | null = null;
51
106
  let jsonFlag = false;
52
107
  let outGiven = false;
53
108
  const positional: string[] = [];
54
109
  for (let i = 0; i < argv.length; i++) {
55
- const arg = argv[i]!;
110
+ let arg = argv[i]!;
111
+ // `--days=30` is `--days 30`. A flag that takes no value refuses an inline one.
112
+ let inline: string | null = null;
113
+ const eq = arg.startsWith("--") ? arg.indexOf("=") : -1;
114
+ if (eq > 0) { inline = arg.slice(eq + 1); arg = arg.slice(0, eq); }
115
+ const bare = (): void => { if (inline !== null) throw new UsageError(`unknown flag${named(argv[i]!)}`); };
56
116
  // A missing value or one that looks like another flag is a usage error,
57
- // never treated as this flag's value (e.g. `--projects --json`).
58
- const value = (): string => { const v = argv[++i]; if (v === undefined || v.startsWith("-")) throw new UsageError(`${arg} needs a value`); return v; };
117
+ // never treated as this flag's value (e.g. `--projects --json`). Only --days
118
+ // takes a negative number as a value, so `--days -1` reaches the range check.
119
+ const value = (negativeNumberIsValue = false): string => {
120
+ if (inline !== null) return inline;
121
+ const v = argv[++i];
122
+ if (v === undefined || (v.startsWith("-") && !(negativeNumberIsValue && /^-\d/.test(v)))) throw new UsageError(`${arg} needs a value`);
123
+ return v;
124
+ };
59
125
  switch (arg) {
60
126
  case "--help":
61
- case "-h": throw new HelpRequested();
62
- case "--version": throw new VersionRequested();
63
- case "--json": jsonFlag = true; break;
64
- case "--explain": a.explain = true; break;
65
- case "--no-color": a.color = false; break;
127
+ case "-h": bare(); throw new HelpRequested();
128
+ case "--version":
129
+ case "-V": bare(); throw new VersionRequested();
130
+ case "--json": bare(); jsonFlag = true; break;
131
+ case "--explain": bare(); a.explain = true; break;
132
+ case "--no-color": bare(); a.color = false; break;
66
133
  case "--projects": a.projects = value(); break;
67
- case "--to": a.to = value(); break;
68
- case "--days": { const v = value(); if (!/^\d+$/.test(v) || Number(v) < 1 || Number(v) > 90) throw new UsageError(`--days must be 1..90, got ${v}`); days = Number(v); break; }
134
+ case "--from": from = value(); break;
135
+ case "--to": to = value(); break;
136
+ case "--days": days = value(true); break;
69
137
  case "--out": a.out = value(); outGiven = true; break;
70
138
  default:
71
- if (arg.startsWith("-")) throw new UsageError(`unknown flag ${arg}`);
139
+ if (arg.startsWith("-")) throw new UsageError(`unknown flag${named(arg)}`);
72
140
  positional.push(arg);
73
141
  }
74
142
  }
75
- const [cmd, ...rest] = positional;
76
- if (cmd === undefined || cmd === "week") { if (rest.length) throw new UsageError(`unexpected argument ${rest[0]}`); a.command = "week"; }
77
- else if (cmd === "day") { a.command = "day"; a.date = rest[0] ?? a.to; if (rest.length > 1) throw new UsageError(`unexpected argument ${rest[1]}`); }
78
- else if (cmd === "card") { a.command = "card"; if (rest.length) throw new UsageError(`unexpected argument ${rest[0]}`); }
79
- else throw new UsageError(`unknown command ${cmd}`);
80
- // Two weeks make a pattern; a week makes a picture of one week.
81
- a.days = days ?? (a.command === "card" ? 14 : 7);
143
+ if (positional.length > 1) throw new UsageError(`unexpected argument${named(positional[1]!)}`);
144
+ const [word] = positional;
145
+ if (word === undefined) a.command = "grid";
146
+ else if (word === "card") a.command = "card";
147
+ else if (word === "today" || word === "yesterday" || DATE.test(word)) { a.command = "day"; a.to = resolveDate("date", word, now); a.days = 1; }
148
+ else throw new UsageError(`unknown command${named(word)} (try today, yesterday, a date or card)`);
149
+
150
+ if (a.command === "day") {
151
+ if (days !== null || from !== null || to !== null) throw new UsageError("--days, --from and --to do not apply to a named day");
152
+ } else {
153
+ // Two weeks make a pattern; a week makes a picture of one week.
154
+ ({ to: a.to, days: a.days } = windowOf(days, from, to, a.command === "card" ? 14 : 7, now));
155
+ }
82
156
  // Tables turn into JSON in a pipe; the card is a file either way, so only an explicit --json switches it.
83
157
  a.json = a.command === "card" ? jsonFlag : jsonFlag || !isTTY;
84
- if (a.command !== "day" && a.explain) throw new UsageError("--explain applies to day only");
158
+ if (a.command !== "day" && a.explain) throw new UsageError("--explain applies to a named day only");
85
159
  if (a.command !== "card" && outGiven) throw new UsageError("--out applies to card only");
86
- // The value is printed back verbatim in `wrote …`, so it must be one plain line:
160
+ // The value is printed back verbatim in `wrote \u2026`, so it must be one plain line:
87
161
  // no control character, and the message never quotes it.
88
162
  if (/[\x00-\x1f\x7f]/.test(a.out)) throw new UsageError("--out must not contain control characters");
89
163
  if (!/\.(png|webp|html)$/i.test(a.out)) throw new UsageError("--out must end in .png, .webp or .html");
90
- if (!validDate(a.to)) throw new UsageError(`--to must be YYYY-MM-DD, got ${a.to}`);
91
- if (a.date !== null && !validDate(a.date)) throw new UsageError(`date must be YYYY-MM-DD, got ${a.date}`);
92
164
  return a;
93
165
  }
94
166
 
@@ -96,9 +168,7 @@ async function main(): Promise<number> {
96
168
  const argv = process.argv.slice(2);
97
169
  const a = parseArgs(argv, new Date(), process.env, process.stdout.isTTY === true);
98
170
  if (a.command === "card") return card(a);
99
- const days: Day[] = a.command === "day"
100
- ? await report({ projects: a.projects, to: a.date!, days: 1 })
101
- : await report({ projects: a.projects, to: a.to, days: a.days });
171
+ const days: Day[] = await report({ projects: a.projects, to: a.to, days: a.days });
102
172
  const data = a.command === "day" ? days[0] : days;
103
173
  if (a.json) console.log(renderJson(data!));
104
174
  else if (a.command === "day") console.log(renderDay(days[0]!, { explain: a.explain, color: a.color }));
@@ -134,7 +204,7 @@ if (import.meta.main) {
134
204
  catch (err) { console.error(`zapara: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); }
135
205
  }
136
206
  const msg = e instanceof Error ? e.message : String(e);
137
- if (e instanceof UsageError) { console.error(`zapara: ${msg}\n${USAGE}`); process.exit(2); }
207
+ if (e instanceof UsageError) { console.error(`zapara: ${msg}\n${HINT}`); process.exit(2); }
138
208
  console.error(`zapara: ${msg}`);
139
209
  process.exit(1);
140
210
  });
package/src/scan.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Dirent } from "node:fs";
1
2
  import { readdir, stat } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
 
@@ -5,32 +6,33 @@ import { join } from "node:path";
5
6
  // directory and are the parent agent's conversation, not the human's; a file whose mtime
6
7
  // is before the cutoff cannot hold events the report needs.
7
8
  export async function scan(projects: string, cutoffMs: number): Promise<string[]> {
9
+ // No path in either message: it may be a value the user typed, or the
10
+ // homedir-derived default, and the CLI must never print a filesystem path.
11
+ let root;
8
12
  try {
9
13
  if (!(await stat(projects)).isDirectory()) throw new Error("not a directory");
10
- } catch {
11
- // No path in the message: it may be a value the user typed, or the
12
- // homedir-derived default, and the CLI must never print a filesystem path.
14
+ root = await readdir(projects, { withFileTypes: true });
15
+ } catch (err) {
16
+ const code = (err as { code?: string }).code;
17
+ if (code === "EACCES" || code === "EPERM") throw new Error("projects directory cannot be read (check its permissions)");
13
18
  throw new Error("projects directory not found (pass --projects <dir>)");
14
19
  }
15
20
  const out: string[] = [];
16
- await walk(projects, cutoffMs, out);
21
+ await collect(projects, root, cutoffMs, out);
17
22
  return out.sort();
18
23
  }
19
24
 
20
- // One directory at a time, so one unreadable directory or a symlink loop costs only
21
- // that directory. A symlink is not followed into: Claude Code never writes one, and
22
- // following it is how a loop or a stray link to $HOME would get in.
23
- async function walk(dir: string, cutoffMs: number, out: string[]): Promise<void> {
24
- let entries;
25
- try {
26
- entries = await readdir(dir, { withFileTypes: true });
27
- } catch {
28
- return;
29
- }
25
+ // One directory at a time, so one unreadable directory or a symlink loop below the
26
+ // root costs only that directory. A symlink is not followed into: Claude Code never
27
+ // writes one, and following it is how a loop or a stray link to $HOME would get in.
28
+ async function collect(dir: string, entries: Dirent[], cutoffMs: number, out: string[]): Promise<void> {
30
29
  for (const entry of entries) {
31
30
  const full = join(dir, entry.name);
32
31
  if (entry.isDirectory()) {
33
- if (entry.name !== "subagents") await walk(full, cutoffMs, out);
32
+ if (entry.name === "subagents") continue;
33
+ try {
34
+ await collect(full, await readdir(full, { withFileTypes: true }), cutoffMs, out);
35
+ } catch {}
34
36
  } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
35
37
  try {
36
38
  if ((await stat(full)).mtimeMs >= cutoffMs) out.push(full);