@iinm/plain-agent 1.7.19 → 1.7.20

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/README.md CHANGED
@@ -1,10 +1,6 @@
1
- <p align="center">
2
- <img src="https://pub-0bb49aa929f242d49c89ed8c297932b5.r2.dev/plain-agent/plain-agent-logo.png" alt="plain-agent logo" width="320">
3
- </p>
4
-
5
1
  # Plain Agent
6
2
 
7
- A lightweight CLI-based coding agent with zero framework dependencies.
3
+ A lightweight CLI-based coding agent.
8
4
 
9
5
  ## Why Plain Agent?
10
6
 
@@ -23,6 +19,7 @@ A lightweight CLI-based coding agent with zero framework dependencies.
23
19
 
24
20
  ## Limitations
25
21
 
22
+ - **CLI only** — Plain Agent does not provide a terminal UI.
26
23
  - **Sequential subagent execution** — Subagents run one at a time rather than
27
24
  in parallel. The trade-off is full visibility: every step is streamed to
28
25
  your terminal so you can follow exactly what each subagent is doing.
@@ -31,7 +28,7 @@ A lightweight CLI-based coding agent with zero framework dependencies.
31
28
 
32
29
  - Node.js 22 or later
33
30
  - LLM provider credentials
34
- - bash / docker for sandboxed execution
31
+ - Bash / Docker for sandboxed execution
35
32
  - [ripgrep](https://github.com/burntsushi/ripgrep)
36
33
  - [fd](https://github.com/sharkdp/fd)
37
34
 
@@ -52,8 +49,8 @@ Create the configuration.
52
49
  ```js
53
50
  // ~/.config/plain-agent/config.local.json
54
51
  {
55
- "model": "gpt-5.4+thinking-high",
56
- // "model": "claude-sonnet-4-6+thinking-high",
52
+ "model": "claude-sonnet-4-6+thinking-high",
53
+ // "model": "gpt-5.5+thinking-high",
57
54
 
58
55
  // Configure the providers you want to use
59
56
  "platforms": [
@@ -83,15 +80,11 @@ Create the configuration.
83
80
  "provider": "gemini",
84
81
  "apiKey": "<GEMINI_API_KEY>",
85
82
  "model": "gemini-3-flash-preview"
86
- // Optional
87
- // "baseURL": "<proxy_url>"
88
83
 
89
84
  // Or use Vertex AI (Requires gcloud CLI to get authentication token)
90
85
  // "provider": "gemini-vertex-ai",
91
86
  // "baseURL": "https://aiplatform.googleapis.com/v1beta1/projects/<project_id>/locations/<location>",
92
87
  // "model": "gemini-3-flash-preview"
93
- // Optional:
94
- // "account": "<service_account_email>"
95
88
  },
96
89
 
97
90
  // askURL: Answers questions based on provided URL content.
@@ -100,15 +93,8 @@ Create the configuration.
100
93
  "provider": "gemini",
101
94
  "apiKey": "<GEMINI_API_KEY>"
102
95
  "model": "gemini-3-flash-preview"
103
- // Optional
104
- // "baseURL": "<proxy_url>"
105
96
 
106
97
  // Or use Vertex AI (Requires gcloud CLI to get authentication token)
107
- // "provider": "gemini-vertex-ai",
108
- // "baseURL": "https://aiplatform.googleapis.com/v1beta1/projects/<project_id>/locations/<location>",
109
- // "model": "gemini-3-flash-preview"
110
- // Optional:
111
- // "account": "<service_account_email>"
112
98
  }
113
99
  },
114
100
 
@@ -166,6 +152,7 @@ Create the configuration.
166
152
  ```
167
153
 
168
154
  ```js
155
+ // OpenAI-compatible provider (Ollama) example with a custom model
169
156
  {
170
157
  "platforms": [
171
158
  {
@@ -279,6 +266,9 @@ plain
279
266
  plain -m <model+variant>
280
267
  ```
281
268
 
269
+ Interrupt the agent while it's running:
270
+ Press **Ctrl-C** to pause auto-approve. The agent will finish the current tool call, then return to the prompt.
271
+
282
272
  (Optional) Set up a sandbox for your project with the `sandbox-configurator` agent.
283
273
 
284
274
  ```
@@ -292,7 +282,7 @@ After the agent finishes, run the generated setup script once to build the sandb
292
282
  ```
293
283
 
294
284
  Run in batch mode (non-interactive).
295
- In batch mode, config files are not loaded automatically. Only the files specified with `--config` are loaded.
285
+ In batch mode, config files are not loaded automatically. Only the files specified with `-c` are loaded.
296
286
 
297
287
  ```sh
298
288
  plain batch \
@@ -307,21 +297,15 @@ Display the help message.
307
297
  /help
308
298
  ```
309
299
 
310
- Show aggregated token cost per day across sessions.
311
- Each finished session appends a record to `~/.local/share/plain-agent/usage.jsonl`,
312
- and `plain cost` reads that log. The period defaults to the first day of the
313
- current month through today; override it with `--from` / `--to`. Multiple
314
- currencies (e.g., USD and JPY) are aggregated separately.
300
+ Show daily token costs across sessions. `plain cost` reads
301
+ `~/.local/share/plain-agent/usage.jsonl`; use `--from` / `--to` to set the
302
+ period. Currencies are shown separately.
315
303
 
316
304
  ```sh
317
305
  plain cost
318
306
  plain cost --from 2026-04-01 --to 2026-04-30
319
307
  ```
320
308
 
321
- Interrupt the agent while it's running:
322
-
323
- Press **Ctrl-C** to pause auto-approve. The agent will finish the current tool call, then return to the prompt.
324
-
325
309
  ## Available Tools
326
310
 
327
311
  The agent can use the following tools to assist with tasks:
@@ -338,29 +322,24 @@ The agent can use the following tools to assist with tasks:
338
322
 
339
323
  ## Configuration
340
324
 
325
+ Files are loaded in the following order. Settings in later files override earlier ones.
326
+
341
327
  ```
342
328
  ~/.config/plain-agent/
343
- \__ config.json # User configuration
344
- \__ config.local.json # User local configuration (including secrets)
345
- \__ prompts/ # Global/User-defined prompts
346
- \__ agents/ # Global/User-defined agent roles
329
+ ├── (1) config.json # User configuration
330
+ ├── (2) config.local.json # User local configuration (including secrets)
331
+ ├── prompts/ # Global/User-defined prompts
332
+ └── agents/ # Global/User-defined agent roles
347
333
 
348
334
  <project-root>
349
- \__ .plain-agent/
350
- \__ config.json # Project-specific configuration
351
- \__ config.local.json # Project-specific local configuration (including secrets)
352
- \__ memory/ # Task-specific memory files
353
- \__ prompts/ # Project-specific prompts
354
- \__ agents/ # Project-specific agent roles
335
+ └── .plain-agent/
336
+ ├── (3) config.json # Project-specific configuration
337
+ ├── (4) config.local.json # Project-specific local configuration (including secrets)
338
+ ├── memory/ # Task-specific memory files
339
+ ├── prompts/ # Project-specific prompts
340
+ └── agents/ # Project-specific agent roles
355
341
  ```
356
342
 
357
- The agent loads configuration files in the following order. Settings in later files will override those in earlier files.
358
-
359
- - `~/.config/plain-agent/config.json`
360
- - `~/.config/plain-agent/config.local.json`
361
- - `.plain-agent/config.json`
362
- - `.plain-agent/config.local.json`
363
-
364
343
  ### Example
365
344
 
366
345
  <details>
@@ -423,19 +402,19 @@ The agent loads configuration files in the following order. Settings in later fi
423
402
  "patterns": [
424
403
  {
425
404
  "toolName": { "$regex": "^(write_file|patch_file)$" },
426
- "input": { "filePath": { "$regex": "^(\\./)?\\.plain-agent/memory/.+\\.md$" } },
405
+ "input": { "filePath": { "$regex": "^\\.plain-agent/memory/.+\\.md$" } },
427
406
  "action": "allow"
428
407
  },
429
408
  {
430
409
  "toolName": { "$regex": "^(write_file|patch_file)$" },
431
- "input": { "filePath": { "$regex": "^(\\./)?src/" } },
410
+ "input": { "filePath": { "$regex": "^src/" } },
432
411
  "action": "allow"
433
412
  },
434
413
 
435
414
  // ⚠️ Arbitrary code execution can access unauthorized files and networks. Always use a sandbox.
436
415
  {
437
416
  "toolName": "exec_command",
438
- "input": { "command": "npm", "args": ["run", { "$regex": "^(check|test|lint|fix)$" }] },
417
+ "input": { "command": "npm", "args": ["run", { "$regex": "^(lint|test)$" }] },
439
418
  "action": "allow"
440
419
  },
441
420
 
@@ -511,8 +490,8 @@ The agent loads configuration files in the following order. Settings in later fi
511
490
  }
512
491
  },
513
492
 
514
- // Override default notification command
515
- // "notifyCmd": "/path/to/notification-command"
493
+ // Override default notification command (falls back to terminal bell)
494
+ // "notifyCmd": { "command": "plain-notify-desktop", "args": [] }
516
495
 
517
496
  // (Optional) Voice input. See "Voice Input" below.
518
497
  // "voiceInput": {
@@ -527,6 +506,17 @@ The agent loads configuration files in the following order. Settings in later fi
527
506
 
528
507
  You can define reusable prompts in Markdown files.
529
508
 
509
+ ### Locations
510
+
511
+ The agent searches for prompts in the following directories:
512
+
513
+ - `~/.config/plain-agent/prompts/`
514
+ - `.plain-agent/prompts/`
515
+ - `.claude/commands/`
516
+ - `.claude/skills/`
517
+
518
+ The prompt ID is the relative path of the file without the `.md` extension. For example, `.plain-agent/prompts/commit.md` becomes `/prompts:commit`.
519
+
530
520
  ### Prompt File Format
531
521
 
532
522
  ```md
@@ -548,30 +538,8 @@ import: https://raw.githubusercontent.com/anthropics/claude-code/5cff78741f54a0d
548
538
  - Parallel execution of subagents is not supported. Delegate to subagents sequentially.
549
539
  ```
550
540
 
551
- ```md
552
- ---
553
- import: https://raw.githubusercontent.com/anthropics/claude-code/db8834ba1d72e9a26fba30ac85f3bc4316bb0689/plugins/code-review/commands/code-review.md
554
- ---
555
-
556
- - Parallel execution of subagents is not supported. Delegate to subagents sequentially.
557
- - If CLAUDE.md is not found, refer to AGENTS.md instead for project rules and conventions.
558
- - If the PR branch is already checked out, review changes from local files instead of fetching from GitHub.
559
- - After explaining the review results to the user, ask whether to post the comments to GitHub as well.
560
- ```
561
-
562
541
  Remote prompts are fetched and cached locally. The local content will be appended to the imported content.
563
542
 
564
- ### Locations
565
-
566
- The agent searches for prompts in the following directories:
567
-
568
- - `~/.config/plain-agent/prompts/`
569
- - `.plain-agent/prompts/`
570
- - `.claude/commands/`
571
- - `.claude/skills/`
572
-
573
- The prompt ID is the relative path of the file without the `.md` extension. For example, `.plain-agent/prompts/commit.md` becomes `/prompts:commit`.
574
-
575
543
  ### Shortcuts
576
544
 
577
545
  Prompts located in a `shortcuts/` subdirectory (e.g., `.plain-agent/prompts/shortcuts/commit.md`) can be invoked directly as a top-level command (e.g., `/commit`).
@@ -580,6 +548,14 @@ Prompts located in a `shortcuts/` subdirectory (e.g., `.plain-agent/prompts/shor
580
548
 
581
549
  Subagents are specialized agents designed for specific tasks.
582
550
 
551
+ ### Locations
552
+
553
+ The agent searches for subagent definitions in the following directories:
554
+
555
+ - `~/.config/plain-agent/agents/`
556
+ - `.plain-agent/agents/`
557
+ - `.claude/agents/`
558
+
583
559
  ### Subagent File Format
584
560
 
585
561
  ```md
@@ -602,14 +578,6 @@ Use AGENTS.md instead of CLAUDE.md in this project.
602
578
 
603
579
  Remote subagents are fetched and cached locally. The local content will be appended to the imported content.
604
580
 
605
- ### Locations
606
-
607
- The agent searches for subagent definitions in the following directories:
608
-
609
- - `~/.config/plain-agent/agents/`
610
- - `.plain-agent/agents/`
611
- - `.claude/agents/`
612
-
613
581
  ## Claude Code Plugin Support
614
582
 
615
583
  Example:
@@ -653,7 +621,7 @@ and send them like regular text.
653
621
 
654
622
  ### Providers
655
623
 
656
- **OpenAI Realtime** (default, recommended):
624
+ **OpenAI Realtime**
657
625
 
658
626
  ```js
659
627
  {
@@ -666,7 +634,7 @@ and send them like regular text.
666
634
  }
667
635
  ```
668
636
 
669
- **Gemini Live** (preview API; model names and pricing may change):
637
+ **Gemini Live**
670
638
 
671
639
  ```js
672
640
  {
@@ -683,8 +651,7 @@ and send them like regular text.
683
651
 
684
652
  - `toggleKey` — Rebind the toggle. Accepts `"ctrl-<char>"` where `<char>`
685
653
  is a letter (a-z) or one of `[ \ ] ^ _`. Defaults to `"ctrl-o"`.
686
- - `recorder` — Override recorder auto-detection. Must write raw 16-bit
687
- little-endian mono PCM to stdout at 24 kHz (OpenAI) or 16 kHz (Gemini).
654
+ - `recorder` — Override recorder auto-detection, e.g. `{ "command": "sox", "args": ["-q", "-d", "-b", "16", "-c", "1", "-r", "24000", "-e", "signed-integer", "-t", "raw", "-"] }`. Must write raw 16-bit little-endian mono PCM to stdout at 24 kHz (OpenAI) or 16 kHz (Gemini).
688
655
 
689
656
  ## Development
690
657
 
@@ -705,13 +672,9 @@ npx npm-check-updates -t minor -c 3 -u
705
672
  ## Release
706
673
 
707
674
  ```sh
708
- npm run check
709
-
710
- git commit -m "<message>"
711
-
712
675
  npm version <major|minor|patch>
713
- git push --follow-tags
714
676
 
677
+ git push --follow-tags
715
678
  gh release create $(git describe --tags) --generate-notes
716
679
 
717
680
  npm publish --access public
@@ -813,7 +813,7 @@
813
813
  }
814
814
  },
815
815
  {
816
- "name": "gpt-5.4",
816
+ "name": "gpt-5.5",
817
817
  "variant": "thinking-medium",
818
818
  "platform": {
819
819
  "name": "openai",
@@ -823,7 +823,7 @@
823
823
  "model": {
824
824
  "format": "openai-responses",
825
825
  "config": {
826
- "model": "gpt-5.4",
826
+ "model": "gpt-5.5",
827
827
  "reasoning": { "effort": "medium", "summary": "auto" },
828
828
  "store": false,
829
829
  "include": ["reasoning.encrypted_content"]
@@ -833,14 +833,14 @@
833
833
  "currency": "USD",
834
834
  "unit": "1M",
835
835
  "costs": {
836
- "input_tokens": 2.5,
837
- "input_tokens_details.cached_tokens": -2.25,
838
- "output_tokens": 15
836
+ "input_tokens": 5,
837
+ "input_tokens_details.cached_tokens": -4.5,
838
+ "output_tokens": 30
839
839
  }
840
840
  }
841
841
  },
842
842
  {
843
- "name": "gpt-5.4",
843
+ "name": "gpt-5.5",
844
844
  "variant": "thinking-high",
845
845
  "platform": {
846
846
  "name": "openai",
@@ -850,7 +850,7 @@
850
850
  "model": {
851
851
  "format": "openai-responses",
852
852
  "config": {
853
- "model": "gpt-5.4",
853
+ "model": "gpt-5.5",
854
854
  "reasoning": { "effort": "high", "summary": "auto" },
855
855
  "store": false,
856
856
  "include": ["reasoning.encrypted_content"]
@@ -860,14 +860,14 @@
860
860
  "currency": "USD",
861
861
  "unit": "1M",
862
862
  "costs": {
863
- "input_tokens": 2.5,
864
- "input_tokens_details.cached_tokens": -2.25,
865
- "output_tokens": 15
863
+ "input_tokens": 5,
864
+ "input_tokens_details.cached_tokens": -4.5,
865
+ "output_tokens": 30
866
866
  }
867
867
  }
868
868
  },
869
869
  {
870
- "name": "gpt-5.4",
870
+ "name": "gpt-5.5",
871
871
  "variant": "thinking-xhigh",
872
872
  "platform": {
873
873
  "name": "openai",
@@ -877,7 +877,7 @@
877
877
  "model": {
878
878
  "format": "openai-responses",
879
879
  "config": {
880
- "model": "gpt-5.4",
880
+ "model": "gpt-5.5",
881
881
  "reasoning": { "effort": "xhigh", "summary": "auto" },
882
882
  "store": false,
883
883
  "include": ["reasoning.encrypted_content"]
@@ -887,9 +887,9 @@
887
887
  "currency": "USD",
888
888
  "unit": "1M",
889
889
  "costs": {
890
- "input_tokens": 2.5,
891
- "input_tokens_details.cached_tokens": -2.25,
892
- "output_tokens": 15
890
+ "input_tokens": 5,
891
+ "input_tokens_details.cached_tokens": -4.5,
892
+ "output_tokens": 30
893
893
  }
894
894
  }
895
895
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iinm/plain-agent",
3
- "version": "1.7.19",
3
+ "version": "1.7.20",
4
4
  "description": "A lightweight CLI-based coding agent",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,9 +10,7 @@
10
10
  },
11
11
  "bin": {
12
12
  "plain": "./bin/plain",
13
- "plain-interrupt": "./bin/plain-interrupt",
14
13
  "plain-notify-desktop": "./bin/plain-notify-desktop",
15
- "plain-notify-terminal-bell": "./bin/plain-notify-terminal-bell",
16
14
  "plain-sandbox": "./sandbox/bin/plain-sandbox"
17
15
  },
18
16
  "files": [
package/src/agentLoop.mjs CHANGED
@@ -128,7 +128,9 @@ export function createAgentLoop({
128
128
 
129
129
  const { message: assistantMessage, providerTokenUsage } = modelOutput;
130
130
  stateManager.appendMessages([assistantMessage]);
131
- agentEventEmitter.emit("providerTokenUsage", providerTokenUsage);
131
+ if (providerTokenUsage) {
132
+ agentEventEmitter.emit("providerTokenUsage", providerTokenUsage);
133
+ }
132
134
 
133
135
  // Gemini may stop with "thinking" -> continue
134
136
  const lastContent = assistantMessage.content.at(-1);
package/src/cliCost.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { styleText } from "node:util";
6
- import { readUsageRecords } from "./usageStore.mjs";
6
+ import * as usageStore from "./usageStore.mjs";
7
7
 
8
8
  /**
9
9
  * @typedef {Object} CostPeriod
@@ -72,15 +72,15 @@ export function parseDateOnly(value) {
72
72
  if (!match) {
73
73
  throw new Error(`Invalid date: "${value}" (expected YYYY-MM-DD)`);
74
74
  }
75
- const [, y, m, d] = match;
76
- const year = Number(y);
77
- const month = Number(m);
78
- const day = Number(d);
79
- const date = new Date(year, month - 1, day);
75
+ const [, year, month, day] = match;
76
+ const y = Number(year);
77
+ const m = Number(month);
78
+ const d = Number(day);
79
+ const date = new Date(y, m - 1, d);
80
80
  if (
81
- date.getFullYear() !== year ||
82
- date.getMonth() !== month - 1 ||
83
- date.getDate() !== day
81
+ date.getFullYear() !== y ||
82
+ date.getMonth() !== m - 1 ||
83
+ date.getDate() !== d
84
84
  ) {
85
85
  throw new Error(`Invalid date: "${value}"`);
86
86
  }
@@ -109,6 +109,10 @@ export function aggregateUsage(records, period) {
109
109
  let excludedOutOfRange = 0;
110
110
 
111
111
  for (const record of records) {
112
+ if (record.timestamp == null) {
113
+ excludedOutOfRange++;
114
+ continue;
115
+ }
112
116
  const recordedAt = new Date(record.timestamp);
113
117
  if (Number.isNaN(recordedAt.getTime())) {
114
118
  excludedOutOfRange++;
@@ -123,12 +127,13 @@ export function aggregateUsage(records, period) {
123
127
  noPricingSessionCount++;
124
128
  continue;
125
129
  }
126
-
127
- let perDate = byCurrency.get(record.currency);
128
- if (!perDate) {
129
- perDate = new Map();
130
- byCurrency.set(record.currency, perDate);
130
+ if (!record.currency || typeof record.currency !== "string") {
131
+ excludedOutOfRange++;
132
+ continue;
131
133
  }
134
+
135
+ const perDate = byCurrency.get(record.currency) ?? new Map();
136
+ byCurrency.set(record.currency, perDate);
132
137
  const existing = perDate.get(localDate);
133
138
  if (existing) {
134
139
  existing.totalCost += record.totalCost;
@@ -146,13 +151,14 @@ export function aggregateUsage(records, period) {
146
151
  const aggregations = [];
147
152
  for (const [currency, perDate] of byCurrency) {
148
153
  const daily = Array.from(perDate.values()).sort((a, b) =>
149
- a.date < b.date ? -1 : a.date > b.date ? 1 : 0,
150
- );
151
- const totalCost = daily.reduce((sum, entry) => sum + entry.totalCost, 0);
152
- const sessionCount = daily.reduce(
153
- (sum, entry) => sum + entry.sessionCount,
154
- 0,
154
+ a.date.localeCompare(b.date),
155
155
  );
156
+ let totalCost = 0;
157
+ let sessionCount = 0;
158
+ for (const entry of daily) {
159
+ totalCost += entry.totalCost;
160
+ sessionCount += entry.sessionCount;
161
+ }
156
162
  aggregations.push({ currency, daily, totalCost, sessionCount });
157
163
  }
158
164
  aggregations.sort((a, b) => a.currency.localeCompare(b.currency));
@@ -166,6 +172,14 @@ export function aggregateUsage(records, period) {
166
172
  };
167
173
  }
168
174
 
175
+ /**
176
+ * @param {number} count
177
+ * @returns {string}
178
+ */
179
+ function formatSessions(count) {
180
+ return `${count} session${count === 1 ? "" : "s"}`;
181
+ }
182
+
169
183
  /**
170
184
  * Render a cost report as a human-readable string.
171
185
  *
@@ -175,10 +189,9 @@ export function aggregateUsage(records, period) {
175
189
  */
176
190
  export function formatCostReport(report, options = {}) {
177
191
  const color = options.color ?? true;
178
- const style = color
179
- ? styleText
180
- : /** @param {any} _modifiers @param {string} text */ (_modifiers, text) =>
181
- text;
192
+ /** @param {string | string[]} _modifiers @param {string} text @returns {string} */
193
+ const plainStyle = (_modifiers, text) => text;
194
+ const style = color ? styleText : plainStyle;
182
195
 
183
196
  const lines = [];
184
197
  lines.push(
@@ -204,14 +217,14 @@ export function formatCostReport(report, options = {}) {
204
217
  lines.push(style("bold", `Daily cost (${agg.currency}):`));
205
218
  for (const entry of agg.daily) {
206
219
  lines.push(
207
- ` ${entry.date} ${formatCost(entry.totalCost)} ${agg.currency} (${entry.sessionCount} session${entry.sessionCount === 1 ? "" : "s"})`,
220
+ ` ${entry.date} ${formatCost(entry.totalCost)} ${agg.currency} (${formatSessions(entry.sessionCount)})`,
208
221
  );
209
222
  }
210
223
  lines.push("");
211
224
  lines.push(
212
225
  style(
213
226
  "bold",
214
- `Total: ${formatCost(agg.totalCost)} ${agg.currency} (${agg.sessionCount} session${agg.sessionCount === 1 ? "" : "s"})`,
227
+ `Total: ${formatCost(agg.totalCost)} ${agg.currency} (${formatSessions(agg.sessionCount)})`,
215
228
  ),
216
229
  );
217
230
  }
@@ -242,28 +255,50 @@ function formatCost(value) {
242
255
  * Run the `plain cost` subcommand.
243
256
  *
244
257
  * @param {{ from: string | null, to: string | null }} args
258
+ * @param {{ readUsageRecords?: typeof import("./usageStore.mjs").readUsageRecords }} [deps]
245
259
  * @returns {Promise<number>} exit code
246
260
  */
247
- export async function runCostCommand(args) {
248
- const { from, to } = resolvePeriod(args);
261
+ export async function runCostCommand(args, deps = {}) {
262
+ let from;
263
+ let to;
264
+ try {
265
+ ({ from, to } = resolvePeriod(args));
266
+ } catch (err) {
267
+ if (err instanceof Error) {
268
+ console.error(`Error: ${err.message}`);
269
+ } else {
270
+ console.error("Error: invalid period arguments");
271
+ }
272
+ return 1;
273
+ }
249
274
 
250
- const { records, skipped } = await readUsageRecords();
275
+ const { records, skipped } = await (
276
+ deps.readUsageRecords ?? usageStore.readUsageRecords
277
+ )();
251
278
  if (skipped.length > 0) {
279
+ const details = skipped
280
+ .slice(0, 3)
281
+ .map((s) => `line ${s.line}: ${s.reason}`)
282
+ .join(", ");
283
+ const ellipsis =
284
+ skipped.length > 3 ? `, and ${skipped.length - 3} more` : "";
252
285
  console.error(
253
- `Warning: skipped ${skipped.length} malformed line(s) in usage log.`,
286
+ `Warning: skipped ${skipped.length} malformed line(s) in usage log (${details}${ellipsis}).`,
254
287
  );
255
288
  }
256
289
 
257
290
  const report = aggregateUsage(records, { from, to });
258
291
  console.log(formatCostReport(report));
259
- return 0;
292
+ return skipped.length > 0 ? 1 : 0;
260
293
  }
261
294
 
262
295
  /**
296
+ * Resolve a period from CLI arguments, falling back to the current month.
297
+ *
263
298
  * @param {{ from: string | null, to: string | null }} args
264
299
  * @returns {CostPeriod}
265
300
  */
266
- function resolvePeriod(args) {
301
+ export function resolvePeriod(args) {
267
302
  const fallback = defaultPeriod();
268
303
  const from = args.from ?? fallback.from;
269
304
  const to = args.to ?? fallback.to;
@@ -91,7 +91,7 @@ export function formatToolUse(toolUse) {
91
91
  const diffs = [];
92
92
  const matches = Array.from(
93
93
  diff.matchAll(
94
- /<<<<<<< SEARCH [0-9a-z]{3}\n(.*?)\n======= [0-9a-z]{3}\n(.*?)\n?>>>>>>> REPLACE [0-9a-z]{3}/gs,
94
+ /<<< [0-9a-z]{3} <<< SEARCH\n(.*?)\n=== [0-9a-z]{3} ===\n(.*?)\n?>>> [0-9a-z]{3} >>> REPLACE/gs,
95
95
  ),
96
96
  );
97
97
  for (const match of matches) {
@@ -57,7 +57,7 @@ const HELP_MESSAGE = [
57
57
  * @property {AgentCommands} agentCommands
58
58
  * @property {string} sessionId
59
59
  * @property {string} modelName
60
- * @property {string} notifyCmd
60
+ * @property {{ command: string; args?: string[] } | undefined} notifyCmd
61
61
  * @property {boolean} sandbox
62
62
  * @property {() => Promise<void>} onStop
63
63
  * @property {ClaudeCodePlugin[]} [claudeCodePlugins]
package/src/config.d.ts CHANGED
@@ -21,14 +21,14 @@ export type AppConfig = {
21
21
  askURL?: AskURLToolOptions;
22
22
  };
23
23
  mcpServers?: Record<string, MCPServerConfig>;
24
- notifyCmd?: string;
24
+ notifyCmd?: { command: string; args?: string[] };
25
25
  voiceInput?: VoiceInputConfig;
26
26
  claudeCodePlugins?: ClaudeCodePluginRepo[];
27
27
  };
28
28
 
29
29
  export type MCPServerConfig = {
30
30
  command: string;
31
- args: string[];
31
+ args?: string[];
32
32
  env?: Record<string, string>;
33
33
  options?: {
34
34
  enabledTools?: string[];
package/src/config.mjs CHANGED
@@ -93,7 +93,7 @@ export async function loadAppConfig(options = {}) {
93
93
  ...(merged.mcpServers ?? {}),
94
94
  ...(config.mcpServers ?? {}),
95
95
  },
96
- notifyCmd: config.notifyCmd || merged.notifyCmd,
96
+ notifyCmd: config.notifyCmd ?? merged.notifyCmd,
97
97
  claudeCodePlugins: [
98
98
  ...(merged.claudeCodePlugins ?? []),
99
99
  ...(config.claudeCodePlugins ?? []),