@ladbabynpm/picc-edit 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ladbaby
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # picc-edit
2
+
3
+ Claude Code style **Edit** tool for [pi](https://pi.dev) — a faithful port of Claude Code's `Edit` tool, overriding pi's built-in `edit`.
4
+
5
+ Part of [picc](https://github.com/Ladbaby/picc), a pi agent setup mirroring Claude Code's harness.
6
+
7
+ > pi's built-in `edit` is a thin multi-edit wrapper: input `path` (relative or
8
+ > absolute) + `edits: [{oldText, newText}]`, no "must read first" guard, no
9
+ > quote normalization, no `replace_all`. This extension replicates Claude Code's
10
+ > `Edit`: the `file_path` + `old_string` / `new_string` (+ optional
11
+ > `replace_all`) input shape, a session-scoped read-first guard, exact string
12
+ > matching with curly-quote normalization, and a structured diff result.
13
+
14
+ ## Usage
15
+
16
+ Install via `pi install npm:@ladbabynpm/picc-edit`.
17
+
18
+ ## Tool
19
+
20
+ - **Name:** `edit` (default; overrides pi's built-in `edit`) or `Edit` — configurable (see below).
21
+ - **Parameters:** `file_path` (absolute, required), `old_string` (required), `new_string` (required), `replace_all` (optional, default `false`).
22
+ - **Behavior:**
23
+ - **Existing file** — must have been read this session (any read tool), and must not
24
+ have been modified since that read. `old_string` is matched (with curly-quote
25
+ normalization) and must be unique unless `replace_all` is set. Returns `The file
26
+ <path> has been updated successfully.` (or the `replace_all` variant) with a
27
+ structured diff.
28
+ - **New file** — `old_string` must be `""`; creates the file.
29
+ - Writes with explicit LF handling (the model's sent line endings are respected
30
+ as-is — no repo resampling).
31
+ - **Read-first guard:** reads are observed from pi `tool_result` events for any read
32
+ tool (`read`/`Read`), so it works whether the file was read with pi's built-in
33
+ `read` or picc-read's `Read`. The map is cleared on `session_start`.
34
+
35
+ ## Configuration
36
+
37
+ | Setting | Where | Values | Default |
38
+ |---|---|---|---|
39
+ | `toolName` | `config.json` | `"edit"` \| `"Edit"` | `"edit"` |
40
+ | `PICC_EDIT_TOOL_NAME` | env | `"edit"` \| `"Edit"` | — |
41
+ | `PICC_EDIT_CONFIG_PATH` | env | absolute path to a config.json | `~/.pi/agent/extensions/picc-edit/config.json` |
42
+
43
+ Precedence for the tool name: `PICC_EDIT_TOOL_NAME` env > `config.json` > `"edit"`.
44
+
45
+ ## What is omitted from the live source
46
+
47
+ No pi equivalent, so left out: permission checks (`checkWritePermissionForTool`),
48
+ `checkTeamMemSecrets`, `validateInputForSettingsFileEdit`, team-memory guards,
49
+ skill discovery, `diagnosticTracker`, `fileHistory`, LSP `didChange`/`didSave`,
50
+ `notifyVscodeFileUpdated`, `fetchSingleFileGitDiff`, and analytics.
51
+
52
+ ## Development
53
+
54
+ ```bash
55
+ npm install
56
+ npm run lint # biome check
57
+ npm run typecheck # tsc --noEmit
58
+ npm run test # vitest run
59
+ ```
package/index.ts ADDED
@@ -0,0 +1,342 @@
1
+ /**
2
+ * picc-edit: Claude Code-style Edit tool for pi.
3
+ *
4
+ * A faithful port of Claude Code's `Edit` tool, registering a tool named
5
+ * `edit`/`Edit` that **overrides pi's built-in `edit`** tool (same-name,
6
+ * last-write-wins — see `core/tools/index.js`).
7
+ *
8
+ * Differences from pi's built-in `edit`:
9
+ * - Input is Claude Code's single-edit shape: `file_path` (absolute),
10
+ * `old_string`, `new_string`, optional `replace_all` (not pi's
11
+ * `edits: [{oldText, newText}]` array).
12
+ * - Enforces Claude Code's read-first guard: an existing file must have been
13
+ * read this session, and must not have been modified since that read.
14
+ * Read-state is tracked from `tool_result` events for any file tool that
15
+ * establishes known contents (read/write/edit) — mirroring Claude Code,
16
+ * where a file the agent just wrote or edited is immediately editable.
17
+ * The same mechanism picc-write uses.
18
+ * - Quote normalization: `old_string`/`new_string` may use straight quotes
19
+ * to match a file that contains curly quotes, preserving the file's
20
+ * typography.
21
+ * - Returns a structured patch + `originalFile` in `details`, with faithful
22
+ * success messages (including the `replace_all` variant).
23
+ * - Writes with explicit LF handling (the model's sent line endings are
24
+ * respected as-is — no repo resampling).
25
+ *
26
+ * Omitted from the live source (no pi equivalent):
27
+ * - permission checks (`checkWritePermissionForTool`, `matchingRuleForInput`
28
+ * — pi's permission system handles edits separately)
29
+ * - `checkTeamMemSecrets`, `validateInputForSettingsFileEdit`, team-memory
30
+ * guards, skill discovery, `diagnosticTracker`, `fileHistory`
31
+ * - LSP `didChange`/`didSave`, `notifyVscodeFileUpdated`, `fetchSingleFileGitDiff`
32
+ * - analytics (`logEvent`, `logFileOperation`), GrowthBook, UI.tsx render
33
+ *
34
+ * Tool name configuration:
35
+ * - Default: `"edit"` (lowercase; pi's built-in tool name).
36
+ * - Set `config.json` `toolName` to `"Edit"` (default location
37
+ * `~/.pi/agent/extensions/picc-edit/config.json`), or set
38
+ * `PICC_EDIT_TOOL_NAME=Edit`. Valid values: `"edit"`, `"Edit"`.
39
+ */
40
+
41
+ import { existsSync, readFileSync } from "node:fs";
42
+ import { homedir } from "node:os";
43
+ import { join } from "node:path";
44
+ import {
45
+ type ExtensionAPI,
46
+ type ExtensionContext,
47
+ } from "@earendil-works/pi-coding-agent";
48
+ import { Text } from "@earendil-works/pi-tui";
49
+ import { Type } from "typebox";
50
+ import { countLinesChanged, generateDisplayDiff } from "./src/diff.js";
51
+ import {
52
+ type EditInput,
53
+ type EditOutcome,
54
+ editOutcome,
55
+ } from "./src/edit.js";
56
+ import {
57
+ convertLeadingTabsToSpaces,
58
+ getFileModificationTime,
59
+ readFileSyncWithMetadata,
60
+ } from "./src/file.js";
61
+ import { expandPath } from "./src/path.js";
62
+ import {
63
+ getEditToolDescription,
64
+ replaceAllMessage,
65
+ singleEditMessage,
66
+ } from "./src/prompt.js";
67
+ import {
68
+ fileStateToolName,
69
+ type ReadEntry,
70
+ readStateClear,
71
+ readStateSet,
72
+ } from "./src/readState.js";
73
+ import { renderDiff } from "./src/renderDiff.js";
74
+
75
+ // ============================================================================
76
+ // Config (mirrors picc-write)
77
+ // ============================================================================
78
+
79
+ const VALID_TOOL_NAMES = ["edit", "Edit"] as const;
80
+ type ToolName = (typeof VALID_TOOL_NAMES)[number];
81
+
82
+ function resolveConfigPath(): string {
83
+ const env = process.env.PICC_EDIT_CONFIG_PATH;
84
+ if (env) return env;
85
+ return join(
86
+ homedir(),
87
+ ".pi",
88
+ "agent",
89
+ "extensions",
90
+ "picc-edit",
91
+ "config.json",
92
+ );
93
+ }
94
+
95
+ function readToolNameFromConfig(): ToolName | undefined {
96
+ const configPath = resolveConfigPath();
97
+ if (!existsSync(configPath)) return undefined;
98
+ try {
99
+ const raw = readFileSync(configPath, "utf-8");
100
+ const parsed = JSON.parse(raw) as { toolName?: unknown };
101
+ const val = parsed?.toolName;
102
+ if (
103
+ typeof val === "string" &&
104
+ (VALID_TOOL_NAMES as readonly string[]).includes(val)
105
+ ) {
106
+ return val as ToolName;
107
+ }
108
+ if (val !== undefined) {
109
+ console.warn(
110
+ `[picc-edit] config.json: invalid toolName "${val}" — valid values are ${VALID_TOOL_NAMES.join(", ")}. Falling back to "edit".`,
111
+ );
112
+ }
113
+ } catch {
114
+ // unreadable / malformed — fall through to default
115
+ }
116
+ return undefined;
117
+ }
118
+
119
+ function loadToolName(): ToolName {
120
+ const envVal = process.env.PICC_EDIT_TOOL_NAME;
121
+ if (typeof envVal === "string") {
122
+ if ((VALID_TOOL_NAMES as readonly string[]).includes(envVal)) {
123
+ return envVal as ToolName;
124
+ }
125
+ console.warn(
126
+ `[picc-edit] PICC_EDIT_TOOL_NAME="${envVal}" is invalid — valid values are ${VALID_TOOL_NAMES.join(", ")}. Falling back to "edit".`,
127
+ );
128
+ }
129
+ return readToolNameFromConfig() ?? "edit";
130
+ }
131
+
132
+ // ============================================================================
133
+ // Schema
134
+ // ============================================================================
135
+
136
+ const EDIT_SCHEMA = Type.Object({
137
+ file_path: Type.String({
138
+ description: "The absolute path to the file to modify",
139
+ }),
140
+ new_string: Type.String({
141
+ description:
142
+ "The text to replace it with (must be different from old_string)",
143
+ }),
144
+ old_string: Type.String({
145
+ description: "The text to replace",
146
+ }),
147
+ replace_all: Type.Optional(
148
+ Type.Boolean({ description: "Replace all occurrences of old_string (default false)" }),
149
+ ),
150
+ });
151
+
152
+ // ============================================================================
153
+ // Read-state tracking (populates the edit guard across read tools)
154
+ // ============================================================================
155
+
156
+ function recordRead(input: Record<string, unknown>, cwd: string): void {
157
+ // Reads are reported under different key names depending on the active
158
+ // read tool: pi's built-in `read` uses `path`, while picc-read (the
159
+ // Claude Code port) uses `file_path`. Accept both so the read guard is
160
+ // satisfied no matter which read tool the session is using.
161
+ const rawPath =
162
+ (typeof input.file_path === "string" && input.file_path) ||
163
+ (typeof input.path === "string" && input.path);
164
+ if (typeof rawPath !== "string" || !rawPath) return;
165
+
166
+ let fullPath: string;
167
+ try {
168
+ fullPath = expandPath(rawPath, cwd);
169
+ } catch {
170
+ return;
171
+ }
172
+
173
+ try {
174
+ const meta = readFileSyncWithMetadata(fullPath);
175
+ const timestamp = getFileModificationTime(fullPath);
176
+ // Always store as a full read (`offset: undefined`, `limit: undefined`).
177
+ // We cannot reliably distinguish a full read from a partial read from
178
+ // the `tool_result` event alone (no read-output content / truncation
179
+ // info is exposed), and being too strict here causes false positives
180
+ // when models default `offset: 1` or when a read tool injects `path`
181
+ // defaults. The `modified-since-read` check in `editOutcome` still
182
+ // guards against stale edits; sacrificing the partial-read guard is
183
+ // the right trade-off (defense-in-depth, not correctness).
184
+ const entry: ReadEntry = {
185
+ content: meta.content,
186
+ timestamp,
187
+ offset: undefined,
188
+ limit: undefined,
189
+ };
190
+ readStateSet(fullPath, entry);
191
+ } catch {
192
+ // file gone or unreadable — nothing to record
193
+ }
194
+ }
195
+
196
+ // ============================================================================
197
+ // Extension entry point
198
+ // ============================================================================
199
+
200
+ export default function (pi: ExtensionAPI): void {
201
+ const toolName = loadToolName();
202
+
203
+ // Clear any stale read-state on a fresh/reloaded session.
204
+ pi.on("session_start", () => {
205
+ readStateClear();
206
+ });
207
+
208
+ // Observe successful file tools (read/write/edit) to feed the edit guard.
209
+ // Claude Code refreshes its shared `readFileState` from all three, so a file
210
+ // the agent just wrote or edited is immediately editable without a redundant
211
+ // re-read. `tool_result` events carry no cwd of their own; use the process
212
+ // cwd.
213
+ pi.on("tool_result", (event) => {
214
+ if (!fileStateToolName(event.toolName)) return;
215
+ if (event.isError) return;
216
+ recordRead(event.input, process.cwd());
217
+ });
218
+
219
+ pi.registerTool({
220
+ name: toolName,
221
+ label: toolName,
222
+ description: getEditToolDescription(),
223
+ promptSnippet: "Make precise string replacements in files",
224
+ promptGuidelines: [],
225
+ parameters: EDIT_SCHEMA,
226
+ // Rely on the framework's default background shell (colored Box) rather
227
+ // than self-framing. This overrides the built-in `edit`, whose `renderShell:
228
+ // "self"` we do NOT want to inherit — in "self" mode the framework would
229
+ // skip the background unless we supplied our own Box. "default" gives the
230
+ // standard pending/success/error background for free (see tool-execution).
231
+ renderShell: "default",
232
+ executionMode: "sequential",
233
+ async execute(
234
+ _toolCallId,
235
+ params,
236
+ _signal,
237
+ _onUpdate,
238
+ ctx: ExtensionContext,
239
+ ) {
240
+ const input = params as EditInput;
241
+ const cwd = ctx.cwd;
242
+
243
+ try {
244
+ const outcome: EditOutcome = await editOutcome(input, cwd);
245
+ const message = outcome.replaceAll
246
+ ? replaceAllMessage(outcome.filePath)
247
+ : singleEditMessage(outcome.filePath);
248
+ // Display-oriented, line-numbered diff in the exact format the
249
+ // built-in diff viewer (`renderDiff`) expects. Both sides are put
250
+ // in the same display space (leading tabs → 2 spaces): `newFile`
251
+ // is already tab-converted by editOutcome, so the old side must
252
+ // be too — otherwise every tab-indented line looks changed and
253
+ // the diff balloons to the whole file. Mirrors how
254
+ // `structuredPatch` is computed (tab-convert both sides). Lives
255
+ // only in `details` (TUI channel) — the model sees just `content`.
256
+ const diff = generateDisplayDiff(
257
+ convertLeadingTabsToSpaces(outcome.originalFile),
258
+ outcome.newFile,
259
+ );
260
+ const { added, removed } = countLinesChanged(
261
+ outcome.structuredPatch,
262
+ outcome.newFile,
263
+ );
264
+ return {
265
+ content: [{ type: "text", text: message }],
266
+ details: { ...outcome, diff, additions: added, removals: removed },
267
+ };
268
+ } catch (err) {
269
+ // pi's agent loop only flags a tool result as errored when
270
+ // execute() rejects — a resolved `{ isError: true }` is dropped
271
+ // because AgentToolResult has no such field. Throw so the
272
+ // guard / validation failure is surfaced as a real tool error
273
+ // (matching pi's built-in `edit`, which also throws).
274
+ throw err instanceof Error ? err : new Error(String(err));
275
+ }
276
+ },
277
+ renderCall(args, theme, context) {
278
+ const path =
279
+ typeof args.file_path === "string" ? args.file_path : "";
280
+ let text = theme.fg("toolTitle", theme.bold(`${toolName} `));
281
+ text += theme.fg("accent", path);
282
+ const t =
283
+ (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
284
+ t.setText(text);
285
+ return t;
286
+ },
287
+ renderResult(result, { isPartial }, theme, context) {
288
+ if (isPartial) {
289
+ return new Text(theme.fg("warning", "Editing..."), 0, 0);
290
+ }
291
+ const t =
292
+ (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
293
+ // On error, details is undefined. Show the error message in red.
294
+ if (context.isError) {
295
+ const errorMsg = result.content
296
+ .filter(
297
+ (c): c is { type: "text"; text: string } => c.type === "text",
298
+ )
299
+ .map((c) => c.text)
300
+ .join("\n");
301
+ t.setText(theme.fg("error", errorMsg || "Edit failed"));
302
+ return t;
303
+ }
304
+ const details = result.details as
305
+ | (EditOutcome & {
306
+ diff?: string;
307
+ additions: number;
308
+ removals: number;
309
+ })
310
+ | undefined;
311
+ // Summary line above the diff ("Added N, removed M"), matching
312
+ // Claude Code's FileEditToolUpdatedMessage. Counts come from
313
+ // details (set in execute); the leading blank line separates the
314
+ // summary from the call header. Styled like picc-write's
315
+ // createPreviewText header: muted text with the counts bolded.
316
+ if (details?.diff) {
317
+ const summaryParts: string[] = [];
318
+ if (details.additions > 0) {
319
+ summaryParts.push(
320
+ `Added ${theme.bold(String(details.additions))} line${details.additions === 1 ? "" : "s"}`,
321
+ );
322
+ }
323
+ if (details.removals > 0) {
324
+ summaryParts.push(
325
+ `${details.additions > 0 ? "removed" : "Removed"} ${theme.bold(String(details.removals))} line${details.removals === 1 ? "" : "s"}`,
326
+ );
327
+ }
328
+ const summary =
329
+ summaryParts.length > 0
330
+ ? summaryParts.join(", ")
331
+ : "Applied";
332
+ t.setText(
333
+ theme.fg("muted", summary) +
334
+ renderDiff(details.diff, theme),
335
+ );
336
+ } else {
337
+ t.setText(theme.fg("success", "Applied"));
338
+ }
339
+ return t;
340
+ },
341
+ });
342
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@ladbabynpm/picc-edit",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code style Edit tool for pi — a faithful port of Claude Code's `Edit` (exact string replacement, quote normalization, read-first guard, replace_all, structured diff).",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "keywords": [
8
+ "pi",
9
+ "pi-package",
10
+ "pi-extension",
11
+ "edit",
12
+ "file",
13
+ "claude-code"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "ladbabynpm",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/Ladbaby/picc-edit.git"
20
+ },
21
+ "homepage": "https://github.com/Ladbaby/picc-edit",
22
+ "bugs": {
23
+ "url": "https://github.com/Ladbaby/picc-edit/issues"
24
+ },
25
+ "files": [
26
+ "index.ts",
27
+ "src",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "pi": {
32
+ "extensions": [
33
+ "./index.ts"
34
+ ]
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-coding-agent": "*",
38
+ "@earendil-works/pi-tui": "*",
39
+ "typebox": "*"
40
+ },
41
+ "dependencies": {
42
+ "diff": "^5.2.0"
43
+ },
44
+ "scripts": {
45
+ "lint": "biome check",
46
+ "lint:fix": "biome check --write",
47
+ "typecheck": "tsc --noEmit",
48
+ "test": "vitest run"
49
+ },
50
+ "devDependencies": {
51
+ "@biomejs/biome": "^2.5.5",
52
+ "@types/diff": "^5.2.1",
53
+ "@types/node": "^24.0.0",
54
+ "typescript": "^5.8.0",
55
+ "vitest": "^3.2.0"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ }
60
+ }
@@ -0,0 +1,15 @@
1
+ // =============================================================================
2
+ // picc-edit — src/constants.ts
3
+ //
4
+
5
+ export const FILE_EDIT_TOOL_NAME = "Edit";
6
+
7
+ /**
8
+ * Error strings returned by the edit orchestrator when the read-first /
9
+ * modified-since-read guards fire.
10
+ */
11
+ export const FILE_NOT_READ_ERROR =
12
+ "File has not been read yet. Read it first before writing to it.";
13
+
14
+ export const FILE_MODIFIED_SINCE_READ_ERROR =
15
+ "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.";