@iinm/plain-agent 1.10.16 → 1.10.17
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 +58 -70
- package/package.json +5 -2
- package/src/cli/formatter.mjs +8 -6
- package/src/tools/patchFile.mjs +84 -61
package/README.md
CHANGED
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
# Plain Agent
|
|
2
2
|
|
|
3
|
-
A lightweight
|
|
3
|
+
A lightweight coding agent for the terminal.
|
|
4
4
|
|
|
5
|
-
- **Multi-provider** — Use Claude, GPT, Gemini, or any OpenAI-compatible model via Bedrock, Vertex AI, or
|
|
6
|
-
- **Fine-grained auto-approval** — Auto-approve tool calls by
|
|
5
|
+
- **Multi-provider** — Use Claude, GPT, Gemini, or any OpenAI-compatible model via direct APIs or through Bedrock, Vertex AI, or Azure.
|
|
6
|
+
- **Fine-grained auto-approval** — Auto-approve tool calls by matching tool names and inputs against configurable patterns, while validating string inputs as paths for safety.
|
|
7
7
|
- **Sandboxed execution** — Run commands in a Docker container with filesystem and network isolation.
|
|
8
|
-
- **Claude Code
|
|
9
|
-
- **Zero external dependencies** — Built
|
|
8
|
+
- **Supports Claude Code resources** — Use Claude Code plugins, commands, subagents, and skills from `.claude/`.
|
|
9
|
+
- **Zero external dependencies** — Built using only Node.js standard libraries.
|
|
10
10
|
|
|
11
11
|
## Limitations
|
|
12
12
|
|
|
13
|
-
- **Path validation only covers tool arguments** — It blocks paths outside the working directory, directory traversal (`..`), symlinks
|
|
14
|
-
- **Sequential subagent execution** — Subagents run one at a time rather than
|
|
15
|
-
in parallel. The trade-off is full visibility: every step is streamed to
|
|
16
|
-
your terminal so you can follow exactly what each subagent is doing.
|
|
13
|
+
- **Path validation only covers tool arguments** — It blocks paths outside the working directory, directory traversal (`..`), symlinks that escape the project, and git-ignored files. However, it only applies to paths explicitly passed as tool-use arguments, so it cannot control file access inside arbitrary scripts. Always use sandboxed execution when running arbitrary scripts.
|
|
14
|
+
- **Sequential subagent execution** — Subagents run one at a time rather than in parallel. The trade-off is that every step is streamed to your terminal, so you can follow exactly what each subagent is doing.
|
|
17
15
|
|
|
18
16
|
## Requirements
|
|
19
17
|
|
|
20
18
|
- Node.js 22 or later
|
|
21
|
-
- LLM provider
|
|
19
|
+
- Credentials for your LLM provider
|
|
22
20
|
- [ripgrep](https://github.com/burntsushi/ripgrep), [fd](https://github.com/sharkdp/fd)
|
|
23
|
-
- Bash
|
|
21
|
+
- Bash and Docker for sandboxed execution
|
|
24
22
|
|
|
25
23
|
## Quick Start
|
|
26
24
|
|
|
@@ -28,13 +26,13 @@ A lightweight, capable coding agent for the terminal.
|
|
|
28
26
|
npm install -g @iinm/plain-agent
|
|
29
27
|
```
|
|
30
28
|
|
|
31
|
-
List available models.
|
|
29
|
+
List the available models.
|
|
32
30
|
|
|
33
31
|
```sh
|
|
34
32
|
plain list-models
|
|
35
33
|
```
|
|
36
34
|
|
|
37
|
-
Create
|
|
35
|
+
Create a configuration file.
|
|
38
36
|
|
|
39
37
|
```js
|
|
40
38
|
// ~/.config/plain-agent/config.local.json
|
|
@@ -63,14 +61,14 @@ Create the configuration.
|
|
|
63
61
|
}
|
|
64
62
|
],
|
|
65
63
|
|
|
66
|
-
//
|
|
64
|
+
// Optional: enable web tools
|
|
67
65
|
"tools": {
|
|
68
66
|
"webSearch": {
|
|
69
67
|
"provider": "gemini",
|
|
70
68
|
"apiKey": "<GEMINI_API_KEY>",
|
|
71
69
|
"model": "gemini-3.5-flash"
|
|
72
70
|
|
|
73
|
-
// Or use Vertex AI (
|
|
71
|
+
// Or use Vertex AI (requires the gcloud CLI for authentication)
|
|
74
72
|
// "provider": "gemini-vertex-ai",
|
|
75
73
|
// "baseURL": "https://aiplatform.googleapis.com/v1beta1/projects/<project_id>/locations/<location>",
|
|
76
74
|
// "model": "gemini-3.5-flash"
|
|
@@ -86,7 +84,7 @@ Create the configuration.
|
|
|
86
84
|
"apiKey": "<GEMINI_API_KEY>",
|
|
87
85
|
"model": "gemini-3.5-flash"
|
|
88
86
|
|
|
89
|
-
// Or use Vertex AI (
|
|
87
|
+
// Or use Vertex AI (requires the gcloud CLI for authentication)
|
|
90
88
|
|
|
91
89
|
// Or use a custom command
|
|
92
90
|
// "provider": "command",
|
|
@@ -98,12 +96,12 @@ Create the configuration.
|
|
|
98
96
|
```
|
|
99
97
|
|
|
100
98
|
<details>
|
|
101
|
-
<summary><b>
|
|
99
|
+
<summary><b>Bedrock / Vertex AI / Azure provider examples</b></summary>
|
|
102
100
|
|
|
103
101
|
```js
|
|
104
102
|
{
|
|
105
103
|
"platforms": [
|
|
106
|
-
// Bedrock: Requires AWS CLI
|
|
104
|
+
// Bedrock: Requires the AWS CLI for authentication
|
|
107
105
|
{
|
|
108
106
|
"name": "bedrock",
|
|
109
107
|
"variant": "default",
|
|
@@ -111,16 +109,16 @@ Create the configuration.
|
|
|
111
109
|
"awsProfile": "<AWS_PROFILE>"
|
|
112
110
|
},
|
|
113
111
|
|
|
114
|
-
// Vertex AI: Requires gcloud CLI
|
|
112
|
+
// Vertex AI: Requires the gcloud CLI for authentication
|
|
115
113
|
{
|
|
116
114
|
"name": "vertex-ai",
|
|
117
115
|
"variant": "default",
|
|
118
116
|
"baseURL": "https://aiplatform.googleapis.com/v1beta1/projects/<project>/locations/<location>",
|
|
119
|
-
//
|
|
117
|
+
// Optional: impersonate this service account to obtain an auth token
|
|
120
118
|
"account": "<SERVICE_ACCOUNT_EMAIL>"
|
|
121
119
|
},
|
|
122
120
|
|
|
123
|
-
// Azure: Requires Azure CLI
|
|
121
|
+
// Azure: Requires the Azure CLI for authentication
|
|
124
122
|
{
|
|
125
123
|
"name": "azure",
|
|
126
124
|
"variant": "default",
|
|
@@ -142,7 +140,7 @@ Create the configuration.
|
|
|
142
140
|
</details>
|
|
143
141
|
|
|
144
142
|
<details>
|
|
145
|
-
<summary><b>OpenAI
|
|
143
|
+
<summary><b>OpenAI-compatible provider examples</b></summary>
|
|
146
144
|
|
|
147
145
|
```js
|
|
148
146
|
{
|
|
@@ -195,7 +193,7 @@ Create the configuration.
|
|
|
195
193
|
</details>
|
|
196
194
|
|
|
197
195
|
<details>
|
|
198
|
-
<summary><b>Bedrock example
|
|
196
|
+
<summary><b>Bedrock example with Claude Geo Cross-Region inference</b></summary>
|
|
199
197
|
|
|
200
198
|
```js
|
|
201
199
|
{
|
|
@@ -270,22 +268,21 @@ Run the agent.
|
|
|
270
268
|
|
|
271
269
|
```sh
|
|
272
270
|
plain
|
|
273
|
-
```
|
|
274
271
|
|
|
275
|
-
|
|
272
|
+
# Or
|
|
276
273
|
plain -m <model+variant>
|
|
277
274
|
```
|
|
278
275
|
|
|
279
|
-
Press **Ctrl-C** to pause auto-
|
|
276
|
+
Press **Ctrl-C** to pause auto-approval. The agent will finish the current tool call, then return to the prompt.
|
|
280
277
|
|
|
281
|
-
|
|
278
|
+
Show the help message.
|
|
282
279
|
|
|
283
280
|
```
|
|
284
281
|
/help
|
|
285
282
|
```
|
|
286
283
|
|
|
287
|
-
Run in batch mode
|
|
288
|
-
In batch mode,
|
|
284
|
+
Run in non-interactive batch mode.
|
|
285
|
+
In batch mode, configuration files are not loaded automatically. Only the files specified with `-c` are loaded.
|
|
289
286
|
|
|
290
287
|
```sh
|
|
291
288
|
plain batch \
|
|
@@ -294,34 +291,32 @@ plain batch \
|
|
|
294
291
|
"Add tests for ..."
|
|
295
292
|
```
|
|
296
293
|
|
|
297
|
-
Show daily token
|
|
294
|
+
Show daily token cost. `plain cost` reads
|
|
298
295
|
`~/.local/share/plain-agent/usage.jsonl`; use `--from` / `--to` to set the
|
|
299
|
-
period.
|
|
296
|
+
period. Costs are shown separately by currency.
|
|
300
297
|
|
|
301
298
|
```sh
|
|
302
299
|
plain cost
|
|
303
|
-
```
|
|
304
300
|
|
|
305
|
-
|
|
301
|
+
# Or
|
|
306
302
|
plain cost --from 2026-04-01 --to 2026-04-30
|
|
307
303
|
```
|
|
308
304
|
|
|
309
305
|
Resume a previously interrupted interactive session. Sessions are
|
|
310
|
-
|
|
311
|
-
no longer needed.
|
|
306
|
+
automatically saved to `.plain-agent/sessions/` and can be deleted with `rm` when
|
|
307
|
+
no longer needed. If no argument is provided, the most recently updated session
|
|
312
308
|
is resumed. Use `--list` to see resumable sessions. Switching models is
|
|
313
|
-
not supported (`-m` is
|
|
309
|
+
not supported (`-m` is not allowed).
|
|
314
310
|
|
|
315
311
|
```sh
|
|
316
312
|
plain resume
|
|
317
|
-
```
|
|
318
313
|
|
|
319
|
-
|
|
314
|
+
# Or
|
|
320
315
|
plain resume --list
|
|
321
316
|
plain resume 2026-05-10-0803-a7k
|
|
322
317
|
```
|
|
323
318
|
|
|
324
|
-
|
|
319
|
+
Set up Plain Agent for your project.
|
|
325
320
|
|
|
326
321
|
```
|
|
327
322
|
/configure Auto-approve file writes and patches
|
|
@@ -353,7 +348,7 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
353
348
|
### Example
|
|
354
349
|
|
|
355
350
|
<details>
|
|
356
|
-
<summary><b>YOLO mode example (requires sandbox for safety)</b></summary>
|
|
351
|
+
<summary><b>YOLO mode example (requires a sandbox for safety)</b></summary>
|
|
357
352
|
|
|
358
353
|
```js
|
|
359
354
|
{
|
|
@@ -373,7 +368,7 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
373
368
|
"toolName": { "$regex": "^(web_search|web_fetch)$" },
|
|
374
369
|
"action": "allow"
|
|
375
370
|
}
|
|
376
|
-
// ⚠️ Never do this.
|
|
371
|
+
// ⚠️ Never do this. MCP runs outside the sandbox, so it can send anything externally.
|
|
377
372
|
// {
|
|
378
373
|
// "toolName": { "$regex": "." },
|
|
379
374
|
// "action": "allow"
|
|
@@ -405,10 +400,10 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
405
400
|
```js
|
|
406
401
|
{
|
|
407
402
|
"autoApproval": {
|
|
408
|
-
// Absolute paths outside the working directory
|
|
403
|
+
// Absolute paths outside the working directory that are allowed. Relative paths are ignored.
|
|
409
404
|
"allowedPaths": ["/tmp"],
|
|
410
405
|
"defaultAction": "ask",
|
|
411
|
-
//
|
|
406
|
+
// Maximum number of automatic approvals.
|
|
412
407
|
"maxApprovals": 50,
|
|
413
408
|
// Patterns are evaluated in order. First match wins.
|
|
414
409
|
"patterns": [
|
|
@@ -435,7 +430,7 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
435
430
|
"action": "allow"
|
|
436
431
|
},
|
|
437
432
|
|
|
438
|
-
// MCP
|
|
433
|
+
// MCP tool naming convention: mcp__<serverName>__<toolName>
|
|
439
434
|
{
|
|
440
435
|
"toolName": { "$regex": "mcp__slack__slack_(read|search)_.+" },
|
|
441
436
|
"action": "allow"
|
|
@@ -477,7 +472,7 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
477
472
|
"command": "npx",
|
|
478
473
|
"args": ["-y", "chrome-devtools-mcp@latest", "--isolated"]
|
|
479
474
|
},
|
|
480
|
-
// ⚠️ Add to config.local.json to avoid committing secrets to Git
|
|
475
|
+
// ⚠️ Add this to config.local.json to avoid committing secrets to Git
|
|
481
476
|
"slack": {
|
|
482
477
|
"command": "npx",
|
|
483
478
|
"args": ["-y", "mcp-remote", "https://mcp.slack.com/mcp", "--header", "Authorization:Bearer <SLACK_TOKEN>"],
|
|
@@ -486,7 +481,7 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
486
481
|
"command": "npx",
|
|
487
482
|
"args": ["-y", "mcp-remote", "https://mcp.notion.com/mcp"],
|
|
488
483
|
"options": {
|
|
489
|
-
// Enable only specific tools
|
|
484
|
+
// Enable only specific tools. If not specified, all tools are enabled.
|
|
490
485
|
"enabledTools": ["notion-search", "notion-fetch"]
|
|
491
486
|
}
|
|
492
487
|
},
|
|
@@ -494,18 +489,18 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
494
489
|
"command": "npx",
|
|
495
490
|
"args": ["-y", "mcp-remote", "https://knowledge-mcp.global.api.aws"]
|
|
496
491
|
},
|
|
497
|
-
// ⚠️ Add to config.local.json to avoid committing secrets to Git
|
|
492
|
+
// ⚠️ Add this to config.local.json to avoid committing secrets to Git
|
|
498
493
|
"google_developer-knowledge": {
|
|
499
494
|
"command": "npx",
|
|
500
495
|
"args": ["-y", "mcp-remote", "https://developerknowledge.googleapis.com/mcp", "--header", "X-Goog-Api-Key:<GOOGLE_API_KEY>"]
|
|
501
496
|
}
|
|
502
497
|
},
|
|
503
498
|
|
|
504
|
-
// Override default notification command
|
|
499
|
+
// Override the default notification command
|
|
505
500
|
"notifyCmd": { "command": "plain-notify-desktop", "args": [] }
|
|
506
501
|
|
|
507
502
|
// Voice input. See "Voice Input" below.
|
|
508
|
-
// ⚠️ Add to config.local.json to avoid committing secrets to Git
|
|
503
|
+
// ⚠️ Add this to config.local.json to avoid committing secrets to Git
|
|
509
504
|
"voiceInput": {
|
|
510
505
|
"provider": "openai",
|
|
511
506
|
"apiKey": "<OPENAI_API_KEY>"
|
|
@@ -516,7 +511,7 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
516
511
|
|
|
517
512
|
## Available Tools
|
|
518
513
|
|
|
519
|
-
The agent can use the following tools
|
|
514
|
+
The agent can use the following tools:
|
|
520
515
|
|
|
521
516
|
- **read_file**: Read a file with line numbers (1-indexed). Supports `offset` and `limit` to read a specific range.
|
|
522
517
|
- **write_file**: Write a file.
|
|
@@ -527,7 +522,7 @@ The agent can use the following tools to assist with tasks:
|
|
|
527
522
|
- **web_fetch**: Fetch the contents of a single URL and answer a question based on it (requires Google API key, Vertex AI configuration, or the `command` provider with a local fetch command such as `w3m`, `curl`, or `lynx`).
|
|
528
523
|
- **switch_to_subagent**: Switch to a subagent role within the same conversation, focusing on the specified goal.
|
|
529
524
|
- **switch_to_main_agent**: Switch back to the main agent role and report the result. After reporting, the subagent's conversation history is removed from the context.
|
|
530
|
-
- **compact_context**: Compact the conversation context by discarding
|
|
525
|
+
- **compact_context**: Compact the conversation context by discarding earlier messages and reloading task state from a memory file. Use this when the context has grown large but the task is not yet complete. You can also invoke it with the `/compact` slash command.
|
|
531
526
|
|
|
532
527
|
## Prompts
|
|
533
528
|
|
|
@@ -561,7 +556,7 @@ Prompts located in a `shortcuts/` subdirectory (e.g., `.plain-agent/prompts/shor
|
|
|
561
556
|
|
|
562
557
|
## Subagents
|
|
563
558
|
|
|
564
|
-
Subagents are specialized
|
|
559
|
+
Subagents are specialized helpers for specific tasks.
|
|
565
560
|
|
|
566
561
|
### Locations
|
|
567
562
|
|
|
@@ -587,12 +582,7 @@ You are a web content reader and analyzer. Given a URL and a question, you:
|
|
|
587
582
|
|
|
588
583
|
## Claude Code Plugin Support
|
|
589
584
|
|
|
590
|
-
Plugins are installed under `.plain-agent/claude-code-plugins/` and must be
|
|
591
|
-
installed per project by running `plain install-claude-code-plugins` from
|
|
592
|
-
the project root. Global installation (e.g., under `~/.plain-agent`) is not
|
|
593
|
-
supported, because plugins may include skills that the agent invokes
|
|
594
|
-
autonomously, and scoping them to the project keeps approval rules and
|
|
595
|
-
permission management straightforward.
|
|
585
|
+
Plugins are installed under `.plain-agent/claude-code-plugins/` and must be installed per project by running `plain install-claude-code-plugins` from the project root. Global installation (e.g., under `~/.plain-agent`) is not supported because plugins may include skills the agent invokes autonomously. Keeping them scoped to the project keeps approval rules and permission management straightforward.
|
|
596
586
|
|
|
597
587
|
Example:
|
|
598
588
|
|
|
@@ -623,15 +613,13 @@ plain install-claude-code-plugins
|
|
|
623
613
|
|
|
624
614
|
## Voice Input
|
|
625
615
|
|
|
626
|
-
Press **Ctrl-O** to start recording, press it again to stop. Partial
|
|
627
|
-
transcripts are inserted into the prompt as you speak so you can edit
|
|
628
|
-
and send them like regular text.
|
|
616
|
+
Press **Ctrl-O** to start recording, then press it again to stop. Partial transcripts are inserted into the prompt as you speak, so you can edit and send them like regular text.
|
|
629
617
|
|
|
630
618
|
### Requirements
|
|
631
619
|
|
|
632
620
|
- A recording command on `PATH`: `arecord`, `sox`, or `ffmpeg`.
|
|
633
621
|
- An API key for the chosen provider.
|
|
634
|
-
- Your host must have microphone access.
|
|
622
|
+
- Your host must have microphone access.
|
|
635
623
|
|
|
636
624
|
### Providers
|
|
637
625
|
|
|
@@ -665,17 +653,17 @@ and send them like regular text.
|
|
|
665
653
|
|
|
666
654
|
### Options
|
|
667
655
|
|
|
668
|
-
- `toggleKey` — Rebind the toggle. Accepts `"ctrl-<char>"` where `<char>`
|
|
656
|
+
- `toggleKey` — Rebind the toggle key. Accepts `"ctrl-<char>"` where `<char>`
|
|
669
657
|
is a letter (a-z) or one of `[ \ ] ^ _`. Defaults to `"ctrl-o"`.
|
|
670
|
-
- `recorder` — Override recorder
|
|
658
|
+
- `recorder` — Override automatic recorder detection, e.g. `{ "command": "sox", "args": ["-q", "-d", "-b", "16", "-c", "1", "-r", "24000", "-e", "signed-integer", "-t", "raw", "-"] }`. It must write raw 16-bit little-endian mono PCM to stdout at 24 kHz (OpenAI) or 16 kHz (Gemini).
|
|
671
659
|
|
|
672
660
|
## Development
|
|
673
661
|
|
|
674
662
|
```sh
|
|
675
|
-
# Run lint, typecheck, and
|
|
663
|
+
# Run lint, typecheck, and tests
|
|
676
664
|
npm run check
|
|
677
665
|
|
|
678
|
-
# Fix lint
|
|
666
|
+
# Fix lint issues
|
|
679
667
|
npm run fix
|
|
680
668
|
# or
|
|
681
669
|
npm run fix -- --unsafe
|
|
@@ -702,7 +690,7 @@ npm publish --access public
|
|
|
702
690
|
<summary><b>Amazon Bedrock</b></summary>
|
|
703
691
|
|
|
704
692
|
```sh
|
|
705
|
-
# IAM Identity Center
|
|
693
|
+
# IAM Identity Center
|
|
706
694
|
identity_center_instance_arn="<IDENTITY_CENTER_INSTANCE_ARN>" # e.g., arn:aws:sso:::instance/ssoins-xxxxxxxxxxxxxxxx"
|
|
707
695
|
identity_store_id=<IDENTITY_STORE_ID>
|
|
708
696
|
aws_account_id=<AWS_ACCOUNT_ID>
|
|
@@ -710,7 +698,7 @@ aws_account_id=<AWS_ACCOUNT_ID>
|
|
|
710
698
|
# Create a permission set
|
|
711
699
|
permission_set_arn=$(aws sso-admin create-permission-set \
|
|
712
700
|
--instance-arn "$identity_center_instance_arn" \
|
|
713
|
-
--name "
|
|
701
|
+
--name "BedrockCodingAgent" \
|
|
714
702
|
--description "Allows only Bedrock model invocation" \
|
|
715
703
|
--query "PermissionSet.PermissionSetArn" --output text)
|
|
716
704
|
|
|
@@ -785,7 +773,7 @@ aws bedrock-runtime invoke-model \
|
|
|
785
773
|
|
|
786
774
|
```sh
|
|
787
775
|
resource_group=<RESOURCE_GROUP>
|
|
788
|
-
account_name=<ACCOUNT_NAME> #
|
|
776
|
+
account_name=<ACCOUNT_NAME> # Resource name
|
|
789
777
|
|
|
790
778
|
# Create a service principal
|
|
791
779
|
service_principal=$(az ad sp create-for-rbac --name "CodingAgentServicePrincipal" --skip-assignment)
|
|
@@ -808,7 +796,7 @@ az role assignment create \
|
|
|
808
796
|
export app_secret=$(echo "$service_principal" | jq -r .password)
|
|
809
797
|
export tenant_id=$(echo "$service_principal" | jq -r .tenant)
|
|
810
798
|
|
|
811
|
-
export AZURE_CONFIG_DIR=$HOME/.azure-for-agent # Change
|
|
799
|
+
export AZURE_CONFIG_DIR=$HOME/.azure-for-agent # Change this to store credentials elsewhere
|
|
812
800
|
az login --service-principal -u "$app_id" -p "$app_secret" --tenant "$tenant_id"
|
|
813
801
|
```
|
|
814
802
|
</details>
|
package/package.json
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iinm/plain-agent",
|
|
3
|
-
"version": "1.10.
|
|
4
|
-
"description": "A lightweight
|
|
3
|
+
"version": "1.10.17",
|
|
4
|
+
"description": "A lightweight coding agent for the terminal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/iinm/plain-agent.git"
|
|
10
10
|
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"coding-agent"
|
|
13
|
+
],
|
|
11
14
|
"bin": {
|
|
12
15
|
"plain": "bin/plain",
|
|
13
16
|
"plain-notify-desktop": "bin/plain-notify-desktop",
|
package/src/cli/formatter.mjs
CHANGED
|
@@ -844,7 +844,7 @@ function renderPatchBlock(block, originalLines, nonce) {
|
|
|
844
844
|
out.push(
|
|
845
845
|
styleText(
|
|
846
846
|
"cyan",
|
|
847
|
-
|
|
847
|
+
`REPLACE ${nonce} ${block.start}:${block.startHash}-${block.end}:${block.endHash}`,
|
|
848
848
|
),
|
|
849
849
|
);
|
|
850
850
|
if (originalLines) {
|
|
@@ -872,7 +872,9 @@ function renderPatchBlock(block, originalLines, nonce) {
|
|
|
872
872
|
}
|
|
873
873
|
} else {
|
|
874
874
|
const afterSuffix = block.afterHash ? `:${block.afterHash}` : "";
|
|
875
|
-
out.push(
|
|
875
|
+
out.push(
|
|
876
|
+
styleText("cyan", `INSERT_AFTER ${nonce} ${block.after}${afterSuffix}`),
|
|
877
|
+
);
|
|
876
878
|
for (const line of block.body) {
|
|
877
879
|
out.push(styleText("green", `+ ${line}`));
|
|
878
880
|
}
|
|
@@ -890,8 +892,8 @@ function highlightPatchPlain(patch) {
|
|
|
890
892
|
if (!patch) {
|
|
891
893
|
return "";
|
|
892
894
|
}
|
|
893
|
-
// Patch headers look like "
|
|
894
|
-
const headerRegex =
|
|
895
|
+
// Patch headers look like "REPLACE <nonce> ..." or "INSERT_AFTER <nonce> ...".
|
|
896
|
+
const headerRegex = /^(REPLACE|INSERT_AFTER)\s+\S+(\s.*)?$/;
|
|
895
897
|
return patch
|
|
896
898
|
.split("\n")
|
|
897
899
|
.map((line) => {
|
|
@@ -912,8 +914,8 @@ function highlightPatchPlain(patch) {
|
|
|
912
914
|
* @returns {string | null}
|
|
913
915
|
*/
|
|
914
916
|
function extractPatchNonce(patch) {
|
|
915
|
-
const match = patch.match(
|
|
916
|
-
return match ? match[
|
|
917
|
+
const match = patch.match(/^(REPLACE|INSERT_AFTER)\s+(\S+)/m);
|
|
918
|
+
return match ? match[2] : null;
|
|
917
919
|
}
|
|
918
920
|
|
|
919
921
|
/**
|
package/src/tools/patchFile.mjs
CHANGED
|
@@ -29,18 +29,18 @@ When editing multiple locations in the same file, include all blocks in a single
|
|
|
29
29
|
patch: {
|
|
30
30
|
description: `
|
|
31
31
|
Format — a single patch string may contain multiple blocks:
|
|
32
|
-
|
|
32
|
+
REPLACE ${nonce} {start}:{startHash}-{end}:{endHash}
|
|
33
33
|
replacement for lines {start}-{end}
|
|
34
|
-
|
|
34
|
+
REPLACE ${nonce} {N}:{hash}
|
|
35
35
|
replace just that one line
|
|
36
|
-
|
|
36
|
+
REPLACE ${nonce} {start}:{startHash}-{end}:{endHash}
|
|
37
37
|
(empty body deletes the range)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
INSERT_AFTER ${nonce} {N}:{afterHash}
|
|
39
|
+
new content after line N
|
|
40
|
+
INSERT_AFTER ${nonce} 0
|
|
41
|
+
content at beginning of file
|
|
42
42
|
|
|
43
|
-
- Each block's content starts right after its
|
|
43
|
+
- Each block's content starts right after its header line and ends at the next header or the end of the string. Any blank lines between the header and the content become part of the replacement.
|
|
44
44
|
- The nonce "${nonce}" is constant; always use the exact value shown above.
|
|
45
45
|
- Hashes are 2-character hex hashes of each line's full content as shown by read_file.
|
|
46
46
|
`.trim(),
|
|
@@ -61,7 +61,7 @@ prepended content at beginning of file
|
|
|
61
61
|
const blocks = parseBlocks(patch, nonce);
|
|
62
62
|
if (blocks.length === 0) {
|
|
63
63
|
throw new Error(
|
|
64
|
-
`No patch blocks found. Each block must start with "
|
|
64
|
+
`No patch blocks found. Each block must start with "REPLACE ${nonce} ..." or "INSERT_AFTER ${nonce} ...".`,
|
|
65
65
|
);
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -91,7 +91,8 @@ prepended content at beginning of file
|
|
|
91
91
|
* @returns {PatchBlock[]}
|
|
92
92
|
*/
|
|
93
93
|
export function parseBlocks(patch, nonce) {
|
|
94
|
-
const
|
|
94
|
+
const replacePrefix = `REPLACE ${nonce} `;
|
|
95
|
+
const insertPrefix = `INSERT_AFTER ${nonce} `;
|
|
95
96
|
const lines = patch.split("\n");
|
|
96
97
|
// Drop trailing empty element produced by split() when patch ends with \n.
|
|
97
98
|
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
@@ -101,14 +102,17 @@ export function parseBlocks(patch, nonce) {
|
|
|
101
102
|
/** @type {number[]} */
|
|
102
103
|
const headerIndices = [];
|
|
103
104
|
for (let i = 0; i < lines.length; i++) {
|
|
104
|
-
if (
|
|
105
|
+
if (
|
|
106
|
+
lines[i].startsWith(replacePrefix) ||
|
|
107
|
+
lines[i].startsWith(insertPrefix)
|
|
108
|
+
) {
|
|
105
109
|
headerIndices.push(i);
|
|
106
110
|
}
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
if (headerIndices.length === 0) {
|
|
110
114
|
throw new Error(
|
|
111
|
-
`No patch blocks found. Each block must start with "
|
|
115
|
+
`No patch blocks found. Each block must start with "REPLACE ${nonce} ..." or "INSERT_AFTER ${nonce} ...".`,
|
|
112
116
|
);
|
|
113
117
|
}
|
|
114
118
|
|
|
@@ -117,8 +121,19 @@ export function parseBlocks(patch, nonce) {
|
|
|
117
121
|
for (let i = 0; i < headerIndices.length; i++) {
|
|
118
122
|
const headerLineIdx = headerIndices[i];
|
|
119
123
|
const headerLine = lines[headerLineIdx];
|
|
120
|
-
|
|
121
|
-
|
|
124
|
+
|
|
125
|
+
/** @type {"replace" | "insert"} */
|
|
126
|
+
let op;
|
|
127
|
+
let headerArgs;
|
|
128
|
+
if (headerLine.startsWith(replacePrefix)) {
|
|
129
|
+
op = "replace";
|
|
130
|
+
headerArgs = headerLine.slice(replacePrefix.length);
|
|
131
|
+
} else {
|
|
132
|
+
op = "insert";
|
|
133
|
+
headerArgs = headerLine.slice(insertPrefix.length);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const header = parseHeaderArgs(headerArgs, op);
|
|
122
137
|
|
|
123
138
|
// Body: from the line after the header to the line before the next header (or EOF)
|
|
124
139
|
const bodyStart = headerLineIdx + 1;
|
|
@@ -126,9 +141,9 @@ export function parseBlocks(patch, nonce) {
|
|
|
126
141
|
i + 1 < headerIndices.length ? headerIndices[i + 1] : lines.length;
|
|
127
142
|
const body = lines.slice(bodyStart, bodyEnd);
|
|
128
143
|
|
|
129
|
-
if (
|
|
144
|
+
if (op === "insert" && body.length === 0) {
|
|
130
145
|
throw new Error(
|
|
131
|
-
|
|
146
|
+
"Insert block has empty body. Use a replace block to delete content.",
|
|
132
147
|
);
|
|
133
148
|
}
|
|
134
149
|
blocks.push({ ...header, body });
|
|
@@ -221,61 +236,69 @@ export function applyBlocks(original, blocks) {
|
|
|
221
236
|
|
|
222
237
|
/**
|
|
223
238
|
* @param {string} headerArgs
|
|
239
|
+
* @param {"replace" | "insert"} op
|
|
224
240
|
* @returns {{ op: "replace"; start: number; end: number; startHash: string; endHash: string } | { op: "insert"; after: number; afterHash: string }}
|
|
225
241
|
*/
|
|
226
|
-
function parseHeaderArgs(headerArgs) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
242
|
+
function parseHeaderArgs(headerArgs, op) {
|
|
243
|
+
if (op === "replace") {
|
|
244
|
+
// Replace form: "{start}:{startHash}-{end}:{endHash}"
|
|
245
|
+
const rangeMatch = headerArgs.match(
|
|
246
|
+
/^(\d+):([a-f0-9]{2})-(\d+):([a-f0-9]{2})\s*$/,
|
|
247
|
+
);
|
|
231
248
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
249
|
+
if (rangeMatch) {
|
|
250
|
+
const start = Number(rangeMatch[1]);
|
|
251
|
+
const end = Number(rangeMatch[3]);
|
|
252
|
+
if (start < 1) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
`Invalid replace range "${headerArgs}": start must be >= 1.`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
if (end < start) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`Invalid replace range "${headerArgs}": end (${end}) must be >= start (${start}).`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
op: "replace",
|
|
264
|
+
start,
|
|
265
|
+
end,
|
|
266
|
+
startHash: rangeMatch[2],
|
|
267
|
+
endHash: rangeMatch[4],
|
|
268
|
+
};
|
|
244
269
|
}
|
|
245
|
-
return {
|
|
246
|
-
op: "replace",
|
|
247
|
-
start,
|
|
248
|
-
end,
|
|
249
|
-
startHash: replaceMatch[2],
|
|
250
|
-
endHash: replaceMatch[4],
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
270
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
271
|
+
// Replace form: "{N}:{hash}" (single line replace — shorthand for N:hash-N:hash)
|
|
272
|
+
const singleMatch = headerArgs.match(/^(\d+):([a-f0-9]{2})\s*$/);
|
|
273
|
+
if (singleMatch) {
|
|
274
|
+
const start = Number(singleMatch[1]);
|
|
275
|
+
if (start < 1) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`Invalid replace range "${headerArgs}": start must be >= 1.`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
op: "replace",
|
|
282
|
+
start,
|
|
283
|
+
end: start,
|
|
284
|
+
startHash: singleMatch[2],
|
|
285
|
+
endHash: singleMatch[2],
|
|
286
|
+
};
|
|
262
287
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
start
|
|
266
|
-
|
|
267
|
-
startHash: singleReplaceMatch[2],
|
|
268
|
-
endHash: singleReplaceMatch[2],
|
|
269
|
-
};
|
|
288
|
+
|
|
289
|
+
throw new Error(
|
|
290
|
+
`Invalid replace header arguments: ${JSON.stringify(headerArgs)}. Expected "{start}:{startHash}-{end}:{endHash}" or "{N}:{hash}".`,
|
|
291
|
+
);
|
|
270
292
|
}
|
|
271
293
|
|
|
272
|
-
//
|
|
273
|
-
|
|
294
|
+
// op === "insert"
|
|
295
|
+
// Insert form: "0" (no hash — there is no line 0 to verify)
|
|
296
|
+
if (/^0\s*$/.test(headerArgs)) {
|
|
274
297
|
return { op: "insert", after: 0, afterHash: "" };
|
|
275
298
|
}
|
|
276
299
|
|
|
277
|
-
// Insert form: "{N}:{afterHash}
|
|
278
|
-
const insertMatch = headerArgs.match(/^(\d+):([a-f0-9]{2})
|
|
300
|
+
// Insert form: "{N}:{afterHash}"
|
|
301
|
+
const insertMatch = headerArgs.match(/^(\d+):([a-f0-9]{2})\s*$/);
|
|
279
302
|
|
|
280
303
|
if (insertMatch) {
|
|
281
304
|
return {
|
|
@@ -286,7 +309,7 @@ function parseHeaderArgs(headerArgs) {
|
|
|
286
309
|
}
|
|
287
310
|
|
|
288
311
|
throw new Error(
|
|
289
|
-
`Invalid
|
|
312
|
+
`Invalid insert header arguments: ${JSON.stringify(headerArgs)}. Expected "{N}:{afterHash}" or "0".`,
|
|
290
313
|
);
|
|
291
314
|
}
|
|
292
315
|
|