@juicesharp/rpiv-args 1.9.2 → 1.10.1

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.
Files changed (3) hide show
  1. package/README.md +42 -0
  2. package/args.ts +230 -27
  3. package/package.json +7 -3
package/README.md CHANGED
@@ -190,6 +190,48 @@ backward compatibility.
190
190
  - Skills **with** placeholders → body gets substitution, raw args still appended after block
191
191
  - The `argument-hint` frontmatter field is read but not enforced in v1
192
192
 
193
+ ## Variables and shell execution
194
+
195
+ Skills can reference runtime context and inline shell command output. These run on **every** invocation, regardless of whether the skill body uses `$N` / `$ARGUMENTS` tokens.
196
+
197
+ | Syntax | Replaced with |
198
+ |---|---|
199
+ | `${SKILL_DIR}` | Absolute path to the skill's source directory (forward-slash normalized on Windows) |
200
+ | `${SESSION_ID}` | The current Pi session id |
201
+ | `` !`command` `` | Single-line shell command output (no newline crossing) |
202
+ | ` ```!\n…\n``` ` | Multi-line shell program output (newlines preserved) |
203
+
204
+ ### Shell execution semantics
205
+
206
+ - **Working directory**: every shell command runs in `process.cwd()` (the Pi session's working directory).
207
+ - **Sequential**: commands within one body run one at a time, in source order. `` !`mkdir x` `` then `` !`ls x` `` is safe.
208
+ - **Output truncation**: combined stdout + stderr capped at 50 KB / 2000 lines (tail-truncated — failures at the end of the output survive).
209
+ - **Errors are inlined** (the rest of the body still reaches the LLM):
210
+ - Timeout → `[Shell error: timed out after Ns]`
211
+ - Non-zero exit → `[Shell error: exit code N]\n<stderr>`
212
+ - **`shell-timeout` frontmatter** (seconds, default 120 s):
213
+
214
+ | Value | Effect |
215
+ |---|---|
216
+ | absent | 120 s (default) |
217
+ | positive number (e.g. `5`, `0.5`) | converted to ms |
218
+ | `0` | timer disabled (no timeout) |
219
+ | any non-finite or negative value (string, `NaN`, `.inf`, `-1`, `true`) | silent fallback to default 120 s |
220
+
221
+ ### Cross-platform skill authoring
222
+
223
+ On Windows, rpiv-args runs each command via `powershell.exe -Command` (PowerShell 5.1+ ships with every supported Windows version). On macOS / Linux it uses `sh -c`. Most POSIX utilities work on both platforms because PowerShell exposes them as aliases:
224
+
225
+ | POSIX command | Works on Windows via PowerShell alias |
226
+ |---|---|
227
+ | `ls`, `cat`, `pwd`, `cp`, `mv`, `rm`, `mkdir` | ✅ (aliases of `Get-ChildItem`, `Get-Content`, etc.) |
228
+ | `git`, `npm`, `node`, `python` | ✅ (external binaries on PATH) |
229
+ | `grep`, `sed`, `awk`, `find`, `xargs` | ❌ (not aliased — use PowerShell equivalents like `Select-String`) |
230
+
231
+ > **POSIX flags are NOT translated.** Aliases match command NAMES only. `` !`rm -rf x` `` will FAIL under PowerShell because `Remove-Item` takes `-Recurse -Force`, not `-rf`. For destructive or flag-heavy commands, prefer external binaries (`git`, `npm`, `node`) or write a portable PowerShell-flavored block (`` ```! ``` ``) instead.
232
+
233
+ **PowerShell cmdlet exit-code quirk**: external commands propagate their exit code via `$LASTEXITCODE`, which PowerShell reflects in its own exit code (so `` !`git status` `` reports failure correctly). However, **cmdlet errors return exit 0 by default**. If a skill relies on a cmdlet's failure to be visible, prepend `$ErrorActionPreference = "Stop"; ` or use `-ErrorAction Stop` per cmdlet. For maximum portability, prefer external commands (`git`, `npm`) over cmdlets where you care about exit codes.
234
+
193
235
  ## Limitations
194
236
 
195
237
  | Limitation | Detail |
package/args.ts CHANGED
@@ -2,17 +2,20 @@
2
2
  * rpiv-args — core logic.
3
3
  *
4
4
  * Intercepts `/skill:<name> <args>` at the input hook and emits a Pi skill
5
- * wrapper with opt-in $N/$ARGUMENTS/$@/${@:N[:L]} substitution on the body.
6
- * Two emit paths:
7
- * - No-token path: byte-identical to Pi's built-in `_expandSkillCommand`
8
- * output (wrapper + `\n\n${args}` suffix), preserving full backward
9
- * compatibility for skills without placeholders.
10
- * - Token path: substitutes inside the body and INTENTIONALLY drops the
11
- * trailing `\n\n${args}` suffix. The bare imperative outside the block
12
- * hijacks LLM attention from the skill workflow; inside-only emission
13
- * leaves the skill body as the sole user-message payload competing for
14
- * attention. See architecture.md "System-Prompt Protocol & Token-Path
15
- * Divergence".
5
+ * wrapper. Pipeline (FR9):
6
+ * strip frontmatter $N/$ARGUMENTS substitution (opt-in via TOKEN_REGEX)
7
+ * ${SKILL_DIR}/${SESSION_ID} substitution (always-on, FR10)
8
+ * shell execution (always-on, FR10 see executeShellInBody)
9
+ * wrap in <skill name=… location=…>…</skill> block
10
+ *
11
+ * Emit-path divergence (FR12): the trailing `\n\n${args}` suffix policy is
12
+ * governed by ORIGINAL token presence (`hadTokens`). The no-token path emits
13
+ * byte-identical to Pi's built-in `_expandSkillCommand`; the token path
14
+ * intentionally drops the suffix (substitution consumed the args; bare
15
+ * trailing imperatives hijack LLM attention from the skill body).
16
+ *
17
+ * Variable substitution and shell execution always run on BOTH emit paths —
18
+ * `hadTokens` governs the suffix only, not the substitution pipeline.
16
19
  *
17
20
  * Also prepends a skill-invocation protocol to the system prompt every turn
18
21
  * (via before_agent_start) so the LLM treats trailing text after `</skill>`
@@ -30,7 +33,12 @@ import { dirname, join, resolve } from "node:path";
30
33
  import {
31
34
  type BeforeAgentStartEvent,
32
35
  type BeforeAgentStartEventResult,
36
+ DEFAULT_MAX_BYTES,
37
+ DEFAULT_MAX_LINES,
38
+ type ExecResult,
33
39
  type ExtensionAPI,
40
+ type ExtensionContext,
41
+ formatSize,
34
42
  getAgentDir,
35
43
  type InputEvent,
36
44
  type InputEventResult,
@@ -38,6 +46,8 @@ import {
38
46
  parseFrontmatter,
39
47
  type Skill,
40
48
  stripFrontmatter,
49
+ type TruncationResult,
50
+ truncateTail,
41
51
  } from "@earendil-works/pi-coding-agent";
42
52
 
43
53
  // ---------------------------------------------------------------------------
@@ -45,7 +55,8 @@ import {
45
55
  // ---------------------------------------------------------------------------
46
56
 
47
57
  /** Matches any placeholder Pi's substituteArgs would replace. Used as the
48
- * opt-in gate: absent pass through verbatim. */
58
+ * opt-in gate for the $N/$ARGUMENTS substitution path AND as the
59
+ * emit-path flag (hadTokens) governing the trailing-args suffix. */
49
60
  const TOKEN_REGEX = /\$(?:\d+|ARGUMENTS|@|\{@:\d+(?::\d+)?\})/;
50
61
 
51
62
  /** Prefix Pi uses (`agent-session.js:829`). Single-space tokenisation. */
@@ -54,6 +65,27 @@ const SKILL_PREFIX = "/skill:";
54
65
  /** Re-entrancy guard. */
55
66
  const WRAPPED_PREFIX = "<skill ";
56
67
 
68
+ /** Default ceiling for shell execution: 2 minutes. Frontmatter `shell-timeout`
69
+ * (seconds) overrides; `0` disables natively via pi.exec's `&&` short-circuit
70
+ * at dist/core/exec.js:42. */
71
+ const DEFAULT_SHELL_TIMEOUT_MS = 120_000;
72
+
73
+ /** Inline shell: !`command` — non-greedy single-line, no newline crossing.
74
+ * Capture is `[^`\n]+` (at least one char) so a literal `` !`` `` in
75
+ * prose does NOT run the shell with an empty `-c` argument (per
76
+ * artifact-reviewer finding R3).
77
+ * Runs AFTER block; block-before-inline is enforced by the mask-and-restore
78
+ * pass below (block outputs are protected from inline re-execution per R2).
79
+ * MUST stay /g — consumed by matchAll(); do NOT call .exec()/.test() on
80
+ * this directly (stale lastIndex would silently skip matches). */
81
+ const SHELL_INLINE_PATTERN = /!`([^`\n]+)`/g;
82
+
83
+ /** Block shell: ```!\n…\n``` — multiline non-greedy. Captured content is
84
+ * handed to the shell as a single program (newlines preserved).
85
+ * MUST stay /g — consumed by matchAll(); do NOT call .exec()/.test() on
86
+ * this directly (stale lastIndex would silently skip matches). */
87
+ const SHELL_BLOCK_PATTERN = /```!\n([\s\S]*?)\n```/g;
88
+
57
89
  // ---------------------------------------------------------------------------
58
90
  // Tokeniser — byte-equivalent to Pi's parseCommandArgs at
59
91
  // node_modules/@earendil-works/pi-coding-agent/dist/core/prompt-templates.js:11-42
@@ -110,6 +142,163 @@ export function substituteArgs(content: string, args: string[]): string {
110
142
  return result;
111
143
  }
112
144
 
145
+ // ---------------------------------------------------------------------------
146
+ // Variable substitution — mechanical, runs after $N/$ARGUMENTS and before
147
+ // shell execution. ${SKILL_DIR} is forward-slash-normalized at the
148
+ // substitution site ONLY — buildSkillBlock must stay byte-exact (its
149
+ // `References are relative to ${baseDir}` line is consumed by Pi unchanged).
150
+ //
151
+ // Backslash normalization is GATED on process.platform === "win32" so a
152
+ // POSIX path containing a literal backslash (e.g. `/tmp/weird\name`) is
153
+ // byte-preserving. Per artifact-reviewer finding R7.
154
+ // ---------------------------------------------------------------------------
155
+
156
+ export function substituteVariables(body: string, vars: { skillDir: string; sessionId: string }): string {
157
+ const skillDir = process.platform === "win32" ? vars.skillDir.split("\\").join("/") : vars.skillDir;
158
+ return body.replace(/\$\{SKILL_DIR\}/g, skillDir).replace(/\$\{SESSION_ID\}/g, vars.sessionId);
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // shell-timeout resolution.
163
+ //
164
+ // YAML scalar coercion at frontmatter parse time can produce any of: number,
165
+ // string, boolean, null, NaN (from `.nan`), Infinity (from `.inf`). Silent
166
+ // fallback to default on any non-finite or non-positive value matches Pi's
167
+ // graceful-degradation posture at dist/utils/frontmatter.js:24 (`parsed ?? {}`).
168
+ //
169
+ // Number.isFinite is load-bearing — both NaN and Infinity must be rejected:
170
+ // - NaN → would silently bypass exec.js:42's `&& options.timeout > 0`
171
+ // short-circuit (NaN > 0 is false) and disable the timer, hiding
172
+ // an FR4 violation.
173
+ // - Infinity → Node's setTimeout(fn, Infinity) clamps to 1ms → an immediate
174
+ // kill (the opposite of "no timeout").
175
+ //
176
+ // `0` is honored as explicit disable (FR4).
177
+ // ---------------------------------------------------------------------------
178
+
179
+ export function resolveShellTimeoutMs(frontmatter: { "shell-timeout"?: unknown }): number {
180
+ const raw = frontmatter["shell-timeout"];
181
+ if (raw === undefined) return DEFAULT_SHELL_TIMEOUT_MS;
182
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return DEFAULT_SHELL_TIMEOUT_MS;
183
+ if (raw < 0) return DEFAULT_SHELL_TIMEOUT_MS;
184
+ if (raw === 0) return 0;
185
+ return raw * 1000;
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Shell execution.
190
+ //
191
+ // Pipeline: blocks first, then inlines. Block-before-inline is load-bearing
192
+ // — the block pattern's content group `[\s\S]*?` legitimately matches `!\``
193
+ // inside the fence; running inline first would eat backticks from block
194
+ // content and produce malformed bodies.
195
+ //
196
+ // Sequential iteration (FR11) — never Promise.all. Skill authors rely on
197
+ // `!`mkdir x`` → `!`ls x`` ordering. The git-context.ts:36-44 Promise.all
198
+ // precedent for parallel read-only git commands is INTENTIONALLY not copied.
199
+ //
200
+ // Cross-platform shim: PowerShell on Windows (POSIX-alias coverage), sh on
201
+ // POSIX. `pi.exec` uses spawn(…, {shell:false}) per dist/core/exec.js:13-17
202
+ // so the shim is required.
203
+ //
204
+ // `pi.exec` NEVER rejects (dist/core/exec.js:10-72 — every termination path
205
+ // calls resolve(...)). No try/catch needed here.
206
+ //
207
+ // FR5 branch order: killed → code !== 0 → success. `killed` is checked first
208
+ // because a timed-out child may also report a non-zero code via `code ?? 0`
209
+ // (exec.js:60) or `1` via the catch (exec.js:71); the timeout message wins.
210
+ // ---------------------------------------------------------------------------
211
+
212
+ /** Truncate a string for LLM consumption: 50KB / 2000-line tail budget,
213
+ * with a `[truncated: hit ...]` footer when truncation occurred. Shared
214
+ * by the success path (`formatShellOutput`) and the non-zero exit path
215
+ * in `runOneShellCommand` so a multi-MB stderr from a failed `!`npm test``
216
+ * cannot bypass FR2's budget (per R1). */
217
+ function truncateForLLM(content: string): string {
218
+ const trunc: TruncationResult = truncateTail(content, {
219
+ maxLines: DEFAULT_MAX_LINES,
220
+ maxBytes: DEFAULT_MAX_BYTES,
221
+ });
222
+ let out = trunc.content;
223
+ if (trunc.truncated) {
224
+ const limit = trunc.truncatedBy === "lines" ? `${trunc.maxLines} lines` : formatSize(trunc.maxBytes);
225
+ out += `\n[truncated: hit ${limit}]`;
226
+ }
227
+ return out;
228
+ }
229
+
230
+ function formatShellOutput(res: ExecResult): string {
231
+ let combined = res.stdout;
232
+ if (res.stderr && res.stderr.length > 0) {
233
+ const sep = combined.length === 0 || combined.endsWith("\n") ? "" : "\n";
234
+ combined = `${combined}${sep}[stderr]\n${res.stderr}`;
235
+ }
236
+ return truncateForLLM(combined);
237
+ }
238
+
239
+ async function runOneShellCommand(command: string, pi: ExtensionAPI, cwd: string, timeoutMs: number): Promise<string> {
240
+ const [shCmd, shFlag] = process.platform === "win32" ? ["powershell.exe", "-Command"] : ["sh", "-c"];
241
+ const res: ExecResult = await pi.exec(shCmd, [shFlag, command], { cwd, timeout: timeoutMs });
242
+ if (res.killed) {
243
+ // Floor at 1s so sub-second `shell-timeout` values (e.g. 0.5) don't display
244
+ // the contradictory `[Shell error: timed out after 0s]` (per R4).
245
+ const sec = Math.max(1, Math.round(timeoutMs / 1000));
246
+ return `[Shell error: timed out after ${sec}s]`;
247
+ }
248
+ if (res.code !== 0) {
249
+ // FR2 budget on the error path — stderr is truncated identically to
250
+ // the success path so a failed `!`npm test`` cannot blow the LLM budget (R1).
251
+ return `[Shell error: exit code ${res.code}]\n${truncateForLLM(res.stderr)}`;
252
+ }
253
+ return formatShellOutput(res);
254
+ }
255
+
256
+ /** Mask-and-restore strategy (per R2): run the block pass first, replacing
257
+ * each block match with a non-printable sentinel (`\x00BLOCK${n}\x00`) that
258
+ * the inline regex CANNOT match (sentinels have no backticks). Then run the
259
+ * inline pass on the sentinel-bearing string. Finally restore sentinels to
260
+ * their block outputs. This guarantees block stdout containing literal
261
+ * `` !`...` `` is NEVER re-executed by the inline pass. */
262
+ export async function executeShellInBody(
263
+ body: string,
264
+ pi: ExtensionAPI,
265
+ cwd: string,
266
+ timeoutMs: number,
267
+ ): Promise<string> {
268
+ // Pass 1: blocks → sentinels (outputs stashed in blockOutputs).
269
+ const blockOutputs: string[] = [];
270
+ let withSentinels = "";
271
+ {
272
+ const matches = [...body.matchAll(SHELL_BLOCK_PATTERN)];
273
+ let last = 0;
274
+ for (const m of matches) {
275
+ const idx = m.index ?? 0;
276
+ withSentinels += body.slice(last, idx);
277
+ withSentinels += `\x00BLOCK${blockOutputs.length}\x00`;
278
+ blockOutputs.push(await runOneShellCommand(m[1] ?? "", pi, cwd, timeoutMs));
279
+ last = idx + m[0].length;
280
+ }
281
+ withSentinels += body.slice(last);
282
+ }
283
+ // Pass 2: inlines on the sentinel-bearing string. Sentinels carry no
284
+ // backticks so the inline regex (which requires backticks at both ends)
285
+ // cannot match against them — block outputs are protected.
286
+ let withInlines = "";
287
+ {
288
+ const matches = [...withSentinels.matchAll(SHELL_INLINE_PATTERN)];
289
+ let last = 0;
290
+ for (const m of matches) {
291
+ const idx = m.index ?? 0;
292
+ withInlines += withSentinels.slice(last, idx);
293
+ withInlines += await runOneShellCommand(m[1] ?? "", pi, cwd, timeoutMs);
294
+ last = idx + m[0].length;
295
+ }
296
+ withInlines += withSentinels.slice(last);
297
+ }
298
+ // Pass 3: restore block sentinels to their actual outputs.
299
+ return withInlines.replace(/\x00BLOCK(\d+)\x00/g, (_, n) => blockOutputs[parseInt(n, 10)] ?? "");
300
+ }
301
+
113
302
  // ---------------------------------------------------------------------------
114
303
  // Skill-path index — populated once, refreshed on session_start(reason:reload)
115
304
  // ---------------------------------------------------------------------------
@@ -210,10 +399,18 @@ function appendArgs(skillBlock: string, args: string): string {
210
399
  }
211
400
 
212
401
  // ---------------------------------------------------------------------------
213
- // Input handler
402
+ // Input handler — async pipeline (FR9 ordering).
403
+ //
404
+ // `pi` is threaded as the 3rd parameter (not captured at module level) so the
405
+ // extension owns zero new singleton state — see architecture.md "Module-level
406
+ // Cache Reset". `ctx` carries the session manager for ${SESSION_ID}.
214
407
  // ---------------------------------------------------------------------------
215
408
 
216
- export function handleInput(event: InputEvent): InputEventResult {
409
+ export async function handleInput(
410
+ event: InputEvent,
411
+ ctx: ExtensionContext,
412
+ pi: ExtensionAPI,
413
+ ): Promise<InputEventResult> {
217
414
  const text = event.text;
218
415
 
219
416
  // Re-entrancy: already-wrapped text (from our own or any other
@@ -237,20 +434,24 @@ export function handleInput(event: InputEvent): InputEventResult {
237
434
  return { action: "continue" }; // let Pi emit its error via _expandSkillCommand
238
435
  }
239
436
 
240
- const { frontmatter } = parseFrontmatter<{ "argument-hint"?: string }>(content);
241
- void frontmatter; // informational only in v1
437
+ const { frontmatter } = parseFrontmatter<{ "argument-hint"?: string; "shell-timeout"?: unknown }>(content);
242
438
  const body = stripFrontmatter(content).trim();
439
+ const timeoutMs = resolveShellTimeoutMs(frontmatter);
243
440
 
244
- // Opt-in gate: if body has no token, emit byte-identical to Pi's :841.
245
- if (!TOKEN_REGEX.test(body)) {
246
- return { action: "transform", text: appendArgs(buildSkillBlock(entry, body), argsString) };
247
- }
441
+ // FR12: emit-path divergence (token-path drops the trailing `\n\n${args}`
442
+ // suffix) is governed by ORIGINAL token presence only. FR10: variable
443
+ // substitution and shell execution run on BOTH paths regardless.
444
+ const hadTokens = TOKEN_REGEX.test(body);
445
+
446
+ let processed = hadTokens ? substituteArgs(body, parseCommandArgs(argsString)) : body;
447
+ processed = substituteVariables(processed, {
448
+ skillDir: entry.baseDir,
449
+ sessionId: ctx.sessionManager.getSessionId(),
450
+ });
451
+ processed = await executeShellInBody(processed, pi, process.cwd(), timeoutMs);
248
452
 
249
- const parsed = parseCommandArgs(argsString);
250
- const substituted = substituteArgs(body, parsed);
251
- // Substitution consumes the args — do not also append them after </skill>.
252
- // Bare trailing imperatives hijack LLM attention from the skill body. See architecture.md.
253
- return { action: "transform", text: buildSkillBlock(entry, substituted) };
453
+ const block = buildSkillBlock(entry, processed);
454
+ return { action: "transform", text: hadTokens ? block : appendArgs(block, argsString) };
254
455
  }
255
456
 
256
457
  // ---------------------------------------------------------------------------
@@ -274,11 +475,13 @@ export function handleBeforeAgentStart(event: BeforeAgentStartEvent): BeforeAgen
274
475
  }
275
476
 
276
477
  // ---------------------------------------------------------------------------
277
- // Registration
478
+ // Registration. The input handler arrow forwards `ctx` (Pi's runner awaits
479
+ // the result at runner.js:801) and closes over `pi` so handleInput sees both
480
+ // without new module-level state.
278
481
  // ---------------------------------------------------------------------------
279
482
 
280
483
  export function registerArgsHandler(pi: ExtensionAPI): void {
281
- pi.on("input", (event) => handleInput(event));
484
+ pi.on("input", async (event, ctx) => handleInput(event, ctx, pi));
282
485
  pi.on("before_agent_start", (event) => handleBeforeAgentStart(event));
283
486
  pi.on("session_start", (event) => {
284
487
  if (event.reason === "reload" || event.reason === "startup") {
package/package.json CHANGED
@@ -1,13 +1,17 @@
1
1
  {
2
2
  "name": "@juicesharp/rpiv-args",
3
- "version": "1.9.2",
4
- "description": "Pi extension. Shell-style $1 and $ARGUMENTS placeholders, expanded into your Pi skills at invocation.",
3
+ "version": "1.10.1",
4
+ "description": "Pi extension. Shell-style $1 / $ARGUMENTS placeholders and !`cmd` / ```! shell substitution, expanded into your Pi skills at invocation.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi-extension",
8
8
  "rpiv",
9
9
  "skills",
10
- "arguments"
10
+ "arguments",
11
+ "shell",
12
+ "shell-substitution",
13
+ "backtick",
14
+ "prompt"
11
15
  ],
12
16
  "type": "module",
13
17
  "license": "MIT",