pi-readseek 0.4.25 → 0.4.27

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.
@@ -118,6 +118,11 @@ export interface ReadSeekTranscript {
118
118
  regions: ReadSeekTranscriptRegion[];
119
119
  }
120
120
 
121
+ export interface ReadSeekDetectedObject {
122
+ label: string;
123
+ bbox: [number, number, number, number];
124
+ }
125
+
121
126
  export type ReadSeekDetection =
122
127
  | {
123
128
  kind: "source";
@@ -139,6 +144,8 @@ export type ReadSeekDetection =
139
144
  height: number;
140
145
  animated: boolean;
141
146
  transcribe?: ReadSeekTranscript;
147
+ caption?: string;
148
+ objects?: ReadSeekDetectedObject[];
142
149
  }
143
150
  | {
144
151
  kind: "text";
@@ -155,6 +162,13 @@ interface ReadSeekSearchOptions {
155
162
  signal?: AbortSignal;
156
163
  }
157
164
 
165
+ interface ReadSeekDetectOptions {
166
+ transcribe?: boolean;
167
+ caption?: boolean;
168
+ objects?: boolean;
169
+ signal?: AbortSignal;
170
+ }
171
+
158
172
  function normalizeLanguage(language: string): string {
159
173
  return language === "java" ? "Java" : language;
160
174
  }
@@ -348,10 +362,11 @@ async function spawnReadSeekRaw(args: string[], options: RunReadSeekOptions = {}
348
362
  });
349
363
  childStdin.end(stdin, "utf-8");
350
364
  }
351
- child.on("close", (code) => {
365
+ child.on("close", (code, signal) => {
352
366
  const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
353
367
  const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
354
368
  if (code === 0) succeed(stdout);
369
+ else if (signal) fail(new Error((stderr || `readseek killed by signal ${signal}`).replace(/^error:\s*/i, "")));
355
370
  else fail(new Error((stderr || `readseek exited with status ${code}`).replace(/^error:\s*/i, "")));
356
371
  });
357
372
  });
@@ -659,6 +674,21 @@ function parseTranscript(value: unknown): ReadSeekTranscript | undefined {
659
674
  };
660
675
  }
661
676
 
677
+ function parseDetectedObjects(value: unknown): ReadSeekDetectedObject[] | undefined {
678
+ if (value === undefined || value === null) return undefined;
679
+ if (!Array.isArray(value)) throw new Error("invalid readseek detect objects");
680
+ return value.map((object) => {
681
+ if (!object || typeof object !== "object") throw new Error("invalid readseek detect object");
682
+ const item = object as Record<string, unknown>;
683
+ const bbox = item.bbox;
684
+ if (!Array.isArray(bbox) || bbox.length !== 4) throw new Error("invalid readseek detect object.bbox");
685
+ return {
686
+ label: requireString(item.label, "object.label"),
687
+ bbox: bbox.map((n, i) => requireNumber(n, `object.bbox[${i}]`)) as ReadSeekDetectedObject["bbox"],
688
+ };
689
+ });
690
+ }
691
+
662
692
  function parseDetectOutput(value: unknown): ReadSeekDetection {
663
693
  if (!value || typeof value !== "object") throw new Error("invalid readseek detect output");
664
694
  const output = value as Record<string, unknown>;
@@ -682,6 +712,8 @@ function parseDetectOutput(value: unknown): ReadSeekDetection {
682
712
  height: requireNumber(output.height, "height"),
683
713
  animated: requireBoolean(output.animated, "animated"),
684
714
  transcribe: parseTranscript(output.transcribe),
715
+ caption: optionalString(output.caption, "caption"),
716
+ objects: parseDetectedObjects(output.objects),
685
717
  };
686
718
  }
687
719
  if (output.language !== undefined) {
@@ -701,10 +733,279 @@ function parseDetectOutput(value: unknown): ReadSeekDetection {
701
733
 
702
734
  export async function readseekDetect(
703
735
  filePath: string,
704
- options: { transcribe?: boolean; signal?: AbortSignal } = {},
736
+ options: ReadSeekDetectOptions = {},
705
737
  ): Promise<ReadSeekDetection> {
706
738
  const args = ["detect"];
707
739
  if (options.transcribe) args.push("--transcribe");
740
+ if (options.caption) args.push("--caption");
741
+ if (options.objects) args.push("--objects");
708
742
  args.push(filePath);
709
743
  return parseDetectOutput(await runReadSeek(args, { signal: options.signal }));
710
744
  }
745
+
746
+ // --- Rename ---
747
+
748
+ export interface RenameConflict {
749
+ line: number;
750
+ column: number;
751
+ reason: string;
752
+ }
753
+
754
+ export interface RenameEdit {
755
+ line: number;
756
+ start_column: number;
757
+ end_column: number;
758
+ start_byte: number;
759
+ end_byte: number;
760
+ occurrence: string;
761
+ line_hash: string;
762
+ text: string;
763
+ }
764
+
765
+ export interface RenameFileOutput {
766
+ file: string;
767
+ language: string;
768
+ engine?: string;
769
+ file_hash: string;
770
+ conflicts: RenameConflict[];
771
+ edits: RenameEdit[];
772
+ }
773
+
774
+ export interface RenameOutput {
775
+ file: string;
776
+ language: string;
777
+ engine?: string;
778
+ file_hash: string;
779
+ old_name: string;
780
+ new_name: string;
781
+ applied: boolean;
782
+ conflicts: RenameConflict[];
783
+ edits: RenameEdit[];
784
+ others: RenameFileOutput[];
785
+ }
786
+
787
+ interface RenameOptions {
788
+ to: string;
789
+ line: number;
790
+ column?: number;
791
+ workspace?: string;
792
+ apply?: boolean;
793
+ language?: string;
794
+ cached?: boolean;
795
+ others?: boolean;
796
+ ignored?: boolean;
797
+ signal?: AbortSignal;
798
+ }
799
+
800
+ function parseRenameOutput(value: unknown): RenameOutput {
801
+ if (!value || typeof value !== "object") throw new Error("invalid readseek rename output");
802
+ const output = value as Record<string, unknown>;
803
+ return {
804
+ file: requireString(output.file, "file"),
805
+ language: requireString(output.language, "language"),
806
+ engine: optionalString(output.engine, "engine"),
807
+ file_hash: requireString(output.file_hash, "file_hash"),
808
+ old_name: requireString(output.old_name, "old_name"),
809
+ new_name: requireString(output.new_name, "new_name"),
810
+ applied: requireBoolean(output.applied, "applied"),
811
+ conflicts: (output.conflicts as any[] | undefined)?.map((c: Record<string, unknown>) => ({
812
+ line: requireNumber(c.line, "conflict.line"),
813
+ column: requireNumber(c.column, "conflict.column"),
814
+ reason: requireString(c.reason, "conflict.reason"),
815
+ })) ?? [],
816
+ edits: (output.edits as any[] | undefined)?.map((e: Record<string, unknown>) => ({
817
+ line: requireNumber(e.line, "edit.line"),
818
+ start_column: requireNumber(e.start_column, "edit.start_column"),
819
+ end_column: requireNumber(e.end_column, "edit.end_column"),
820
+ start_byte: requireNumber(e.start_byte, "edit.start_byte"),
821
+ end_byte: requireNumber(e.end_byte, "edit.end_byte"),
822
+ occurrence: requireString(e.occurrence, "edit.occurrence"),
823
+ line_hash: requireString(e.line_hash, "edit.line_hash"),
824
+ text: requireString(e.text, "edit.text"),
825
+ })) ?? [],
826
+ others: (output.others as any[] | undefined)?.map((o: Record<string, unknown>) => ({
827
+ file: requireString(o.file, "other.file"),
828
+ language: requireString(o.language, "other.language"),
829
+ engine: optionalString(o.engine, "other.engine"),
830
+ file_hash: requireString(o.file_hash, "other.file_hash"),
831
+ conflicts: (o.conflicts as any[] | undefined)?.map((c: Record<string, unknown>) => ({
832
+ line: requireNumber(c.line, "conflict.line"),
833
+ column: requireNumber(c.column, "conflict.column"),
834
+ reason: requireString(c.reason, "conflict.reason"),
835
+ })) ?? [],
836
+ edits: (o.edits as any[] | undefined)?.map((e: Record<string, unknown>) => ({
837
+ line: requireNumber(e.line, "edit.line"),
838
+ start_column: requireNumber(e.start_column, "edit.start_column"),
839
+ end_column: requireNumber(e.end_column, "edit.end_column"),
840
+ start_byte: requireNumber(e.start_byte, "edit.start_byte"),
841
+ end_byte: requireNumber(e.end_byte, "edit.end_byte"),
842
+ occurrence: requireString(e.occurrence, "edit.occurrence"),
843
+ line_hash: requireString(e.line_hash, "edit.line_hash"),
844
+ text: requireString(e.text, "edit.text"),
845
+ })) ?? [],
846
+ })) ?? [],
847
+ };
848
+ }
849
+
850
+ export async function readseekRename(
851
+ filePath: string,
852
+ options: RenameOptions,
853
+ ): Promise<RenameOutput> {
854
+ const args = ["rename", filePath, "--line", String(options.line)];
855
+ if (options.column !== undefined) args.push("--column", String(options.column));
856
+ args.push("--to", options.to);
857
+ if (options.apply) args.push("--apply");
858
+ if (options.workspace) args.push("--workspace", options.workspace);
859
+ if (options.language) args.push("--language", options.language);
860
+ if (options.cached) args.push("--cached");
861
+ if (options.others) args.push("--others");
862
+ if (options.ignored) args.push("--ignored");
863
+ return parseRenameOutput(await runReadSeek(args, { signal: options.signal }));
864
+ }
865
+
866
+ // --- Identify ---
867
+
868
+ export interface IdentifierOutput {
869
+ text: string;
870
+ start_column: number;
871
+ end_column: number;
872
+ start_byte: number;
873
+ end_byte: number;
874
+ }
875
+
876
+ export interface IdentifyOutput {
877
+ file: string;
878
+ language: string;
879
+ engine?: string;
880
+ line_count: number;
881
+ file_hash: string;
882
+ line: number;
883
+ column: number;
884
+ line_hash: string;
885
+ identifier?: IdentifierOutput;
886
+ symbol?: {
887
+ name: string;
888
+ kind: string;
889
+ qualified_name: string;
890
+ start_line: number;
891
+ end_line: number;
892
+ };
893
+ }
894
+
895
+ interface IdentifyOptions {
896
+ line?: number;
897
+ column?: number;
898
+ language?: string;
899
+ signal?: AbortSignal;
900
+ }
901
+
902
+ function parseIdentifyOutput(value: unknown): IdentifyOutput {
903
+ if (!value || typeof value !== "object") throw new Error("invalid readseek identify output");
904
+ const output = value as Record<string, unknown>;
905
+ const identifier = output.identifier;
906
+ const symbol = output.symbol;
907
+ return {
908
+ file: requireString(output.file, "file"),
909
+ language: requireString(output.language, "language"),
910
+ engine: optionalString(output.engine, "engine"),
911
+ line_count: requireNumber(output.line_count, "line_count"),
912
+ file_hash: requireString(output.file_hash, "file_hash"),
913
+ line: requireNumber(output.line, "line"),
914
+ column: requireNumber(output.column, "column"),
915
+ line_hash: requireString(output.line_hash, "line_hash"),
916
+ identifier: identifier && typeof identifier === "object"
917
+ ? {
918
+ text: requireString((identifier as Record<string, unknown>).text, "identifier.text"),
919
+ start_column: requireNumber((identifier as Record<string, unknown>).start_column, "identifier.start_column"),
920
+ end_column: requireNumber((identifier as Record<string, unknown>).end_column, "identifier.end_column"),
921
+ start_byte: requireNumber((identifier as Record<string, unknown>).start_byte, "identifier.start_byte"),
922
+ end_byte: requireNumber((identifier as Record<string, unknown>).end_byte, "identifier.end_byte"),
923
+ }
924
+ : undefined,
925
+ symbol: symbol && typeof symbol === "object"
926
+ ? {
927
+ name: requireString((symbol as Record<string, unknown>).name, "symbol.name"),
928
+ kind: requireString((symbol as Record<string, unknown>).kind, "symbol.kind"),
929
+ qualified_name: requireString((symbol as Record<string, unknown>).qualified_name, "symbol.qualified_name"),
930
+ start_line: requireNumber((symbol as Record<string, unknown>).start_line, "symbol.start_line"),
931
+ end_line: requireNumber((symbol as Record<string, unknown>).end_line, "symbol.end_line"),
932
+ }
933
+ : undefined,
934
+ };
935
+ }
936
+
937
+ export async function readseekIdentify(
938
+ filePath: string,
939
+ content: string,
940
+ options: IdentifyOptions = {},
941
+ ): Promise<IdentifyOutput> {
942
+ const args = ["identify", "--stdin", filePath];
943
+ if (options.line !== undefined) args.push("--line", String(options.line));
944
+ if (options.column !== undefined) args.push("--column", String(options.column));
945
+ if (options.language) args.push("--language", options.language);
946
+ return parseIdentifyOutput(await runReadSeek(args, { signal: options.signal, stdin: content }));
947
+ }
948
+
949
+ // --- Def ---
950
+
951
+ export interface DefLocation {
952
+ file: string;
953
+ line: number;
954
+ column: number;
955
+ line_hash: string;
956
+ text: string;
957
+ kind?: string;
958
+ name?: string;
959
+ qualified_name?: string;
960
+ }
961
+
962
+ interface DefOptions {
963
+ name?: string;
964
+ fromIdentify?: boolean;
965
+ identifyInput?: string;
966
+ language?: string;
967
+ cached?: boolean;
968
+ others?: boolean;
969
+ ignored?: boolean;
970
+ signal?: AbortSignal;
971
+ }
972
+
973
+ function parseDefCompact(value: unknown): DefLocation[] {
974
+ if (!value || typeof value !== "object") throw new Error("invalid readseek def output");
975
+ const output = value as Record<string, unknown>;
976
+ const locations = output.locations;
977
+ if (!Array.isArray(locations)) throw new Error("invalid readseek def locations");
978
+ return locations.map((loc) => {
979
+ if (!loc || typeof loc !== "object") throw new Error("invalid readseek def location");
980
+ const item = loc as Record<string, unknown>;
981
+ return {
982
+ file: requireString(item.file, "location.file"),
983
+ line: requireNumber(item.line, "location.line"),
984
+ column: requireNumber(item.column, "location.column"),
985
+ line_hash: requireString(item.line_hash, "location.line_hash"),
986
+ text: requireString(item.text, "location.text"),
987
+ kind: optionalString(item.kind, "location.kind"),
988
+ name: optionalString(item.name, "location.name"),
989
+ qualified_name: optionalString(item.qualified_name, "location.qualified_name"),
990
+ };
991
+ });
992
+ }
993
+
994
+ export async function readseekDef(
995
+ target: string,
996
+ options: DefOptions = {},
997
+ ): Promise<DefLocation[]> {
998
+ const args = ["def", target, "--format", "plain"];
999
+ if (options.fromIdentify) {
1000
+ args.push("--from-identify");
1001
+ }
1002
+ if (options.name) {
1003
+ args.push(options.name);
1004
+ }
1005
+ if (options.language) args.push("--language", options.language);
1006
+ if (options.cached) args.push("--cached");
1007
+ if (options.others) args.push("--others");
1008
+ if (options.ignored) args.push("--ignored");
1009
+ const stdin = options.fromIdentify && options.identifyInput ? options.identifyInput : undefined;
1010
+ return parseDefCompact(await runReadSeek(args, { signal: options.signal, stdin }));
1011
+ }
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  interface ReadSeekJsonSettings {
6
6
  grep?: { maxLines?: number; maxBytes?: number };
7
7
  edit?: { diffDisplay?: "collapsed" | "expanded" };
8
+ read?: { ocrMode?: "on" | "off" | "auto" };
8
9
  }
9
10
 
10
11
  interface ReadSeekSettingsWarning {
@@ -75,6 +76,16 @@ function validateSettings(raw: unknown, source: string): ReadSeekSettingsResult
75
76
  if (Object.keys(edit).length > 0) settings.edit = edit;
76
77
  }
77
78
 
79
+ if (isRecord(raw.read)) {
80
+ const read: NonNullable<ReadSeekJsonSettings["read"]> = {};
81
+ if ("ocrMode" in raw.read) {
82
+ const value = raw.read.ocrMode;
83
+ if (value === "on" || value === "off" || value === "auto") read.ocrMode = value;
84
+ else warnings.push(invalid(source, "read.ocrMode"));
85
+ }
86
+ if (Object.keys(read).length > 0) settings.read = read;
87
+ }
88
+
78
89
  return { settings, warnings };
79
90
  }
80
91
 
@@ -96,6 +107,8 @@ function mergeSettings(base: ReadSeekJsonSettings, override: ReadSeekJsonSetting
96
107
  if (Object.keys(grep).length > 0) merged.grep = grep;
97
108
  const edit = { ...(base.edit ?? {}), ...(override.edit ?? {}) };
98
109
  if (Object.keys(edit).length > 0) merged.edit = edit;
110
+ const read = { ...(base.read ?? {}), ...(override.read ?? {}) };
111
+ if (Object.keys(read).length > 0) merged.read = read;
99
112
  return merged;
100
113
  }
101
114
 
@@ -118,3 +131,14 @@ export function resolveEditDiffDisplay(env: NodeJS.ProcessEnv = process.env): "c
118
131
  if (json === "expanded" || json === "collapsed") return json;
119
132
  return "collapsed";
120
133
  }
134
+
135
+ export function resolveReadSeekOcrMode(env: NodeJS.ProcessEnv = process.env): "on" | "off" | "auto" {
136
+ const raw = env.READSEEK_READ_OCR_MODE;
137
+ if (typeof raw === "string") {
138
+ const normalized = raw.trim().toLowerCase();
139
+ if (normalized === "on" || normalized === "off" || normalized === "auto") return normalized;
140
+ }
141
+ const json = resolveReadSeekJsonSettings().settings.read?.ocrMode;
142
+ if (json === "on" || json === "off" || json === "auto") return json;
143
+ return "on";
144
+ }
package/src/rename.ts ADDED
@@ -0,0 +1,167 @@
1
+ import type { ExtensionAPI, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import path from "node:path";
4
+ import { Text } from "@earendil-works/pi-tui";
5
+
6
+ import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
7
+ import { buildToolErrorResult } from "./readseek-value.js";
8
+ import { resolveToCwd } from "./path-utils.js";
9
+ import { classifyReadSeekFailure, readseekRename, type RenameOutput } from "./readseek-client.js";
10
+ import { filePathParam, registerReadSeekTool } from "./register-tool.js";
11
+
12
+ import { clampLinesToWidth, linkToolPath, renderPendingResult, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
13
+
14
+ const RENAME_PROMPT_METADATA = defineToolPromptMetadata({
15
+ promptUrl: new URL("../prompts/rename.md", import.meta.url),
16
+ promptSnippet: "Rename an identifier with binding-accurate readseek rename",
17
+ });
18
+
19
+ const renameSchema = Type.Object({
20
+ path: filePathParam(),
21
+ line: Type.Number({ description: "One-based cursor line of the binding to rename" }),
22
+ column: Type.Optional(Type.Number({ description: "One-based cursor byte column of the binding to rename" })),
23
+ to: Type.String({ description: "New name for the binding" }),
24
+ workspace: Type.Optional(Type.Boolean({ description: "Expand rename across project root (name-based outside cursor file)" })),
25
+ apply: Type.Optional(Type.Boolean({ description: "Write the planned edits to disk after verifying line hashes" })),
26
+ });
27
+
28
+ interface RenameParams {
29
+ path: string;
30
+ line: number;
31
+ column?: number;
32
+ to: string;
33
+ workspace?: boolean;
34
+ apply?: boolean;
35
+ }
36
+
37
+ export interface ExecuteRenameOptions {
38
+ params: unknown;
39
+ signal: AbortSignal | undefined;
40
+ cwd: string;
41
+ }
42
+
43
+ export async function executeRename(opts: ExecuteRenameOptions): Promise<any> {
44
+ const { params, signal, cwd } = opts;
45
+ const p = params as RenameParams;
46
+
47
+ if (!p.to.trim()) {
48
+ return buildToolErrorResult("rename", "invalid-parameter", "rename parameter 'to' must not be empty");
49
+ }
50
+ if (!Number.isSafeInteger(p.line) || p.line < 1) {
51
+ return buildToolErrorResult("rename", "invalid-parameter", "rename parameter 'line' must be a positive integer");
52
+ }
53
+
54
+ const filePath = resolveToCwd(p.path, cwd);
55
+
56
+ try {
57
+ const output = await readseekRename(filePath, {
58
+ to: p.to,
59
+ line: p.line,
60
+ column: p.column,
61
+ workspace: p.workspace ? cwd : undefined,
62
+ apply: p.apply ?? true,
63
+ signal,
64
+ });
65
+
66
+ const files: string[] = [output.file];
67
+ for (const other of output.others) {
68
+ const abs = path.isAbsolute(other.file) ? other.file : path.resolve(cwd, other.file);
69
+ if (!files.includes(abs)) files.push(abs);
70
+ }
71
+
72
+ const totalEdits = output.edits.length + output.others.reduce((sum, o) => sum + o.edits.length, 0);
73
+ const totalConflicts = output.conflicts.length + output.others.reduce((sum, o) => sum + o.conflicts.length, 0);
74
+
75
+ const parts: string[] = [];
76
+ if (totalConflicts > 0) {
77
+ parts.push(`${totalConflicts} naming conflict(s)`);
78
+ }
79
+ parts.push(`renamed ${output.old_name} to ${output.new_name} in ${totalEdits} location(s) across ${files.length} file(s)`);
80
+
81
+ let text = parts.join("; ");
82
+ if (output.applied) text += " (applied)";
83
+ else text += " (dry-run)";
84
+
85
+ // Surface first conflict reason for visibility
86
+ const firstConflict = output.conflicts[0]
87
+ ?? output.others.find((o) => o.conflicts.length > 0)?.conflicts[0];
88
+ if (firstConflict) {
89
+ text += `\nFirst conflict at ${firstConflict.line}:${firstConflict.column}: ${firstConflict.reason}`;
90
+ }
91
+
92
+ return {
93
+ content: [{ type: "text", text }],
94
+ details: {
95
+ readseekValue: {
96
+ tool: "rename",
97
+ ok: true,
98
+ path: filePath,
99
+ output,
100
+ },
101
+ },
102
+ };
103
+ } catch (err: any) {
104
+ const failure = classifyReadSeekFailure(err);
105
+ return buildToolErrorResult("rename", failure.code, failure.message, failure.hint ? { hint: failure.hint } : {});
106
+ }
107
+ }
108
+
109
+ export function registerRenameTool(pi: ExtensionAPI) {
110
+ registerReadSeekTool(pi, {
111
+ policy: "mutating",
112
+ pythonName: "rename",
113
+ defaultExposure: "not-safe-by-default",
114
+ }, {
115
+ name: "rename",
116
+ label: "Rename",
117
+ description: RENAME_PROMPT_METADATA.description,
118
+ promptSnippet: RENAME_PROMPT_METADATA.promptSnippet,
119
+ promptGuidelines: RENAME_PROMPT_METADATA.promptGuidelines,
120
+ parameters: renameSchema,
121
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
122
+ return executeRename({ params, signal, cwd: ctx.cwd });
123
+ },
124
+ renderCall(args: any, theme: any, ...rest: any[]) {
125
+ const context = rest[0] ?? {};
126
+ const cwd = context.cwd ?? process.cwd();
127
+ const displayPath = typeof args?.path === "string" ? args.path : "?";
128
+ const oldName = typeof args?.to === "string" ? "" : "";
129
+ let text = theme.fg("toolTitle", theme.bold("rename"));
130
+ text += ` ${linkToolPath(theme.fg("accent", displayPath), displayPath, cwd)}`;
131
+ if (args?.to) text += theme.fg("dim", ` → ${args.to}`);
132
+ return text;
133
+ },
134
+ renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
135
+ const { isPartial, isError, expanded, width } = resolveRenderResultContext(options, rest);
136
+
137
+ if (isPartial) return renderPendingResult("pending rename", width);
138
+
139
+ const content = result.content?.[0];
140
+ const textContent = content?.type === "text" ? content.text : "";
141
+ const readseekValue = (result.details as any)?.readseekValue;
142
+ const output = readseekValue?.output as RenameOutput | undefined;
143
+
144
+ if (isError || result.isError) {
145
+ return new Text(textContent || "rename failed", 0, 0);
146
+ }
147
+
148
+ let text = summaryLine(
149
+ output?.applied
150
+ ? `renamed ${output.old_name} → ${output.new_name}`
151
+ : `rename plan for ${output?.old_name ?? "?"} → ${output?.new_name ?? "?"} (dry-run)`,
152
+ );
153
+
154
+ if (expanded && output) {
155
+ const files = new Set<string>();
156
+ files.add(output.file);
157
+ for (const o of output.others) files.add(o.file);
158
+ for (const f of files) {
159
+ text += `\n ${f}`;
160
+ }
161
+ }
162
+
163
+ const lines = clampLinesToWidth(text.split("\n"), width);
164
+ return new Text(lines.join("\n"), 0, 0);
165
+ },
166
+ });
167
+ }
@@ -2,6 +2,7 @@ import { stat as fsStat } from "node:fs/promises";
2
2
  import type { Stats } from "node:fs";
3
3
 
4
4
  import { buildToolErrorResult, type ToolErrorResult } from "./readseek-value.js";
5
+ import { formatFsError } from "./fs-error.js";
5
6
 
6
7
  export type StatSearchPathResult =
7
8
  | { ok: true; stats: Stats }
@@ -22,13 +23,7 @@ export async function statSearchPathOrError(
22
23
  } catch (err: any) {
23
24
  const display = rawPath ?? ".";
24
25
  const path = rawPath ?? searchPath;
25
- if (err?.code === "ENOENT") {
26
- return { ok: false, error: buildToolErrorResult(tool, "path-not-found", `Path '${display}' does not exist`, { path }) };
27
- }
28
- if (err?.code === "EACCES" || err?.code === "EPERM") {
29
- return { ok: false, error: buildToolErrorResult(tool, "permission-denied", `Permission denied for path '${display}'`, { path }) };
30
- }
31
- const message = `Could not access path '${display}': ${err?.message ?? String(err)}`;
32
- return { ok: false, error: buildToolErrorResult(tool, "fs-error", message, { path, details: { fsCode: err?.code, fsMessage: err?.message } }) };
26
+ const { code, message } = formatFsError(err, "stat-error");
27
+ return { ok: false, error: buildToolErrorResult(tool, code, message, { path }) };
33
28
  }
34
29
  }
package/src/write.ts CHANGED
@@ -9,7 +9,7 @@ import { resolveToCwd } from "./path-utils.js";
9
9
  import { ensureHashInit, formatHashlineDisplay } from "./hashline.js";
10
10
  import { buildReadSeekError, buildReadSeekLine, buildReadSeekWarning, buildToolErrorResult, type ReadSeekLine, type ReadSeekWarning } from "./readseek-value.js";
11
11
  import { looksLikeBinary } from "./binary-detect.js";
12
- import { classifyFsError, type FsErrorCode } from "./fs-error.js";
12
+ import { formatFsError } from "./fs-error.js";
13
13
  import { getOrGenerateMap } from "./file-map.js";
14
14
  import { formatFileMapWithBudget } from "./readseek/formatter.js";
15
15
 
@@ -120,57 +120,12 @@ export interface WriteToolOptions {
120
120
  onFileAnchored?: FileAnchoredCallback;
121
121
  }
122
122
 
123
- type MappedFsError = {
124
- code: FsErrorCode | "fs-error";
125
- message: string;
126
- hint?: string;
127
- includeMeta: boolean;
128
- };
129
-
130
- function mapFsWriteError(err: any, path: string): MappedFsError {
131
- const phase: "mkdir" | "write" | undefined = err?.__phase;
132
- const fsCode = err?.code as string | undefined;
133
-
134
- if (fsCode === "ENOENT" && phase === "mkdir") {
135
- return {
136
- code: "fs-error",
137
- message: `Cannot create parent directories for ${path}: ${err?.message ?? String(err)}`,
138
- includeMeta: true,
139
- };
140
- }
141
- if (fsCode === "ENOSPC") {
142
- return {
143
- code: "fs-error",
144
- message: `No space left on device — cannot write: ${path}`,
145
- includeMeta: true,
146
- };
147
- }
148
- if (fsCode === "EROFS") {
149
- return {
150
- code: "fs-error",
151
- message: `Read-only filesystem — cannot write: ${path}`,
152
- includeMeta: true,
153
- };
154
- }
155
- const fsError = classifyFsError(err, path);
156
- if (fsError) {
157
- return { code: fsError.code, message: fsError.message, hint: fsError.hint, includeMeta: false };
158
- }
159
- return {
160
- code: "fs-error",
161
- message: `Error writing ${path}: ${err?.message ?? String(err)}`,
162
- includeMeta: true,
163
- };
164
- }
165
-
166
123
  function buildWriteFsErrorResult(err: any, absolutePath: string) {
167
- const mapped = mapFsWriteError(err, absolutePath);
168
- return buildToolErrorResult("write", mapped.code, mapped.message, {
169
- path: absolutePath,
170
- hint: mapped.hint,
171
- extra: { lines: [], warnings: [] },
172
- details: mapped.includeMeta ? { fsCode: err?.code, fsMessage: err?.message } : undefined,
173
- });
124
+ const { code, message } = formatFsError(err, "write-error");
125
+ return buildToolErrorResult("write", code, message, {
126
+ path: absolutePath,
127
+ extra: { lines: [], warnings: [] },
128
+ });
174
129
  }
175
130
 
176
131
  export async function executeWrite(opts: {
@@ -218,21 +173,10 @@ export async function executeWrite(opts: {
218
173
  const previousContent = await readPreviousTextForDiff(filePath);
219
174
  const existedBeforeWrite = await access(filePath).then(() => true, () => false);
220
175
 
221
- // Create parent directories
222
- try {
223
- await mkdir(dirname(filePath), { recursive: true });
224
- } catch (err: any) {
225
- err.__phase = "mkdir";
226
- throw err;
227
- }
228
- // Write file
229
- try {
230
- await writeFile(filePath, content, "utf-8");
231
- } catch (err: any) {
232
- err.__phase = "write";
233
- throw err;
234
- }
235
-
176
+ // Create parent directories
177
+ await mkdir(dirname(filePath), { recursive: true });
178
+ // Write file
179
+ await writeFile(filePath, content, "utf-8");
236
180
  // Compute hashlines
237
181
  const rawLines = content.split("\n");
238
182
  const readseekLines: ReadSeekLine[] = [];