@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 +11 -0
- package/README.md +26 -17
- package/package.json +1 -1
- package/src/index.ts +104 -34
- package/src/scan.ts +17 -15
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
|
-
#
|
|
23
|
+
# The last 7 days, straight from the registry
|
|
23
24
|
bunx @drakulavich/zapara@latest
|
|
24
25
|
|
|
25
|
-
#
|
|
26
|
-
bunx @drakulavich/zapara@latest
|
|
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
|
|
73
|
-
TZ=UTC bun src/index.ts
|
|
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` |
|
|
103
|
-
| `zapara
|
|
104
|
-
| `zapara
|
|
105
|
-
| `zapara
|
|
106
|
-
| `zapara
|
|
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
|
-
|
|
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
|
|
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
|
|
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
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 [
|
|
26
|
-
zapara day
|
|
27
|
-
zapara card [
|
|
28
|
-
|
|
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
|
-
|
|
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 =
|
|
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: "
|
|
50
|
-
let days:
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
63
|
-
case "
|
|
64
|
-
case "--
|
|
65
|
-
case "--
|
|
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 "--
|
|
68
|
-
case "--
|
|
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
|
|
139
|
+
if (arg.startsWith("-")) throw new UsageError(`unknown flag${named(arg)}`);
|
|
72
140
|
positional.push(arg);
|
|
73
141
|
}
|
|
74
142
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
else if (
|
|
79
|
-
else
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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.
|
|
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${
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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
|
|
21
|
-
// that directory. A symlink is not followed into: Claude Code never
|
|
22
|
-
// following it is how a loop or a stray link to $HOME would get in.
|
|
23
|
-
async function
|
|
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
|
|
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);
|