pi-edit-first 0.1.0 → 0.1.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.
package/README.md CHANGED
@@ -42,4 +42,42 @@ Applies to every pi session (including pi run as an external agent in editors).
42
42
  - Only affects the `write` tool; `edit` is never blocked.
43
43
  - Does not affect other agents (e.g. Zed's native agent).
44
44
 
45
+ ## Security notes
46
+
47
+ - **A token saver, not a sandbox.** It guards pi's `write` tool. An agent can still write files through `bash`; the guard exists to keep wasteful rewrites out of your context, not to confine the agent.
48
+ - **Same path as the tool.** Paths are resolved exactly like pi's own tools (`@file`, `~/file`, `file://`, Unicode spaces, Windows shell paths), so those spellings can't slip past it. A contract test compares it with pi's real `resolveToCwd`.
49
+ - **Fails open.** pi blocks a tool when a `tool_call` handler throws, so any unexpected error here means "allow": the guard can never stop legitimate work.
50
+ - **Constant memory.** Line counts are streamed (64 KB chunks, early exit), never the whole file.
51
+ - No network access, no shell commands, **zero runtime dependencies**.
52
+
53
+ ## Development
54
+
55
+ ```bash
56
+ bun install
57
+ just check # typecheck + tests + pack check + nushell tests (what CI runs)
58
+ just test # bun test only (with coverage)
59
+ just coverage # tests + a coverage table
60
+ ```
61
+
62
+ **Coverage:** every `bun test` collects coverage (`bunfig.toml`) and **fails below 95% lines / 95% functions**. CI shows the table on the run page and uploads `lcov.info`.
63
+
64
+ CI scripts are [nushell](https://www.nushell.sh) (`scripts/`), the same ones locally and in GitHub / Gitea Actions.
65
+ `just --list` shows every task.
66
+
67
+ ## Releases (automatic)
68
+
69
+ | Workflow | When | What |
70
+ |---|---|---|
71
+ | **CI** | push / PR | quality gate on Linux; tests on macOS, Windows and extreme time zones |
72
+ | **Nightly Dependency Update** | every night (GitHub 02:00 UTC, Gitea 02:30) | `bun update` within ranges, verify on all platforms, commit `chore(deps)`; then **publish a patch** when a *runtime* dependency was upgraded or `feat`/`fix`/`perf` commits are waiting since the last tag |
73
+ | **Release** | tag `vX.Y.Z` | validates the tag against `package.json`, runs the gate, `npm publish` (idempotent), creates the release |
74
+
75
+ Dev-tooling-only bumps are committed but never released on their own. A downgrade blocks the whole update.
76
+
77
+ Manual release: `just bump patch` (or `minor` / `major` / `X.Y.Z`), then `just release-push`.
78
+
79
+ **Secrets** (repo settings): `NPM_TOKEN` — npm *granular access token* with publish rights and "bypass 2FA" (required for CI publishing). Optional: `GH_PAT` (lets a tag push trigger the release itself), `GITEA_TOKEN` (Gitea).
80
+
81
+ Commits follow [Conventional Commits](https://www.conventionalcommits.org); the changelog is generated by git-cliff.
82
+
45
83
  MIT
@@ -19,9 +19,10 @@
19
19
  */
20
20
 
21
21
  import { existsSync } from "node:fs";
22
- import { readFile } from "node:fs/promises";
22
+ import { open, readFile } from "node:fs/promises";
23
23
  import { homedir } from "node:os";
24
24
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
25
+ import { fileURLToPath } from "node:url";
25
26
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
27
 
27
28
  interface Config {
@@ -32,12 +33,15 @@ interface Config {
32
33
 
33
34
  const DEFAULTS: Config = { maxWriteLines: 40, scaffold: true, ignore: [] };
34
35
 
35
- function agentDir(): string {
36
- if (process.env.PI_CODING_AGENT_DIR) return process.env.PI_CODING_AGENT_DIR;
37
- if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, "pi", "agent");
38
- return join(homedir(), ".pi", "agent");
36
+ /** Where pi keeps its config: PI_CODING_AGENT_DIR, then $XDG_CONFIG_HOME/pi/agent, then ~/.pi/agent. */
37
+ export function resolveAgentDir(env: Record<string, string | undefined>, home: string): string {
38
+ if (env.PI_CODING_AGENT_DIR) return env.PI_CODING_AGENT_DIR;
39
+ if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, "pi", "agent");
40
+ return join(home, ".pi", "agent");
39
41
  }
40
42
 
43
+ const agentDir = () => resolveAgentDir(process.env, homedir());
44
+
41
45
  async function loadConfig(): Promise<Config> {
42
46
  try {
43
47
  const p = JSON.parse(await readFile(join(agentDir(), "edit-first.json"), "utf8"));
@@ -53,17 +57,79 @@ async function loadConfig(): Promise<Config> {
53
57
  }
54
58
 
55
59
  /** Minimal glob: `*` matches anything except `/`; matched against path or basename. */
56
- function matchesIgnore(path: string, patterns: string[]): boolean {
60
+ /** Minimal glob (`*` = anything except `/`) against the path or its basename. */
61
+ export function matchesIgnore(path: string, patterns: string[]): boolean {
57
62
  return patterns.some((pat) => {
58
- const re = new RegExp(`^${pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*")}$`);
63
+ if (pat.length > 256) return false; // absurd pattern: ignore it rather than risk slow matching
64
+ const body = pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*+/g, "*").replace(/\*/g, "[^/]*");
65
+ const re = new RegExp(`^${body}$`);
59
66
  return re.test(path) || re.test(basename(path));
60
67
  });
61
68
  }
62
69
 
63
- function countLines(text: string): number {
64
- return text === "" ? 0 : text.split("\n").length;
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Path handling — mirrors pi's own tool path resolution (`resolveToCwd`), so the
73
+ // guard looks at the same file the tool will: `@file`, `~/file`, `file://…`,
74
+ // Unicode spaces and Windows shell paths are normalised exactly as pi does.
75
+ // ---------------------------------------------------------------------------
76
+
77
+ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
78
+
79
+ function normalizeWindowsShellPath(filePath: string): string {
80
+ if (!filePath.startsWith("/") || filePath.startsWith("//") || filePath.includes("\\")) return filePath;
81
+ const m = filePath.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
82
+ if (!m) return filePath;
83
+ return `${m[1].toUpperCase()}:\\${m[2]?.replaceAll("/", "\\") ?? ""}`;
65
84
  }
66
85
 
86
+ export function resolveToolPath(
87
+ input: string,
88
+ cwd: string,
89
+ home: string = homedir(),
90
+ platform: string = process.platform,
91
+ ): string {
92
+ let p = input.replace(UNICODE_SPACES, " ");
93
+ if (p.startsWith("@")) p = p.slice(1);
94
+ if (platform === "win32") p = normalizeWindowsShellPath(p);
95
+ if (p === "~") p = home;
96
+ else if (p.startsWith("~/") || (platform === "win32" && p.startsWith("~\\"))) p = join(home, p.slice(2));
97
+ if (/^file:\/\//.test(p)) {
98
+ try {
99
+ p = fileURLToPath(p);
100
+ } catch {
101
+ /* malformed URL: leave as is; the tool will report it */
102
+ }
103
+ }
104
+ return isAbsolute(p) ? resolve(p) : resolve(cwd, p);
105
+ }
106
+
107
+ /**
108
+ * Count lines without loading the file: constant memory, and it stops as soon as
109
+ * `cap` is exceeded. `lines` is exact unless `capped`.
110
+ */
111
+ export async function countLinesCapped(path: string, cap: number): Promise<{ lines: number; capped: boolean }> {
112
+ const fh = await open(path, "r");
113
+ try {
114
+ const buf = Buffer.allocUnsafe(64 * 1024);
115
+ let newlines = 0;
116
+ let size = 0;
117
+ for (;;) {
118
+ const { bytesRead } = await fh.read(buf, 0, buf.length, null);
119
+ if (bytesRead === 0) break;
120
+ size += bytesRead;
121
+ for (let i = 0; i < bytesRead; i++) if (buf[i] === 10) newlines++;
122
+ if (newlines + 1 > cap) return { lines: cap, capped: true };
123
+ }
124
+ return { lines: size === 0 ? 0 : newlines + 1, capped: false };
125
+ } finally {
126
+ await fh.close();
127
+ }
128
+ }
129
+
130
+ /** Lines counted exactly up to this many beyond the limit; past that the message says "over N". */
131
+ const COUNT_HEADROOM = 10_000;
132
+
67
133
  /** Manifest → what to run instead, and a marker that proves a project already exists. */
68
134
  const SCAFFOLDS: Record<string, { hint: string; exists: string[] }> = {
69
135
  "Cargo.toml": { hint: "run `cargo init` / `cargo new`", exists: ["Cargo.toml"] },
@@ -91,12 +157,16 @@ export default function editFirst(pi: ExtensionAPI) {
91
157
  allowed.clear();
92
158
  });
93
159
 
94
- pi.on("tool_call", async (event, ctx) => {
160
+ /**
161
+ * pi BLOCKS the tool when a `tool_call` handler throws, so a bug here must never
162
+ * stop legitimate work: any unexpected error means "allow".
163
+ */
164
+ const guard = async (event: any, ctx: any) => {
95
165
  if (!enabled || event.toolName !== "write") return undefined;
96
166
 
97
167
  const raw = event.input?.path;
98
168
  if (typeof raw !== "string" || raw === "") return undefined;
99
- const abs = isAbsolute(raw) ? raw : resolve(ctx.cwd, raw);
169
+ const abs = resolveToolPath(raw, ctx.cwd);
100
170
 
101
171
  if (allowed.has(abs) || matchesIgnore(raw, config.ignore)) return undefined;
102
172
 
@@ -107,15 +177,16 @@ export default function editFirst(pi: ExtensionAPI) {
107
177
  };
108
178
 
109
179
  if (existsSync(abs)) {
110
- let lines = 0;
180
+ let count: { lines: number; capped: boolean };
111
181
  try {
112
- lines = countLines(await readFile(abs, "utf8"));
182
+ count = await countLinesCapped(abs, config.maxWriteLines + COUNT_HEADROOM);
113
183
  } catch {
114
184
  return undefined;
115
185
  }
116
- if (lines > config.maxWriteLines) {
186
+ if (count.lines > config.maxWriteLines) {
187
+ const size = count.capped ? `over ${count.lines} lines` : `${count.lines} lines`;
117
188
  return deny(
118
- `"${raw}" exists (${lines} lines). Do not rewrite whole files: use the edit tool with small targeted replacements. ` +
189
+ `"${raw}" exists (${size}). Do not rewrite whole files: use the edit tool with small targeted replacements. ` +
119
190
  `If a full rewrite is truly required, ask the user to run /edit-first allow ${raw}.`,
120
191
  );
121
192
  }
@@ -134,6 +205,14 @@ export default function editFirst(pi: ExtensionAPI) {
134
205
  }
135
206
  }
136
207
  return undefined;
208
+ };
209
+
210
+ pi.on("tool_call", async (event, ctx) => {
211
+ try {
212
+ return await guard(event, ctx);
213
+ } catch {
214
+ return undefined;
215
+ }
137
216
  });
138
217
 
139
218
  pi.registerCommand("edit-first", {
@@ -144,7 +223,7 @@ export default function editFirst(pi: ExtensionAPI) {
144
223
  else if (cmd === "on") enabled = true;
145
224
  else if (cmd === "allow" && rest.length > 0) {
146
225
  const p = rest.join(" ");
147
- allowed.add(isAbsolute(p) ? p : resolve(ctx.cwd, p));
226
+ allowed.add(resolveToolPath(p, ctx.cwd));
148
227
  ctx.ui.notify(`edit-first: full rewrite of ${p} allowed this session`, "info");
149
228
  return;
150
229
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-edit-first",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Pi extension that cuts output tokens: blocks whole-file `write` rewrites and hand-written project manifests, steering the agent to targeted `edit` calls and scaffolders. Zero prompt tokens.",
5
5
  "keywords": ["pi-package", "pi-extension", "tokens", "edit", "guard"],
6
6
  "license": "MIT",
@@ -11,6 +11,20 @@
11
11
  },
12
12
  "homepage": "https://github.com/sorinirimies/pi-edit-first#readme",
13
13
  "files": ["extensions", "README.md", "LICENSE"],
14
- "pi": { "extensions": ["./extensions/edit-first.ts"] },
15
- "peerDependencies": { "@earendil-works/pi-coding-agent": "*" }
14
+ "pi": {
15
+ "extensions": ["./extensions/edit-first.ts"]
16
+ },
17
+ "scripts": {
18
+ "test": "bun test",
19
+ "typecheck": "tsc --noEmit",
20
+ "check": "nu scripts/ci/quality_gate.nu"
21
+ },
22
+ "peerDependencies": {
23
+ "@earendil-works/pi-coding-agent": "*"
24
+ },
25
+ "devDependencies": {
26
+ "@earendil-works/pi-coding-agent": "^1.1.0",
27
+ "@types/bun": "^1.4.2",
28
+ "typescript": "^7.0.2"
29
+ }
16
30
  }