@iinm/plain-agent 1.10.15 → 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 +75 -77
- package/config/config.predefined.json +198 -3
- package/package.json +5 -2
- package/src/cli/formatter.mjs +8 -6
- package/src/providers/openaiCompatible.mjs +17 -1
- 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,33 +96,43 @@ 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": [
|
|
104
|
+
// Bedrock: Requires the AWS CLI for authentication
|
|
106
105
|
{
|
|
107
|
-
// Requires Azure CLI to get access token
|
|
108
|
-
"name": "azure",
|
|
109
|
-
"variant": "openai",
|
|
110
|
-
"baseURL": "https://<resource>.openai.azure.com/openai",
|
|
111
|
-
// Optional
|
|
112
|
-
"azureConfigDir": "/home/xxx/.azure-for-agent"
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
// Requires AWS CLI to get credentials
|
|
116
106
|
"name": "bedrock",
|
|
117
107
|
"variant": "default",
|
|
118
108
|
"baseURL": "https://bedrock-runtime.<region>.amazonaws.com",
|
|
119
109
|
"awsProfile": "<AWS_PROFILE>"
|
|
120
110
|
},
|
|
111
|
+
|
|
112
|
+
// Vertex AI: Requires the gcloud CLI for authentication
|
|
121
113
|
{
|
|
122
|
-
// Requires gcloud CLI to get authentication token
|
|
123
114
|
"name": "vertex-ai",
|
|
124
115
|
"variant": "default",
|
|
125
116
|
"baseURL": "https://aiplatform.googleapis.com/v1beta1/projects/<project>/locations/<location>",
|
|
126
|
-
//
|
|
117
|
+
// Optional: impersonate this service account to obtain an auth token
|
|
127
118
|
"account": "<SERVICE_ACCOUNT_EMAIL>"
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
// Azure: Requires the Azure CLI for authentication
|
|
122
|
+
{
|
|
123
|
+
"name": "azure",
|
|
124
|
+
"variant": "default",
|
|
125
|
+
"baseURL": "https://<resource>.services.ai.azure.com",
|
|
126
|
+
// Optional
|
|
127
|
+
"azureConfigDir": "/home/xxx/.azure-for-agent"
|
|
128
|
+
},
|
|
129
|
+
// Azure OpenAI
|
|
130
|
+
{
|
|
131
|
+
"name": "azure",
|
|
132
|
+
"variant": "openai",
|
|
133
|
+
"baseURL": "https://<resource>.openai.azure.com/openai",
|
|
134
|
+
// Optional
|
|
135
|
+
"azureConfigDir": "/home/xxx/.azure-for-agent"
|
|
128
136
|
}
|
|
129
137
|
]
|
|
130
138
|
}
|
|
@@ -132,7 +140,7 @@ Create the configuration.
|
|
|
132
140
|
</details>
|
|
133
141
|
|
|
134
142
|
<details>
|
|
135
|
-
<summary><b>OpenAI
|
|
143
|
+
<summary><b>OpenAI-compatible provider examples</b></summary>
|
|
136
144
|
|
|
137
145
|
```js
|
|
138
146
|
{
|
|
@@ -185,7 +193,7 @@ Create the configuration.
|
|
|
185
193
|
</details>
|
|
186
194
|
|
|
187
195
|
<details>
|
|
188
|
-
<summary><b>Bedrock example
|
|
196
|
+
<summary><b>Bedrock example with Claude Geo Cross-Region inference</b></summary>
|
|
189
197
|
|
|
190
198
|
```js
|
|
191
199
|
{
|
|
@@ -260,22 +268,21 @@ Run the agent.
|
|
|
260
268
|
|
|
261
269
|
```sh
|
|
262
270
|
plain
|
|
263
|
-
```
|
|
264
271
|
|
|
265
|
-
|
|
272
|
+
# Or
|
|
266
273
|
plain -m <model+variant>
|
|
267
274
|
```
|
|
268
275
|
|
|
269
|
-
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.
|
|
270
277
|
|
|
271
|
-
|
|
278
|
+
Show the help message.
|
|
272
279
|
|
|
273
280
|
```
|
|
274
281
|
/help
|
|
275
282
|
```
|
|
276
283
|
|
|
277
|
-
Run in batch mode
|
|
278
|
-
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.
|
|
279
286
|
|
|
280
287
|
```sh
|
|
281
288
|
plain batch \
|
|
@@ -284,34 +291,32 @@ plain batch \
|
|
|
284
291
|
"Add tests for ..."
|
|
285
292
|
```
|
|
286
293
|
|
|
287
|
-
Show daily token
|
|
294
|
+
Show daily token cost. `plain cost` reads
|
|
288
295
|
`~/.local/share/plain-agent/usage.jsonl`; use `--from` / `--to` to set the
|
|
289
|
-
period.
|
|
296
|
+
period. Costs are shown separately by currency.
|
|
290
297
|
|
|
291
298
|
```sh
|
|
292
299
|
plain cost
|
|
293
|
-
```
|
|
294
300
|
|
|
295
|
-
|
|
301
|
+
# Or
|
|
296
302
|
plain cost --from 2026-04-01 --to 2026-04-30
|
|
297
303
|
```
|
|
298
304
|
|
|
299
305
|
Resume a previously interrupted interactive session. Sessions are
|
|
300
|
-
|
|
301
|
-
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
|
|
302
308
|
is resumed. Use `--list` to see resumable sessions. Switching models is
|
|
303
|
-
not supported (`-m` is
|
|
309
|
+
not supported (`-m` is not allowed).
|
|
304
310
|
|
|
305
311
|
```sh
|
|
306
312
|
plain resume
|
|
307
|
-
```
|
|
308
313
|
|
|
309
|
-
|
|
314
|
+
# Or
|
|
310
315
|
plain resume --list
|
|
311
316
|
plain resume 2026-05-10-0803-a7k
|
|
312
317
|
```
|
|
313
318
|
|
|
314
|
-
|
|
319
|
+
Set up Plain Agent for your project.
|
|
315
320
|
|
|
316
321
|
```
|
|
317
322
|
/configure Auto-approve file writes and patches
|
|
@@ -343,7 +348,7 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
343
348
|
### Example
|
|
344
349
|
|
|
345
350
|
<details>
|
|
346
|
-
<summary><b>YOLO mode example (requires sandbox for safety)</b></summary>
|
|
351
|
+
<summary><b>YOLO mode example (requires a sandbox for safety)</b></summary>
|
|
347
352
|
|
|
348
353
|
```js
|
|
349
354
|
{
|
|
@@ -363,7 +368,7 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
363
368
|
"toolName": { "$regex": "^(web_search|web_fetch)$" },
|
|
364
369
|
"action": "allow"
|
|
365
370
|
}
|
|
366
|
-
// ⚠️ Never do this.
|
|
371
|
+
// ⚠️ Never do this. MCP runs outside the sandbox, so it can send anything externally.
|
|
367
372
|
// {
|
|
368
373
|
// "toolName": { "$regex": "." },
|
|
369
374
|
// "action": "allow"
|
|
@@ -395,10 +400,10 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
395
400
|
```js
|
|
396
401
|
{
|
|
397
402
|
"autoApproval": {
|
|
398
|
-
// Absolute paths outside the working directory
|
|
403
|
+
// Absolute paths outside the working directory that are allowed. Relative paths are ignored.
|
|
399
404
|
"allowedPaths": ["/tmp"],
|
|
400
405
|
"defaultAction": "ask",
|
|
401
|
-
//
|
|
406
|
+
// Maximum number of automatic approvals.
|
|
402
407
|
"maxApprovals": 50,
|
|
403
408
|
// Patterns are evaluated in order. First match wins.
|
|
404
409
|
"patterns": [
|
|
@@ -425,7 +430,7 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
425
430
|
"action": "allow"
|
|
426
431
|
},
|
|
427
432
|
|
|
428
|
-
// MCP
|
|
433
|
+
// MCP tool naming convention: mcp__<serverName>__<toolName>
|
|
429
434
|
{
|
|
430
435
|
"toolName": { "$regex": "mcp__slack__slack_(read|search)_.+" },
|
|
431
436
|
"action": "allow"
|
|
@@ -467,7 +472,7 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
467
472
|
"command": "npx",
|
|
468
473
|
"args": ["-y", "chrome-devtools-mcp@latest", "--isolated"]
|
|
469
474
|
},
|
|
470
|
-
// ⚠️ Add to config.local.json to avoid committing secrets to Git
|
|
475
|
+
// ⚠️ Add this to config.local.json to avoid committing secrets to Git
|
|
471
476
|
"slack": {
|
|
472
477
|
"command": "npx",
|
|
473
478
|
"args": ["-y", "mcp-remote", "https://mcp.slack.com/mcp", "--header", "Authorization:Bearer <SLACK_TOKEN>"],
|
|
@@ -476,7 +481,7 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
476
481
|
"command": "npx",
|
|
477
482
|
"args": ["-y", "mcp-remote", "https://mcp.notion.com/mcp"],
|
|
478
483
|
"options": {
|
|
479
|
-
// Enable only specific tools
|
|
484
|
+
// Enable only specific tools. If not specified, all tools are enabled.
|
|
480
485
|
"enabledTools": ["notion-search", "notion-fetch"]
|
|
481
486
|
}
|
|
482
487
|
},
|
|
@@ -484,18 +489,18 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
484
489
|
"command": "npx",
|
|
485
490
|
"args": ["-y", "mcp-remote", "https://knowledge-mcp.global.api.aws"]
|
|
486
491
|
},
|
|
487
|
-
// ⚠️ Add to config.local.json to avoid committing secrets to Git
|
|
492
|
+
// ⚠️ Add this to config.local.json to avoid committing secrets to Git
|
|
488
493
|
"google_developer-knowledge": {
|
|
489
494
|
"command": "npx",
|
|
490
495
|
"args": ["-y", "mcp-remote", "https://developerknowledge.googleapis.com/mcp", "--header", "X-Goog-Api-Key:<GOOGLE_API_KEY>"]
|
|
491
496
|
}
|
|
492
497
|
},
|
|
493
498
|
|
|
494
|
-
// Override default notification command
|
|
499
|
+
// Override the default notification command
|
|
495
500
|
"notifyCmd": { "command": "plain-notify-desktop", "args": [] }
|
|
496
501
|
|
|
497
502
|
// Voice input. See "Voice Input" below.
|
|
498
|
-
// ⚠️ Add to config.local.json to avoid committing secrets to Git
|
|
503
|
+
// ⚠️ Add this to config.local.json to avoid committing secrets to Git
|
|
499
504
|
"voiceInput": {
|
|
500
505
|
"provider": "openai",
|
|
501
506
|
"apiKey": "<OPENAI_API_KEY>"
|
|
@@ -506,7 +511,7 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
506
511
|
|
|
507
512
|
## Available Tools
|
|
508
513
|
|
|
509
|
-
The agent can use the following tools
|
|
514
|
+
The agent can use the following tools:
|
|
510
515
|
|
|
511
516
|
- **read_file**: Read a file with line numbers (1-indexed). Supports `offset` and `limit` to read a specific range.
|
|
512
517
|
- **write_file**: Write a file.
|
|
@@ -517,7 +522,7 @@ The agent can use the following tools to assist with tasks:
|
|
|
517
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`).
|
|
518
523
|
- **switch_to_subagent**: Switch to a subagent role within the same conversation, focusing on the specified goal.
|
|
519
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.
|
|
520
|
-
- **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.
|
|
521
526
|
|
|
522
527
|
## Prompts
|
|
523
528
|
|
|
@@ -551,7 +556,7 @@ Prompts located in a `shortcuts/` subdirectory (e.g., `.plain-agent/prompts/shor
|
|
|
551
556
|
|
|
552
557
|
## Subagents
|
|
553
558
|
|
|
554
|
-
Subagents are specialized
|
|
559
|
+
Subagents are specialized helpers for specific tasks.
|
|
555
560
|
|
|
556
561
|
### Locations
|
|
557
562
|
|
|
@@ -577,12 +582,7 @@ You are a web content reader and analyzer. Given a URL and a question, you:
|
|
|
577
582
|
|
|
578
583
|
## Claude Code Plugin Support
|
|
579
584
|
|
|
580
|
-
Plugins are installed under `.plain-agent/claude-code-plugins/` and must be
|
|
581
|
-
installed per project by running `plain install-claude-code-plugins` from
|
|
582
|
-
the project root. Global installation (e.g., under `~/.plain-agent`) is not
|
|
583
|
-
supported, because plugins may include skills that the agent invokes
|
|
584
|
-
autonomously, and scoping them to the project keeps approval rules and
|
|
585
|
-
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.
|
|
586
586
|
|
|
587
587
|
Example:
|
|
588
588
|
|
|
@@ -613,15 +613,13 @@ plain install-claude-code-plugins
|
|
|
613
613
|
|
|
614
614
|
## Voice Input
|
|
615
615
|
|
|
616
|
-
Press **Ctrl-O** to start recording, press it again to stop. Partial
|
|
617
|
-
transcripts are inserted into the prompt as you speak so you can edit
|
|
618
|
-
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.
|
|
619
617
|
|
|
620
618
|
### Requirements
|
|
621
619
|
|
|
622
620
|
- A recording command on `PATH`: `arecord`, `sox`, or `ffmpeg`.
|
|
623
621
|
- An API key for the chosen provider.
|
|
624
|
-
- Your host must have microphone access.
|
|
622
|
+
- Your host must have microphone access.
|
|
625
623
|
|
|
626
624
|
### Providers
|
|
627
625
|
|
|
@@ -655,17 +653,17 @@ and send them like regular text.
|
|
|
655
653
|
|
|
656
654
|
### Options
|
|
657
655
|
|
|
658
|
-
- `toggleKey` — Rebind the toggle. Accepts `"ctrl-<char>"` where `<char>`
|
|
656
|
+
- `toggleKey` — Rebind the toggle key. Accepts `"ctrl-<char>"` where `<char>`
|
|
659
657
|
is a letter (a-z) or one of `[ \ ] ^ _`. Defaults to `"ctrl-o"`.
|
|
660
|
-
- `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).
|
|
661
659
|
|
|
662
660
|
## Development
|
|
663
661
|
|
|
664
662
|
```sh
|
|
665
|
-
# Run lint, typecheck, and
|
|
663
|
+
# Run lint, typecheck, and tests
|
|
666
664
|
npm run check
|
|
667
665
|
|
|
668
|
-
# Fix lint
|
|
666
|
+
# Fix lint issues
|
|
669
667
|
npm run fix
|
|
670
668
|
# or
|
|
671
669
|
npm run fix -- --unsafe
|
|
@@ -692,7 +690,7 @@ npm publish --access public
|
|
|
692
690
|
<summary><b>Amazon Bedrock</b></summary>
|
|
693
691
|
|
|
694
692
|
```sh
|
|
695
|
-
# IAM Identity Center
|
|
693
|
+
# IAM Identity Center
|
|
696
694
|
identity_center_instance_arn="<IDENTITY_CENTER_INSTANCE_ARN>" # e.g., arn:aws:sso:::instance/ssoins-xxxxxxxxxxxxxxxx"
|
|
697
695
|
identity_store_id=<IDENTITY_STORE_ID>
|
|
698
696
|
aws_account_id=<AWS_ACCOUNT_ID>
|
|
@@ -700,7 +698,7 @@ aws_account_id=<AWS_ACCOUNT_ID>
|
|
|
700
698
|
# Create a permission set
|
|
701
699
|
permission_set_arn=$(aws sso-admin create-permission-set \
|
|
702
700
|
--instance-arn "$identity_center_instance_arn" \
|
|
703
|
-
--name "
|
|
701
|
+
--name "BedrockCodingAgent" \
|
|
704
702
|
--description "Allows only Bedrock model invocation" \
|
|
705
703
|
--query "PermissionSet.PermissionSetArn" --output text)
|
|
706
704
|
|
|
@@ -775,7 +773,7 @@ aws bedrock-runtime invoke-model \
|
|
|
775
773
|
|
|
776
774
|
```sh
|
|
777
775
|
resource_group=<RESOURCE_GROUP>
|
|
778
|
-
account_name=<ACCOUNT_NAME> #
|
|
776
|
+
account_name=<ACCOUNT_NAME> # Resource name
|
|
779
777
|
|
|
780
778
|
# Create a service principal
|
|
781
779
|
service_principal=$(az ad sp create-for-rbac --name "CodingAgentServicePrincipal" --skip-assignment)
|
|
@@ -798,7 +796,7 @@ az role assignment create \
|
|
|
798
796
|
export app_secret=$(echo "$service_principal" | jq -r .password)
|
|
799
797
|
export tenant_id=$(echo "$service_principal" | jq -r .tenant)
|
|
800
798
|
|
|
801
|
-
export AZURE_CONFIG_DIR=$HOME/.azure-for-agent # Change
|
|
799
|
+
export AZURE_CONFIG_DIR=$HOME/.azure-for-agent # Change this to store credentials elsewhere
|
|
802
800
|
az login --service-principal -u "$app_id" -p "$app_secret" --tenant "$tenant_id"
|
|
803
801
|
```
|
|
804
802
|
</details>
|
|
@@ -916,7 +916,7 @@
|
|
|
916
916
|
},
|
|
917
917
|
|
|
918
918
|
{
|
|
919
|
-
"name": "gpt-5.
|
|
919
|
+
"name": "gpt-5.4-mini",
|
|
920
920
|
"variant": "thinking-medium-azure",
|
|
921
921
|
"platform": {
|
|
922
922
|
"name": "azure",
|
|
@@ -925,8 +925,149 @@
|
|
|
925
925
|
"model": {
|
|
926
926
|
"format": "openai-responses",
|
|
927
927
|
"config": {
|
|
928
|
-
"model": "gpt-5.
|
|
929
|
-
"reasoning": { "effort": "medium", "summary": "auto" }
|
|
928
|
+
"model": "gpt-5.4-mini",
|
|
929
|
+
"reasoning": { "effort": "medium", "summary": "auto" },
|
|
930
|
+
"store": false,
|
|
931
|
+
"include": ["reasoning.encrypted_content"]
|
|
932
|
+
}
|
|
933
|
+
},
|
|
934
|
+
"cost": {
|
|
935
|
+
"currency": "USD",
|
|
936
|
+
"unit": "1M",
|
|
937
|
+
"costs": {
|
|
938
|
+
"input_tokens": 0.75,
|
|
939
|
+
"input_tokens_details.cached_tokens": -0.675,
|
|
940
|
+
"output_tokens": 4.5
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
},
|
|
944
|
+
{
|
|
945
|
+
"name": "gpt-5.4-mini",
|
|
946
|
+
"variant": "thinking-high-azure",
|
|
947
|
+
"platform": {
|
|
948
|
+
"name": "azure",
|
|
949
|
+
"variant": "openai"
|
|
950
|
+
},
|
|
951
|
+
"model": {
|
|
952
|
+
"format": "openai-responses",
|
|
953
|
+
"config": {
|
|
954
|
+
"model": "gpt-5.4-mini",
|
|
955
|
+
"reasoning": { "effort": "high", "summary": "auto" },
|
|
956
|
+
"store": false,
|
|
957
|
+
"include": ["reasoning.encrypted_content"]
|
|
958
|
+
}
|
|
959
|
+
},
|
|
960
|
+
"cost": {
|
|
961
|
+
"currency": "USD",
|
|
962
|
+
"unit": "1M",
|
|
963
|
+
"costs": {
|
|
964
|
+
"input_tokens": 0.75,
|
|
965
|
+
"input_tokens_details.cached_tokens": -0.675,
|
|
966
|
+
"output_tokens": 4.5
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
},
|
|
970
|
+
{
|
|
971
|
+
"name": "gpt-5.4-mini",
|
|
972
|
+
"variant": "thinking-xhigh-azure",
|
|
973
|
+
"platform": {
|
|
974
|
+
"name": "azure",
|
|
975
|
+
"variant": "openai"
|
|
976
|
+
},
|
|
977
|
+
"model": {
|
|
978
|
+
"format": "openai-responses",
|
|
979
|
+
"config": {
|
|
980
|
+
"model": "gpt-5.4-mini",
|
|
981
|
+
"reasoning": { "effort": "xhigh", "summary": "auto" },
|
|
982
|
+
"store": false,
|
|
983
|
+
"include": ["reasoning.encrypted_content"]
|
|
984
|
+
}
|
|
985
|
+
},
|
|
986
|
+
"cost": {
|
|
987
|
+
"currency": "USD",
|
|
988
|
+
"unit": "1M",
|
|
989
|
+
"costs": {
|
|
990
|
+
"input_tokens": 0.75,
|
|
991
|
+
"input_tokens_details.cached_tokens": -0.675,
|
|
992
|
+
"output_tokens": 4.5
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
},
|
|
996
|
+
{
|
|
997
|
+
"name": "gpt-5.5",
|
|
998
|
+
"variant": "thinking-medium-azure",
|
|
999
|
+
"platform": {
|
|
1000
|
+
"name": "azure",
|
|
1001
|
+
"variant": "openai"
|
|
1002
|
+
},
|
|
1003
|
+
"model": {
|
|
1004
|
+
"format": "openai-responses",
|
|
1005
|
+
"config": {
|
|
1006
|
+
"model": "gpt-5.5",
|
|
1007
|
+
"reasoning": { "effort": "medium", "summary": "auto" },
|
|
1008
|
+
"store": false,
|
|
1009
|
+
"include": ["reasoning.encrypted_content"]
|
|
1010
|
+
}
|
|
1011
|
+
},
|
|
1012
|
+
"cost": {
|
|
1013
|
+
"currency": "USD",
|
|
1014
|
+
"unit": "1M",
|
|
1015
|
+
"costs": {
|
|
1016
|
+
"input_tokens": 5,
|
|
1017
|
+
"input_tokens_details.cached_tokens": -4.5,
|
|
1018
|
+
"output_tokens": 30
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
},
|
|
1022
|
+
{
|
|
1023
|
+
"name": "gpt-5.5",
|
|
1024
|
+
"variant": "thinking-high-azure",
|
|
1025
|
+
"platform": {
|
|
1026
|
+
"name": "azure",
|
|
1027
|
+
"variant": "openai"
|
|
1028
|
+
},
|
|
1029
|
+
"model": {
|
|
1030
|
+
"format": "openai-responses",
|
|
1031
|
+
"config": {
|
|
1032
|
+
"model": "gpt-5.5",
|
|
1033
|
+
"reasoning": { "effort": "high", "summary": "auto" },
|
|
1034
|
+
"store": false,
|
|
1035
|
+
"include": ["reasoning.encrypted_content"]
|
|
1036
|
+
}
|
|
1037
|
+
},
|
|
1038
|
+
"cost": {
|
|
1039
|
+
"currency": "USD",
|
|
1040
|
+
"unit": "1M",
|
|
1041
|
+
"costs": {
|
|
1042
|
+
"input_tokens": 5,
|
|
1043
|
+
"input_tokens_details.cached_tokens": -4.5,
|
|
1044
|
+
"output_tokens": 30
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
},
|
|
1048
|
+
{
|
|
1049
|
+
"name": "gpt-5.5",
|
|
1050
|
+
"variant": "thinking-xhigh-azure",
|
|
1051
|
+
"platform": {
|
|
1052
|
+
"name": "azure",
|
|
1053
|
+
"variant": "openai"
|
|
1054
|
+
},
|
|
1055
|
+
"model": {
|
|
1056
|
+
"format": "openai-responses",
|
|
1057
|
+
"config": {
|
|
1058
|
+
"model": "gpt-5.5",
|
|
1059
|
+
"reasoning": { "effort": "xhigh", "summary": "auto" },
|
|
1060
|
+
"store": false,
|
|
1061
|
+
"include": ["reasoning.encrypted_content"]
|
|
1062
|
+
}
|
|
1063
|
+
},
|
|
1064
|
+
"cost": {
|
|
1065
|
+
"currency": "USD",
|
|
1066
|
+
"unit": "1M",
|
|
1067
|
+
"costs": {
|
|
1068
|
+
"input_tokens": 5,
|
|
1069
|
+
"input_tokens_details.cached_tokens": -4.5,
|
|
1070
|
+
"output_tokens": 30
|
|
930
1071
|
}
|
|
931
1072
|
}
|
|
932
1073
|
},
|
|
@@ -978,6 +1119,30 @@
|
|
|
978
1119
|
}
|
|
979
1120
|
}
|
|
980
1121
|
},
|
|
1122
|
+
{
|
|
1123
|
+
"name": "glm-5",
|
|
1124
|
+
"variant": "bedrock",
|
|
1125
|
+
"platform": {
|
|
1126
|
+
"name": "bedrock",
|
|
1127
|
+
"variant": "default"
|
|
1128
|
+
},
|
|
1129
|
+
"model": {
|
|
1130
|
+
"format": "openai-messages",
|
|
1131
|
+
"config": {
|
|
1132
|
+
"model": "zai.glm-5",
|
|
1133
|
+
"reasoning_effort": "high"
|
|
1134
|
+
}
|
|
1135
|
+
},
|
|
1136
|
+
"cost": {
|
|
1137
|
+
"currency": "USD",
|
|
1138
|
+
"unit": "1M",
|
|
1139
|
+
"costs": {
|
|
1140
|
+
"prompt_tokens": 1,
|
|
1141
|
+
"completion_tokens": 3.2
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
},
|
|
1145
|
+
|
|
981
1146
|
{
|
|
982
1147
|
"name": "glm-5.1",
|
|
983
1148
|
"variant": "fireworks",
|
|
@@ -1071,6 +1236,21 @@
|
|
|
1071
1236
|
}
|
|
1072
1237
|
}
|
|
1073
1238
|
},
|
|
1239
|
+
{
|
|
1240
|
+
"name": "kimi-k2.6",
|
|
1241
|
+
"variant": "azure",
|
|
1242
|
+
"platform": {
|
|
1243
|
+
"name": "azure",
|
|
1244
|
+
"variant": "default"
|
|
1245
|
+
},
|
|
1246
|
+
"model": {
|
|
1247
|
+
"format": "openai-messages",
|
|
1248
|
+
"config": {
|
|
1249
|
+
"model": "Kimi-K2.6",
|
|
1250
|
+
"reasoning_effort": "high"
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
},
|
|
1074
1254
|
|
|
1075
1255
|
{
|
|
1076
1256
|
"name": "deepseek-v4-pro",
|
|
@@ -1118,6 +1298,21 @@
|
|
|
1118
1298
|
}
|
|
1119
1299
|
}
|
|
1120
1300
|
},
|
|
1301
|
+
{
|
|
1302
|
+
"name": "deepseek-v4-pro",
|
|
1303
|
+
"variant": "azure",
|
|
1304
|
+
"platform": {
|
|
1305
|
+
"name": "azure",
|
|
1306
|
+
"variant": "default"
|
|
1307
|
+
},
|
|
1308
|
+
"model": {
|
|
1309
|
+
"format": "openai-messages",
|
|
1310
|
+
"config": {
|
|
1311
|
+
"model": "DeepSeek-V4-Pro",
|
|
1312
|
+
"reasoning_effort": "high"
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
},
|
|
1121
1316
|
|
|
1122
1317
|
{
|
|
1123
1318
|
"name": "minimax-m2.7",
|
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
|
/**
|
|
@@ -8,6 +8,7 @@ import { styleText } from "node:util";
|
|
|
8
8
|
import { noThrow } from "../utils/noThrow.mjs";
|
|
9
9
|
import { retryOnError } from "../utils/retryOnError.mjs";
|
|
10
10
|
import { loadAwsCredentials, signAwsRequest } from "./platform/awsSigV4.mjs";
|
|
11
|
+
import { getAzureAccessToken } from "./platform/azure.mjs";
|
|
11
12
|
import { readBedrockStreamEvents } from "./platform/bedrock.mjs";
|
|
12
13
|
import { getGoogleCloudAccessToken } from "./platform/googleCloud.mjs";
|
|
13
14
|
|
|
@@ -43,6 +44,8 @@ export async function callOpenAICompatibleModel(
|
|
|
43
44
|
return `${baseURL}/model/${modelConfig.model}/invoke-with-response-stream`;
|
|
44
45
|
case "vertex-ai":
|
|
45
46
|
return `${baseURL}/endpoints/openapi/chat/completions`;
|
|
47
|
+
case "azure":
|
|
48
|
+
return `${baseURL}/models/chat/completions?api-version=2024-05-01-preview`;
|
|
46
49
|
default:
|
|
47
50
|
throw new Error(`Unsupported platform: ${platformConfig.name}`);
|
|
48
51
|
}
|
|
@@ -64,6 +67,15 @@ export async function callOpenAICompatibleModel(
|
|
|
64
67
|
...platformConfig.customHeaders,
|
|
65
68
|
Authorization: `Bearer ${await getGoogleCloudAccessToken()}`,
|
|
66
69
|
};
|
|
70
|
+
case "azure":
|
|
71
|
+
return {
|
|
72
|
+
...platformConfig.customHeaders,
|
|
73
|
+
Authorization: `Bearer ${await getAzureAccessToken(
|
|
74
|
+
platformConfig.azureConfigDir
|
|
75
|
+
? { azureConfigDir: platformConfig.azureConfigDir }
|
|
76
|
+
: undefined,
|
|
77
|
+
)}`,
|
|
78
|
+
};
|
|
67
79
|
}
|
|
68
80
|
})();
|
|
69
81
|
|
|
@@ -83,7 +95,11 @@ export async function callOpenAICompatibleModel(
|
|
|
83
95
|
case "vertex-ai":
|
|
84
96
|
return {
|
|
85
97
|
...modelConfig,
|
|
86
|
-
|
|
98
|
+
stream: true,
|
|
99
|
+
};
|
|
100
|
+
case "azure":
|
|
101
|
+
return {
|
|
102
|
+
...modelConfig,
|
|
87
103
|
stream: true,
|
|
88
104
|
};
|
|
89
105
|
}
|
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
|
|