pi-prompt-template-model 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +150 -2
- package/args.ts +174 -1
- package/chain-parser.ts +162 -0
- package/index.ts +516 -158
- package/loop-utils.ts +67 -0
- package/package.json +4 -1
- package/prompt-loader.ts +144 -17
- package/tool-manager.ts +216 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.5.0] - 2026-03-17
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Loop execution via `--loop` flag: `--loop N`, `--loop=N` to run a prompt N times (1-999), or bare `--loop` for unlimited until convergence with a 50-iteration safety cap. Bare `--loop` always forces convergence on.
|
|
10
|
+
- Frontmatter loop controls: templates can now set `loop: N` (1-999) and `converge: false` defaults; CLI `--loop` overrides frontmatter `loop`, and `--no-converge` disables convergence for bounded loops.
|
|
11
|
+
- Convergence detection: loops stop early when an iteration makes no file changes (`write`/`edit`). Enabled by default; `--no-converge` opts out.
|
|
12
|
+
- Fresh context mode: `--fresh` flag or `fresh: true` frontmatter collapses conversation between loop iterations, keeping only accumulated summaries. Saves tokens on long loops.
|
|
13
|
+
- Loop iteration context injected into the system prompt so the agent builds on previous work across iterations.
|
|
14
|
+
- Loop progress indicator in the TUI status bar.
|
|
15
|
+
- `run-prompt` agent tool: the agent can run prompt templates, chains, and loops on its own. Opt-in via `/prompt-tool on [guidance]`. Config persists in `~/.pi/agent/prompt-template-model.json`.
|
|
16
|
+
- Chain templates: new `chain` frontmatter field to declare reusable template pipelines (`chain: double-check --loop 2 -> deslop --loop 2`). Per-step `--loop N` loops each step independently. No `model` required — each step uses its own. Supports `loop`, `fresh`, `converge`, `restore` for overall execution control. Chain nesting is rejected at runtime.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- `readSkillContent` no longer swallows read errors. The caller now sees the actual error message (e.g., permission denied) instead of a generic "Failed to read skill" notification.
|
|
21
|
+
- `restoreSessionState` no longer clears `pendingSkill` as a side effect unrelated to model/thinking restoration.
|
|
22
|
+
- Error diagnostics now consistently use `String(error)` instead of hardcoded fallback strings.
|
|
23
|
+
|
|
3
24
|
## [0.4.0] - 2026-03-13
|
|
4
25
|
|
|
5
26
|
### Added
|
package/README.md
CHANGED
|
@@ -86,11 +86,15 @@ Here `skill: surf` loads `~/.pi/agent/skills/surf/SKILL.md` and injects its cont
|
|
|
86
86
|
|
|
87
87
|
| Field | Required | Default | Description |
|
|
88
88
|
|-------|----------|---------|-------------|
|
|
89
|
-
| `model` |
|
|
89
|
+
| `model` | Conditional | - | Required for non-chain templates; ignored when `chain` is set |
|
|
90
|
+
| `chain` | Conditional | - | Chain declaration (`step -> step --loop 2`) for orchestration templates; body is ignored |
|
|
90
91
|
| `skill` | No | - | Skill name to inject into system prompt |
|
|
91
92
|
| `thinking` | No | - | Thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh` |
|
|
92
93
|
| `description` | No | - | Shown in autocomplete |
|
|
93
94
|
| `restore` | No | `true` | Restore previous model and thinking level after response |
|
|
95
|
+
| `fresh` | No | `false` | Collapse context between loop iterations (applies when looping via `--loop` or frontmatter `loop`) |
|
|
96
|
+
| `loop` | No | - | Default loop count for this template (`1`-`999`) |
|
|
97
|
+
| `converge` | No | `true` | Loop convergence behavior; set `false` to always run all iterations |
|
|
94
98
|
|
|
95
99
|
## Model Format
|
|
96
100
|
|
|
@@ -332,6 +336,149 @@ Step 1 uses its per-step args (`"error handling"`), steps 2 and 3 fall back to t
|
|
|
332
336
|
|
|
333
337
|
The chain captures your current model and thinking level before starting, and restores them when the chain finishes (or if any step fails mid-chain). Individual template `restore` settings are ignored during chain execution.
|
|
334
338
|
|
|
339
|
+
### Chain Templates
|
|
340
|
+
|
|
341
|
+
For reusable pipelines, define a chain in frontmatter instead of typing `/chain-prompts` every time:
|
|
342
|
+
|
|
343
|
+
```markdown
|
|
344
|
+
---
|
|
345
|
+
description: Review then clean up
|
|
346
|
+
chain: double-check --loop 2 -> deslop --loop 2
|
|
347
|
+
---
|
|
348
|
+
ignored — chain templates don't use the body
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
This registers `/review-then-clean` as a command that runs `double-check` twice, then `deslop` twice. Each step references a separate prompt template with its own `model`. The chain template itself doesn't need a `model` field — each step uses whatever model its template specifies.
|
|
352
|
+
|
|
353
|
+
Per-step `--loop N` repeats that step N times before moving to the next. Per-step convergence applies: if a step makes no file changes on an iteration, its inner loop stops early (unless the step's template has `converge: false`).
|
|
354
|
+
|
|
355
|
+
Chain templates support `loop`, `fresh`, `converge`, and `restore` in their frontmatter for overall execution control:
|
|
356
|
+
|
|
357
|
+
```markdown
|
|
358
|
+
---
|
|
359
|
+
chain: analyze -> fix
|
|
360
|
+
loop: 3
|
|
361
|
+
fresh: true
|
|
362
|
+
converge: false
|
|
363
|
+
---
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
This runs the full analyze → fix chain 3 times, with fresh context between iterations and no early stopping. CLI `--loop` overrides frontmatter `loop` when invoking the command.
|
|
367
|
+
|
|
368
|
+
Chain nesting is not supported — a chain template's steps cannot reference other chain templates.
|
|
369
|
+
|
|
370
|
+
## Loop Execution
|
|
371
|
+
|
|
372
|
+
Looping uses the `--loop` flag:
|
|
373
|
+
|
|
374
|
+
```
|
|
375
|
+
/deslop --loop 5
|
|
376
|
+
/deslop --loop=5
|
|
377
|
+
/deslop "focus on performance" --loop 3
|
|
378
|
+
/deslop --loop
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
`--loop` without a number means unlimited looping until convergence, with a built-in safety cap of 50 iterations.
|
|
382
|
+
|
|
383
|
+
You can also set a default loop count in frontmatter:
|
|
384
|
+
|
|
385
|
+
```markdown
|
|
386
|
+
---
|
|
387
|
+
model: claude-sonnet-4-20250514
|
|
388
|
+
loop: 5
|
|
389
|
+
---
|
|
390
|
+
...
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
With that template, `/deslop` runs 5 iterations by default. CLI `--loop` overrides frontmatter (`/deslop --loop 3` runs 3 iterations).
|
|
394
|
+
|
|
395
|
+
The agent runs the same prompt N times. Context accumulates across iterations — by iteration 3, the agent sees the full conversation from iterations 1 and 2 and builds on that work. Use `--fresh` to collapse context between iterations instead (see below).
|
|
396
|
+
|
|
397
|
+
By default, the loop stops early if an iteration makes no file changes (no `write` or `edit` tool calls), since there's nothing left to improve. Add `--no-converge` to always run all iterations for bounded loops, or set `converge: false` in frontmatter:
|
|
398
|
+
|
|
399
|
+
```
|
|
400
|
+
/deslop --loop 5 --no-converge
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
```markdown
|
|
404
|
+
---
|
|
405
|
+
model: claude-sonnet-4-20250514
|
|
406
|
+
loop: 5
|
|
407
|
+
converge: false
|
|
408
|
+
---
|
|
409
|
+
...
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Bare `--loop` always forces convergence on (even with `--no-converge` or `converge: false`) because its intent is "run until no changes." `--loop N` and `--loop=N` support range 1-999. Quoted `"--loop"` is treated as a regular argument.
|
|
413
|
+
|
|
414
|
+
Model, thinking level, and skill are maintained throughout the loop. If the template has `restore: true` (the default), the original model and thinking level are restored after the final iteration (or if any iteration fails). If `restore: false`, the switched model persists after the loop ends.
|
|
415
|
+
|
|
416
|
+
### Fresh Context
|
|
417
|
+
|
|
418
|
+
Add `--fresh` to collapse context between iterations:
|
|
419
|
+
|
|
420
|
+
```
|
|
421
|
+
/deslop --loop 5 --fresh
|
|
422
|
+
/deslop --fresh # when frontmatter sets loop: N
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
Each iteration's conversation is collapsed to a brief summary (files read, files modified, outcome) before the next iteration starts. The agent sees accumulated summaries from all previous iterations but not the full conversation. This saves tokens on long loops and gives each iteration a clean slate for reasoning.
|
|
426
|
+
|
|
427
|
+
You can also set `fresh: true` in the template frontmatter to make it the default when looped:
|
|
428
|
+
|
|
429
|
+
```markdown
|
|
430
|
+
---
|
|
431
|
+
description: Remove AI slop from code
|
|
432
|
+
model: claude-sonnet-4-20250514
|
|
433
|
+
fresh: true
|
|
434
|
+
---
|
|
435
|
+
Review the codebase and improve code quality. $@
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### Loop with Chains
|
|
439
|
+
|
|
440
|
+
Chains support the same looping forms:
|
|
441
|
+
|
|
442
|
+
```
|
|
443
|
+
/chain-prompts analyze -> fix --loop 3 -- src/main.ts
|
|
444
|
+
/chain-prompts analyze -> fix --loop=3 -- src/main.ts
|
|
445
|
+
/chain-prompts analyze -> fix --loop -- src/main.ts
|
|
446
|
+
/chain-prompts analyze -> fix --loop 3 --fresh -- src/main.ts
|
|
447
|
+
/chain-prompts analyze -> fix --loop 3 --no-converge -- src/main.ts
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
This runs the full chain (analyze → fix) three times. Convergence detection applies across all steps in each iteration — if no step made file changes, the loop stops. Each iteration re-reads prompts from disk, so template edits take effect between iterations. The status bar shows `loop 2/3` during execution.
|
|
451
|
+
|
|
452
|
+
## Agent Tool
|
|
453
|
+
|
|
454
|
+
The agent can run prompt templates on its own via the `run-prompt` tool. Disabled by default — enable it with:
|
|
455
|
+
|
|
456
|
+
```
|
|
457
|
+
/prompt-tool on
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Once enabled, the agent sees `run-prompt` in its tool list and can call it with any template command:
|
|
461
|
+
|
|
462
|
+
```
|
|
463
|
+
run-prompt({ command: "deslop --loop 5 --fresh" })
|
|
464
|
+
run-prompt({ command: "deslop --loop" })
|
|
465
|
+
run-prompt({ command: "chain-prompts analyze -> fix --loop 3" })
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
The tool queues the command for execution when the agent's current turn ends. All loop, fresh context, and convergence features work the same as when invoked via slash commands.
|
|
469
|
+
|
|
470
|
+
Add guidance to steer when the agent uses it:
|
|
471
|
+
|
|
472
|
+
```
|
|
473
|
+
/prompt-tool on Use run-prompt for iterative code improvement tasks
|
|
474
|
+
/prompt-tool guidance Use sparingly, only for multi-pass refinement
|
|
475
|
+
/prompt-tool guidance clear
|
|
476
|
+
/prompt-tool off
|
|
477
|
+
/prompt-tool
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
Config persists across sessions in `~/.pi/agent/prompt-template-model.json`.
|
|
481
|
+
|
|
335
482
|
## Autocomplete Display
|
|
336
483
|
|
|
337
484
|
Commands show model, thinking level, and skill in the description:
|
|
@@ -358,4 +505,5 @@ The model switches, skill injects, agent responds, and output prints to stdout.
|
|
|
358
505
|
|
|
359
506
|
- Prompt files are reloaded on session start and whenever an extension-owned prompt command runs. If you add a brand-new prompt file while already inside a session, run another extension-owned command such as `/chain-prompts`, start a new session, or reload pi so the new slash command is registered.
|
|
360
507
|
- Model restore state is in-memory. Closing pi mid-response loses restore state.
|
|
361
|
-
-
|
|
508
|
+
- Chain steps must reference templates with a `model` field. Chain templates themselves use `chain` and do not execute their own body.
|
|
509
|
+
- The `run-prompt` tool must be explicitly enabled with `/prompt-tool on` before the agent can use it.
|
package/args.ts
CHANGED
|
@@ -1,3 +1,176 @@
|
|
|
1
|
+
export interface LoopExtraction {
|
|
2
|
+
args: string;
|
|
3
|
+
loopCount: number | null;
|
|
4
|
+
fresh: boolean;
|
|
5
|
+
converge: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface LoopFlags {
|
|
9
|
+
args: string;
|
|
10
|
+
fresh: boolean;
|
|
11
|
+
converge: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function extractLoopCount(argsString: string): LoopExtraction | null {
|
|
15
|
+
let loopCount: number | null = null;
|
|
16
|
+
let loopFound = false;
|
|
17
|
+
let fresh = false;
|
|
18
|
+
let noConverge = false;
|
|
19
|
+
const tokensToRemove: Array<{ start: number; end: number }> = [];
|
|
20
|
+
|
|
21
|
+
let i = 0;
|
|
22
|
+
while (i < argsString.length) {
|
|
23
|
+
const char = argsString[i];
|
|
24
|
+
|
|
25
|
+
if (char === '"' || char === "'") {
|
|
26
|
+
const quote = char;
|
|
27
|
+
i++;
|
|
28
|
+
while (i < argsString.length && argsString[i] !== quote) i++;
|
|
29
|
+
if (i < argsString.length) i++;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (/\s/.test(char)) {
|
|
34
|
+
i++;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const tokenStart = i;
|
|
39
|
+
while (i < argsString.length && !/\s/.test(argsString[i])) i++;
|
|
40
|
+
const token = argsString.slice(tokenStart, i);
|
|
41
|
+
|
|
42
|
+
if (!loopFound && (token === "--loop" || token.startsWith("--loop="))) {
|
|
43
|
+
if (token.startsWith("--loop=")) {
|
|
44
|
+
const value = token.slice("--loop=".length);
|
|
45
|
+
if (/^\d+$/.test(value)) {
|
|
46
|
+
const parsed = parseInt(value, 10);
|
|
47
|
+
if (parsed >= 1 && parsed <= 999) {
|
|
48
|
+
loopFound = true;
|
|
49
|
+
loopCount = parsed;
|
|
50
|
+
tokensToRemove.push({ start: tokenStart, end: i });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
let lookahead = i;
|
|
55
|
+
while (lookahead < argsString.length && /\s/.test(argsString[lookahead])) lookahead++;
|
|
56
|
+
|
|
57
|
+
if (lookahead < argsString.length && argsString[lookahead] !== '"' && argsString[lookahead] !== "'") {
|
|
58
|
+
const nextTokenStart = lookahead;
|
|
59
|
+
while (lookahead < argsString.length && !/\s/.test(argsString[lookahead])) lookahead++;
|
|
60
|
+
const nextToken = argsString.slice(nextTokenStart, lookahead);
|
|
61
|
+
|
|
62
|
+
if (/^\d+$/.test(nextToken)) {
|
|
63
|
+
const parsed = parseInt(nextToken, 10);
|
|
64
|
+
if (parsed >= 1 && parsed <= 999) {
|
|
65
|
+
loopFound = true;
|
|
66
|
+
loopCount = parsed;
|
|
67
|
+
tokensToRemove.push({ start: tokenStart, end: i }, { start: nextTokenStart, end: lookahead });
|
|
68
|
+
i = lookahead;
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
loopFound = true;
|
|
72
|
+
loopCount = null;
|
|
73
|
+
tokensToRemove.push({ start: tokenStart, end: i });
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
loopFound = true;
|
|
77
|
+
loopCount = null;
|
|
78
|
+
tokensToRemove.push({ start: tokenStart, end: i });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (token === "--fresh") {
|
|
84
|
+
fresh = true;
|
|
85
|
+
tokensToRemove.push({ start: tokenStart, end: i });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (token === "--no-converge") {
|
|
89
|
+
noConverge = true;
|
|
90
|
+
tokensToRemove.push({ start: tokenStart, end: i });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (loopCount === null && !loopFound) return null;
|
|
95
|
+
|
|
96
|
+
tokensToRemove.sort((a, b) => b.start - a.start);
|
|
97
|
+
let cleaned = argsString;
|
|
98
|
+
for (const { start, end } of tokensToRemove) {
|
|
99
|
+
cleaned = cleaned.slice(0, start) + cleaned.slice(end);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const converge = loopFound && loopCount === null ? true : !noConverge;
|
|
103
|
+
return { args: cleaned.trim(), loopCount, fresh, converge };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function extractLoopFlags(argsString: string): LoopFlags {
|
|
107
|
+
let fresh = false;
|
|
108
|
+
let noConverge = false;
|
|
109
|
+
const tokensToRemove: Array<{ start: number; end: number }> = [];
|
|
110
|
+
|
|
111
|
+
let i = 0;
|
|
112
|
+
while (i < argsString.length) {
|
|
113
|
+
const char = argsString[i];
|
|
114
|
+
|
|
115
|
+
if (char === '"' || char === "'") {
|
|
116
|
+
const quote = char;
|
|
117
|
+
i++;
|
|
118
|
+
while (i < argsString.length && argsString[i] !== quote) i++;
|
|
119
|
+
if (i < argsString.length) i++;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (/\s/.test(char)) {
|
|
124
|
+
i++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const tokenStart = i;
|
|
129
|
+
while (i < argsString.length && !/\s/.test(argsString[i])) i++;
|
|
130
|
+
const token = argsString.slice(tokenStart, i);
|
|
131
|
+
|
|
132
|
+
if (token === "--fresh") {
|
|
133
|
+
fresh = true;
|
|
134
|
+
tokensToRemove.push({ start: tokenStart, end: i });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (token === "--no-converge") {
|
|
138
|
+
noConverge = true;
|
|
139
|
+
tokensToRemove.push({ start: tokenStart, end: i });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
tokensToRemove.sort((a, b) => b.start - a.start);
|
|
144
|
+
let cleaned = argsString;
|
|
145
|
+
for (const { start, end } of tokensToRemove) {
|
|
146
|
+
cleaned = cleaned.slice(0, start) + cleaned.slice(end);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { args: cleaned.trim(), fresh, converge: !noConverge };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function splitByUnquotedSeparator(input: string, separator: string): string[] {
|
|
153
|
+
const parts: string[] = [];
|
|
154
|
+
let start = 0;
|
|
155
|
+
let inQuote: string | null = null;
|
|
156
|
+
|
|
157
|
+
for (let i = 0; i < input.length; i++) {
|
|
158
|
+
const char = input[i];
|
|
159
|
+
if (inQuote) {
|
|
160
|
+
if (char === inQuote) inQuote = null;
|
|
161
|
+
} else if (char === '"' || char === "'") {
|
|
162
|
+
inQuote = char;
|
|
163
|
+
} else if (i <= input.length - separator.length && input.startsWith(separator, i)) {
|
|
164
|
+
parts.push(input.slice(start, i));
|
|
165
|
+
start = i + separator.length;
|
|
166
|
+
i += separator.length - 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
parts.push(input.slice(start));
|
|
171
|
+
return parts;
|
|
172
|
+
}
|
|
173
|
+
|
|
1
174
|
export function parseCommandArgs(argsString: string): string[] {
|
|
2
175
|
const args: string[] = [];
|
|
3
176
|
let current = "";
|
|
@@ -14,7 +187,7 @@ export function parseCommandArgs(argsString: string): string[] {
|
|
|
14
187
|
}
|
|
15
188
|
} else if (char === '"' || char === "'") {
|
|
16
189
|
inQuote = char;
|
|
17
|
-
} else if (char
|
|
190
|
+
} else if (/\s/.test(char)) {
|
|
18
191
|
if (current) {
|
|
19
192
|
args.push(current);
|
|
20
193
|
current = "";
|
package/chain-parser.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { parseCommandArgs, splitByUnquotedSeparator } from "./args.js";
|
|
2
|
+
|
|
3
|
+
export interface ChainStep {
|
|
4
|
+
name: string;
|
|
5
|
+
args: string[];
|
|
6
|
+
loopCount?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ParsedChainSteps {
|
|
10
|
+
steps: ChainStep[];
|
|
11
|
+
sharedArgs: string[];
|
|
12
|
+
invalidSegments: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ParsedChainDeclaration {
|
|
16
|
+
steps: ChainStep[];
|
|
17
|
+
invalidSegments: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface SegmentToken {
|
|
21
|
+
start: number;
|
|
22
|
+
end: number;
|
|
23
|
+
value: string;
|
|
24
|
+
quoted: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function scanSegmentTokens(segment: string): SegmentToken[] {
|
|
28
|
+
const tokens: SegmentToken[] = [];
|
|
29
|
+
let i = 0;
|
|
30
|
+
|
|
31
|
+
while (i < segment.length) {
|
|
32
|
+
while (i < segment.length && /\s/.test(segment[i])) i++;
|
|
33
|
+
if (i >= segment.length) break;
|
|
34
|
+
|
|
35
|
+
const start = i;
|
|
36
|
+
let inQuote: string | null = null;
|
|
37
|
+
let value = "";
|
|
38
|
+
let sawQuoted = false;
|
|
39
|
+
let sawUnquoted = false;
|
|
40
|
+
|
|
41
|
+
while (i < segment.length) {
|
|
42
|
+
const char = segment[i];
|
|
43
|
+
if (inQuote) {
|
|
44
|
+
if (char === inQuote) {
|
|
45
|
+
inQuote = null;
|
|
46
|
+
} else {
|
|
47
|
+
value += char;
|
|
48
|
+
}
|
|
49
|
+
i++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (char === '"' || char === "'") {
|
|
54
|
+
inQuote = char;
|
|
55
|
+
sawQuoted = true;
|
|
56
|
+
i++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (/\s/.test(char)) break;
|
|
60
|
+
|
|
61
|
+
value += char;
|
|
62
|
+
sawUnquoted = true;
|
|
63
|
+
i++;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
tokens.push({
|
|
67
|
+
start,
|
|
68
|
+
end: i,
|
|
69
|
+
value,
|
|
70
|
+
quoted: sawQuoted && !sawUnquoted,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return tokens;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function extractStepLoopCount(segment: string): { cleanedSegment: string; loopCount?: number } {
|
|
78
|
+
const tokens = scanSegmentTokens(segment);
|
|
79
|
+
|
|
80
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
81
|
+
const token = tokens[i];
|
|
82
|
+
if (token.quoted) continue;
|
|
83
|
+
|
|
84
|
+
if (token.value.startsWith("--loop=")) {
|
|
85
|
+
const value = token.value.slice("--loop=".length);
|
|
86
|
+
if (/^\d+$/.test(value)) {
|
|
87
|
+
const parsed = parseInt(value, 10);
|
|
88
|
+
if (parsed >= 1 && parsed <= 999) {
|
|
89
|
+
const cleanedSegment = `${segment.slice(0, token.start)}${segment.slice(token.end)}`.trim();
|
|
90
|
+
return { cleanedSegment, loopCount: parsed };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (token.value === "--loop" && i + 1 < tokens.length) {
|
|
97
|
+
const next = tokens[i + 1];
|
|
98
|
+
if (!next.quoted && /^\d+$/.test(next.value)) {
|
|
99
|
+
const parsed = parseInt(next.value, 10);
|
|
100
|
+
if (parsed >= 1 && parsed <= 999) {
|
|
101
|
+
const cleanedSegment = `${segment.slice(0, token.start)}${segment.slice(next.end)}`.trim();
|
|
102
|
+
return { cleanedSegment, loopCount: parsed };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { cleanedSegment: segment };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function parseChainSteps(args: string): ParsedChainSteps {
|
|
112
|
+
const sharedArgsSplit = splitByUnquotedSeparator(args, " -- ");
|
|
113
|
+
const templatesPart = sharedArgsSplit[0];
|
|
114
|
+
const argsPart = sharedArgsSplit.length > 1 ? sharedArgsSplit.slice(1).join(" -- ") : "";
|
|
115
|
+
|
|
116
|
+
const invalidSegments: string[] = [];
|
|
117
|
+
const steps: ChainStep[] = [];
|
|
118
|
+
|
|
119
|
+
for (const rawSegment of splitByUnquotedSeparator(templatesPart, "->")) {
|
|
120
|
+
const segment = rawSegment.trim();
|
|
121
|
+
if (!segment) {
|
|
122
|
+
invalidSegments.push(rawSegment);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const tokens = parseCommandArgs(segment);
|
|
126
|
+
if (tokens.length === 0) {
|
|
127
|
+
invalidSegments.push(segment);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
steps.push({ name: tokens[0], args: tokens.slice(1) });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { steps, sharedArgs: parseCommandArgs(argsPart), invalidSegments };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function parseChainDeclaration(chain: string): ParsedChainDeclaration {
|
|
137
|
+
const invalidSegments: string[] = [];
|
|
138
|
+
const steps: ChainStep[] = [];
|
|
139
|
+
|
|
140
|
+
for (const rawSegment of splitByUnquotedSeparator(chain, "->")) {
|
|
141
|
+
const segment = rawSegment.trim();
|
|
142
|
+
if (!segment) {
|
|
143
|
+
invalidSegments.push(rawSegment);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const { cleanedSegment, loopCount } = extractStepLoopCount(segment);
|
|
148
|
+
const tokens = parseCommandArgs(cleanedSegment);
|
|
149
|
+
if (tokens.length === 0) {
|
|
150
|
+
invalidSegments.push(segment);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
steps.push({
|
|
155
|
+
name: tokens[0],
|
|
156
|
+
args: tokens.slice(1),
|
|
157
|
+
loopCount,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { steps, invalidSegments };
|
|
162
|
+
}
|