ysagc-agent 0.5.0 → 0.7.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/dist/tools.js CHANGED
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.ToolError = void 0;
37
+ exports.walkTree = walkTree;
38
+ exports.globToRegex = globToRegex;
37
39
  exports.preExecute = preExecute;
38
40
  exports.postExecute = postExecute;
39
41
  exports.createToolRegistry = createToolRegistry;
@@ -95,6 +97,15 @@ function requiredArray(args, key) {
95
97
  }
96
98
  return v;
97
99
  }
100
+ function optionalBool(args, key, fallback) {
101
+ const v = args[key];
102
+ if (v === undefined || v === null)
103
+ return fallback;
104
+ if (typeof v !== 'boolean') {
105
+ throw new ToolError('INVALID_ARG', `"${key}" must be a boolean.`);
106
+ }
107
+ return v;
108
+ }
98
109
  function abortIfRequested(signal) {
99
110
  if (signal?.aborted) {
100
111
  throw new ToolError('ABORTED', 'Operation was cancelled.');
@@ -119,11 +130,11 @@ function isUtf8(buf) {
119
130
  return false;
120
131
  }
121
132
  }
122
- function readUpTo(target, length) {
133
+ function readUpTo(target, length, offset = 0) {
123
134
  const fd = fs.openSync(target, 'r');
124
135
  try {
125
136
  const buf = Buffer.alloc(length);
126
- const bytesRead = fs.readSync(fd, buf, 0, length, 0);
137
+ const bytesRead = fs.readSync(fd, buf, 0, length, offset);
127
138
  return buf.subarray(0, Math.max(0, bytesRead));
128
139
  }
129
140
  finally {
@@ -145,13 +156,142 @@ function shellQuote(arg) {
145
156
  }
146
157
  return arg;
147
158
  }
159
+ // ---------------------------------------------------------------------------
160
+ // directory walking / glob helpers
161
+ // ---------------------------------------------------------------------------
162
+ /** Heavy / generated directories that file walkers skip by default so a large
163
+ * dependency tree does not flood a search or glob result. */
164
+ const DEFAULT_WALK_EXCLUDE = new Set([
165
+ '.git',
166
+ '.hg',
167
+ '.svn',
168
+ 'node_modules',
169
+ 'dist',
170
+ 'build',
171
+ 'out',
172
+ '.cache',
173
+ '.npm',
174
+ '.venv',
175
+ 'venv',
176
+ 'env',
177
+ '__pycache__',
178
+ '.next',
179
+ '.nuxt',
180
+ '.turbo',
181
+ 'coverage',
182
+ '.idea',
183
+ '.vs',
184
+ 'target',
185
+ '.docusaurus',
186
+ ]);
187
+ /**
188
+ * Walk a directory tree depth-first, returning every file and directory as a
189
+ * flat list. Never follows symbolic links (which defends against root escape
190
+ * and symlink cycles), skips the default heavy directories, and bounds the
191
+ * total number of entries returned. Directory entries are guaranteed to be
192
+ * returned before their children.
193
+ */
194
+ function walkTree(base, opts = {}) {
195
+ const max = opts.maxEntries ?? 5000;
196
+ const exclude = opts.exclude ?? DEFAULT_WALK_EXCLUDE;
197
+ const includeHidden = opts.includeHidden ?? false;
198
+ const out = [];
199
+ const stack = [{ rel: '', abs: base }];
200
+ while (stack.length) {
201
+ const cur = stack.pop();
202
+ let names;
203
+ try {
204
+ names = fs.readdirSync(cur.abs);
205
+ }
206
+ catch {
207
+ continue;
208
+ }
209
+ names.sort();
210
+ for (const name of names) {
211
+ if (!includeHidden && name.startsWith('.'))
212
+ continue;
213
+ if (exclude.has(name))
214
+ continue;
215
+ const rel = cur.rel ? `${cur.rel}/${name}` : name;
216
+ const abs = path.join(cur.abs, name);
217
+ let st;
218
+ try {
219
+ st = fs.lstatSync(abs);
220
+ }
221
+ catch {
222
+ continue;
223
+ }
224
+ if (st.isSymbolicLink())
225
+ continue;
226
+ const type = st.isDirectory() ? 'dir' : st.isFile() ? 'file' : 'other';
227
+ if (type === 'other')
228
+ continue;
229
+ out.push({ rel, abs, type });
230
+ if (out.length >= max)
231
+ return out;
232
+ if (type === 'dir')
233
+ stack.push({ rel, abs });
234
+ }
235
+ }
236
+ return out;
237
+ }
238
+ /** Whether a file buffer is binary (contains a NUL byte). */
239
+ function isBinary(buf) {
240
+ return buf.includes(0);
241
+ }
242
+ /** Convert a glob pattern (`*`, `?`, `**`, and literals — no char classes)
243
+ * into an anchored regular expression. `**` matches zero or more path
244
+ * segments; `*`/`?` match within a single segment. On Windows the match is
245
+ * case-insensitive to mirror the filesystem. */
246
+ function globToRegex(glob) {
247
+ let out = '^';
248
+ let i = 0;
249
+ const n = glob.length;
250
+ while (i < n) {
251
+ const ch = glob[i];
252
+ if (ch === '*') {
253
+ if (glob[i + 1] === '*') {
254
+ i += 2;
255
+ if (glob[i] === '/') {
256
+ out += '(?:.*/)?';
257
+ i += 1;
258
+ }
259
+ else {
260
+ out += '.*';
261
+ }
262
+ continue;
263
+ }
264
+ out += '[^/]*';
265
+ i += 1;
266
+ continue;
267
+ }
268
+ if (ch === '?') {
269
+ out += '[^/]';
270
+ i += 1;
271
+ continue;
272
+ }
273
+ if ('\\.^$|()[]{}'.includes(ch)) {
274
+ out += '\\' + ch;
275
+ }
276
+ else {
277
+ out += ch;
278
+ }
279
+ i += 1;
280
+ }
281
+ out += '$';
282
+ return new RegExp(out, process.platform === 'win32' ? 'i' : '');
283
+ }
148
284
  /** Tools that mutate the workspace or execute commands (blocked by read-only). */
149
285
  const WRITE_OR_COMMAND_TOOLS = new Set([
150
286
  'write_file',
151
287
  'edit_file',
288
+ 'str_replace_editor',
152
289
  'generate',
153
290
  'run_command',
154
291
  'git',
292
+ 'mv',
293
+ 'cp',
294
+ 'delete_file',
155
295
  ]);
156
296
  /** Reconstruct the shell command a command-tool intends to run, if any. */
157
297
  function commandForTool(tool, args) {
@@ -173,6 +313,8 @@ function pathArgsForTool(tool, args) {
173
313
  const keys = ['path', 'cwd'];
174
314
  if (tool === 'generate')
175
315
  keys.push('name');
316
+ if (tool === 'mv' || tool === 'cp')
317
+ keys.push('source', 'destination');
176
318
  const out = [];
177
319
  for (const key of keys) {
178
320
  const v = args[key];
@@ -350,7 +492,11 @@ const listDir = async (ctx, args) => {
350
492
  const readFile = async (ctx, args, signal) => {
351
493
  abortIfRequested(signal);
352
494
  const targetStr = requireString(args, 'path');
353
- const maxBytes = optionalInt(args, 'maxBytes', ctx.maxBytes, { min: 1, max: MAX_WRITE_BYTES });
495
+ const offset = optionalInt(args, 'offset', 0, { min: 0 });
496
+ // `length` takes precedence over the legacy `maxBytes`; if neither is given,
497
+ // fall back to the configured output cap.
498
+ const hasLength = args.length !== undefined && args.length !== null;
499
+ const length = optionalInt(args, hasLength ? 'length' : 'maxBytes', ctx.maxBytes, { min: 1, max: MAX_WRITE_BYTES });
354
500
  const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
355
501
  const target = (0, path_utils_1.safeResolve)(ctx.root, targetStr, realRoot);
356
502
  assertNotSensitive(target);
@@ -365,9 +511,12 @@ const readFile = async (ctx, args, signal) => {
365
511
  throw new ToolError('IS_DIR', `Path is a directory, not a file: ${targetStr}`);
366
512
  }
367
513
  const totalBytes = stat.size;
368
- const readLen = Math.min(maxBytes + 1, totalBytes);
369
- const buf = readUpTo(target, readLen);
370
- const truncated = totalBytes > maxBytes;
514
+ const available = Math.max(0, totalBytes - offset);
515
+ // Return exactly the requested window (capped at what is available) and
516
+ // report `truncated` when more bytes remain beyond that window.
517
+ const readLen = Math.min(length, available);
518
+ const buf = readUpTo(target, readLen, offset);
519
+ const truncated = available > length;
371
520
  const bytesRead = buf.length;
372
521
  const utf8 = isUtf8(buf);
373
522
  const content = utf8 ? buf.toString('utf8') : buf.toString('hex');
@@ -377,6 +526,7 @@ const readFile = async (ctx, args, signal) => {
377
526
  path: rel(realRoot, target),
378
527
  bytesRead,
379
528
  totalBytes,
529
+ offset,
380
530
  truncated,
381
531
  encoding: utf8 ? 'utf8' : 'hex',
382
532
  content,
@@ -602,6 +752,601 @@ const generate = async (ctx, args, signal) => {
602
752
  result: { template, name, root: rel(realRoot, targetDir), files: written },
603
753
  };
604
754
  };
755
+ // --- search (grep) ---------------------------------------------------------
756
+ const search = async (ctx, args, signal) => {
757
+ abortIfRequested(signal);
758
+ const patternStr = requireString(args, 'pattern');
759
+ const targetStr = optionalString(args, 'path', '.') ?? '.';
760
+ const maxResults = optionalInt(args, 'maxResults', 100, { min: 1, max: 5000 });
761
+ const maxFileBytes = optionalInt(args, 'maxBytes', 256 * 1024, { min: 1, max: MAX_WRITE_BYTES });
762
+ const caseSensitive = optionalBool(args, 'caseSensitive', false);
763
+ const recursive = optionalBool(args, 'recursive', true);
764
+ let re;
765
+ try {
766
+ re = new RegExp(patternStr, caseSensitive ? 'gm' : 'gim');
767
+ }
768
+ catch {
769
+ throw new ToolError('INVALID_REGEX', `Invalid search pattern: ${patternStr}`);
770
+ }
771
+ const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
772
+ const target = (0, path_utils_1.safeResolve)(ctx.root, targetStr, realRoot, { allowEqual: true });
773
+ assertNotSensitive(target);
774
+ let st;
775
+ try {
776
+ st = fs.statSync(target);
777
+ }
778
+ catch {
779
+ throw new ToolError('NOT_FOUND', `Path does not exist: ${targetStr}`);
780
+ }
781
+ const matches = [];
782
+ let count = 0;
783
+ const scanFile = (abs) => {
784
+ let mbuf;
785
+ try {
786
+ const fst = fs.statSync(abs);
787
+ if (fst.size > maxFileBytes)
788
+ return;
789
+ mbuf = fs.readFileSync(abs);
790
+ }
791
+ catch {
792
+ return;
793
+ }
794
+ if (isBinary(mbuf))
795
+ return;
796
+ const lines = mbuf.toString('utf8').split('\n');
797
+ for (let i = 0; i < lines.length; i++) {
798
+ const lineText = lines[i];
799
+ re.lastIndex = 0;
800
+ // While loop with a zero-length-match guard to avoid infinite loops.
801
+ for (;;) {
802
+ const m = re.exec(lineText);
803
+ if (!m)
804
+ break;
805
+ count += 1;
806
+ if (matches.length < maxResults) {
807
+ matches.push({ path: rel(realRoot, abs), line: i + 1, text: lineText });
808
+ }
809
+ if (m[0] === '')
810
+ re.lastIndex = (m.index ?? 0) + 1;
811
+ }
812
+ }
813
+ };
814
+ if (st.isFile()) {
815
+ scanFile(target);
816
+ }
817
+ else if (st.isDirectory()) {
818
+ if (recursive) {
819
+ const entries = walkTree(target, { maxEntries: Math.max(maxResults * 8, 500), includeHidden: false });
820
+ for (const e of entries) {
821
+ if (e.type !== 'file')
822
+ continue;
823
+ scanFile(e.abs);
824
+ if (matches.length >= maxResults && count > maxResults)
825
+ break;
826
+ }
827
+ }
828
+ else {
829
+ let names = [];
830
+ try {
831
+ names = fs.readdirSync(target).sort();
832
+ }
833
+ catch {
834
+ /* ignore */
835
+ }
836
+ for (const name of names) {
837
+ if (name.startsWith('.'))
838
+ continue;
839
+ const full = path.join(target, name);
840
+ let s;
841
+ try {
842
+ s = fs.lstatSync(full);
843
+ }
844
+ catch {
845
+ continue;
846
+ }
847
+ if (s.isSymbolicLink() || !s.isFile())
848
+ continue;
849
+ scanFile(full);
850
+ if (matches.length >= maxResults && count > maxResults)
851
+ break;
852
+ }
853
+ }
854
+ }
855
+ else {
856
+ throw new ToolError('NOT_A_FILE_OR_DIR', `Path is not a file or directory: ${targetStr}`);
857
+ }
858
+ return {
859
+ ok: true,
860
+ result: {
861
+ pattern: patternStr,
862
+ path: rel(realRoot, target),
863
+ matches,
864
+ count,
865
+ truncated: count > matches.length,
866
+ },
867
+ };
868
+ };
869
+ // --- glob ------------------------------------------------------------------
870
+ const glob = async (ctx, args, signal) => {
871
+ abortIfRequested(signal);
872
+ const pattern = requireString(args, 'pattern');
873
+ const baseStr = optionalString(args, 'path', '.') ?? '.';
874
+ const maxResults = optionalInt(args, 'maxResults', 1000, { min: 1, max: 5000 });
875
+ const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
876
+ const base = (0, path_utils_1.safeResolve)(ctx.root, baseStr, realRoot, { allowEqual: true });
877
+ assertNotSensitive(base);
878
+ let st;
879
+ try {
880
+ st = fs.statSync(base);
881
+ }
882
+ catch {
883
+ throw new ToolError('NOT_FOUND', `Path does not exist: ${baseStr}`);
884
+ }
885
+ if (!st.isDirectory()) {
886
+ throw new ToolError('NOT_A_DIR', `Path is not a directory: ${baseStr}`);
887
+ }
888
+ const re = globToRegex(pattern);
889
+ const entries = walkTree(base, { maxEntries: Math.max(maxResults * 4, 500), includeHidden: false });
890
+ const matches = [];
891
+ for (const e of entries) {
892
+ if (e.type && re.test(e.rel)) {
893
+ matches.push({ path: rel(realRoot, e.abs), type: e.type });
894
+ if (matches.length >= maxResults)
895
+ break;
896
+ }
897
+ }
898
+ return {
899
+ ok: true,
900
+ result: {
901
+ pattern,
902
+ path: rel(realRoot, base),
903
+ matches,
904
+ count: matches.length,
905
+ truncated: matches.length >= maxResults,
906
+ },
907
+ };
908
+ };
909
+ const tree = async (ctx, args, signal) => {
910
+ abortIfRequested(signal);
911
+ const targetStr = optionalString(args, 'path', '.') ?? '.';
912
+ const depth = optionalInt(args, 'depth', 2, { min: 0, max: 12 });
913
+ const maxEntries = optionalInt(args, 'maxEntries', 500, { min: 1, max: 5000 });
914
+ const includeHidden = optionalBool(args, 'showHidden', false);
915
+ const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
916
+ const target = (0, path_utils_1.safeResolve)(ctx.root, targetStr, realRoot, { allowEqual: true });
917
+ assertNotSensitive(target);
918
+ let st;
919
+ try {
920
+ st = fs.statSync(target);
921
+ }
922
+ catch {
923
+ throw new ToolError('NOT_FOUND', `Path does not exist: ${targetStr}`);
924
+ }
925
+ if (!st.isDirectory()) {
926
+ throw new ToolError('NOT_A_DIR', `Path is not a directory: ${targetStr}`);
927
+ }
928
+ let count = 0;
929
+ let truncated = false;
930
+ const build = (absDir, cur) => {
931
+ if (cur > depth)
932
+ return [];
933
+ let names = [];
934
+ try {
935
+ names = fs.readdirSync(absDir).sort();
936
+ }
937
+ catch {
938
+ return [];
939
+ }
940
+ const nodes = [];
941
+ for (const name of names) {
942
+ if (!includeHidden && name.startsWith('.'))
943
+ continue;
944
+ const full = path.join(absDir, name);
945
+ let cst;
946
+ try {
947
+ cst = fs.lstatSync(full);
948
+ }
949
+ catch {
950
+ continue;
951
+ }
952
+ if (cst.isSymbolicLink())
953
+ continue;
954
+ const type = cst.isDirectory() ? 'dir' : cst.isFile() ? 'file' : '';
955
+ if (!type)
956
+ continue;
957
+ count += 1;
958
+ const node = {
959
+ name,
960
+ path: rel(realRoot, full),
961
+ type,
962
+ ...(cst.isFile() ? { size: cst.size } : {}),
963
+ };
964
+ if (type === 'dir' && cur + 1 <= depth) {
965
+ const children = build(full, cur + 1);
966
+ if (children.length)
967
+ node.children = children;
968
+ }
969
+ nodes.push(node);
970
+ if (count >= maxEntries) {
971
+ truncated = true;
972
+ break;
973
+ }
974
+ }
975
+ return nodes;
976
+ };
977
+ const treeNodes = build(target, 0);
978
+ const lines = [rel(realRoot, target) || '.'];
979
+ const render = (nodes, prefix) => {
980
+ nodes.forEach((node, idx) => {
981
+ const last = idx === nodes.length - 1;
982
+ lines.push(`${prefix}${last ? '└── ' : '├── '}${node.name}${node.type === 'dir' ? '/' : ''}`);
983
+ if (node.children && node.children.length) {
984
+ render(node.children, `${prefix}${last ? ' ' : '│ '}`);
985
+ }
986
+ });
987
+ };
988
+ render(treeNodes, '');
989
+ return {
990
+ ok: true,
991
+ result: {
992
+ path: rel(realRoot, target),
993
+ depth,
994
+ truncated,
995
+ count,
996
+ tree: treeNodes,
997
+ text: lines.join('\n'),
998
+ },
999
+ };
1000
+ };
1001
+ // --- str_replace_editor ----------------------------------------------------
1002
+ function readLineRange(args) {
1003
+ const v = args.view_range;
1004
+ if (v === undefined || v === null)
1005
+ return null;
1006
+ if (!Array.isArray(v) || v.length !== 2) {
1007
+ throw new ToolError('INVALID_ARG', '"view_range" must be a [start, end] array.');
1008
+ }
1009
+ const start = Number(v[0]);
1010
+ const end = Number(v[1]);
1011
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1 || end < start) {
1012
+ throw new ToolError('INVALID_ARG', '"view_range" must be [start, end] with 1 <= start <= end.');
1013
+ }
1014
+ return [start, end];
1015
+ }
1016
+ const strReplaceEditor = async (ctx, args, signal) => {
1017
+ abortIfRequested(signal);
1018
+ const command = requireString(args, 'command').toLowerCase();
1019
+ const pathStr = requireString(args, 'path');
1020
+ const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
1021
+ const target = (0, path_utils_1.safeResolve)(ctx.root, pathStr, realRoot);
1022
+ assertNotSensitive(target);
1023
+ const assertNotRootTarget = () => {
1024
+ if (path.relative(realRoot, target) === '') {
1025
+ throw new ToolError('ROOT_PATH', 'The authorisation root itself cannot be edited.');
1026
+ }
1027
+ };
1028
+ const ensureFile = () => {
1029
+ let st;
1030
+ try {
1031
+ st = fs.statSync(target);
1032
+ }
1033
+ catch {
1034
+ throw new ToolError('NOT_FOUND', `Path does not exist: ${pathStr}`);
1035
+ }
1036
+ if (st.isDirectory()) {
1037
+ throw new ToolError('IS_DIR', `Path is a directory, not a file: ${pathStr}`);
1038
+ }
1039
+ const buf = fs.readFileSync(target);
1040
+ if (!isUtf8(buf)) {
1041
+ throw new ToolError('BINARY_FILE', 'str_replace_editor only supports UTF-8 text files.');
1042
+ }
1043
+ return buf.toString('utf8');
1044
+ };
1045
+ switch (command) {
1046
+ case 'create': {
1047
+ assertNotRootTarget();
1048
+ const fileText = requireString(args, 'file_text');
1049
+ const allowOverwrite = optionalBool(args, 'allowOverwrite', false);
1050
+ if (fs.existsSync(target) && !allowOverwrite) {
1051
+ throw new ToolError('EXISTS', `File already exists: ${pathStr} (pass allowOverwrite:true to replace it).`);
1052
+ }
1053
+ fs.mkdirSync(path.dirname(target), { recursive: true });
1054
+ fs.writeFileSync(target, fileText, 'utf8');
1055
+ return { ok: true, result: { path: rel(realRoot, target), bytesWritten: Buffer.byteLength(fileText) } };
1056
+ }
1057
+ case 'view': {
1058
+ const text = ensureFile();
1059
+ const allLines = text === '' ? [] : text.split('\n');
1060
+ const range = readLineRange(args);
1061
+ const selected = range ? allLines.slice(range[0] - 1, range[1]) : allLines;
1062
+ const joined = selected.join('\n');
1063
+ const content = joined.length > ctx.maxBytes ? joined.slice(0, ctx.maxBytes) : joined;
1064
+ return {
1065
+ ok: true,
1066
+ result: {
1067
+ path: rel(realRoot, target),
1068
+ totalLines: allLines.length,
1069
+ lineStart: range ? range[0] : 1,
1070
+ lineCount: selected.length,
1071
+ truncated: joined.length > content.length,
1072
+ content,
1073
+ },
1074
+ };
1075
+ }
1076
+ case 'str_replace': {
1077
+ assertNotRootTarget();
1078
+ const text = ensureFile();
1079
+ const oldStr = requireString(args, 'old_str');
1080
+ const newStr = typeof args.new_str === 'string' ? args.new_str : '';
1081
+ const replaceAll = optionalBool(args, 'replace_all', false);
1082
+ const count = text.split(oldStr).length - 1;
1083
+ if (count === 0) {
1084
+ throw new ToolError('NO_MATCH', 'old_str was not found in the file.');
1085
+ }
1086
+ if (!replaceAll && count !== 1) {
1087
+ throw new ToolError('MULTIPLE_MATCHES', `old_str matched ${count} times; pass replace_all:true to replace all of them.`);
1088
+ }
1089
+ const updated = replaceAll ? text.split(oldStr).join(newStr) : text.replace(oldStr, newStr);
1090
+ fs.writeFileSync(target, updated, 'utf8');
1091
+ return { ok: true, result: { path: rel(realRoot, target), replacements: count } };
1092
+ }
1093
+ case 'insert': {
1094
+ assertNotRootTarget();
1095
+ const text = ensureFile();
1096
+ const newStr = requireString(args, 'new_str');
1097
+ const insertLine = optionalInt(args, 'insert_line', 0, { min: 0 });
1098
+ const lines = text === '' ? [] : text.split('\n');
1099
+ // insert_line is 1-based; 0 or out-of-range appends.
1100
+ const pos = insertLine >= 1 && insertLine <= lines.length + 1 ? insertLine - 1 : lines.length;
1101
+ lines.splice(pos, 0, newStr);
1102
+ fs.writeFileSync(target, lines.join('\n'), 'utf8');
1103
+ return { ok: true, result: { path: rel(realRoot, target), line: pos + 1, inserted: newStr } };
1104
+ }
1105
+ case 'delete': {
1106
+ assertNotRootTarget();
1107
+ const text = ensureFile();
1108
+ const range = readLineRange(args);
1109
+ if (!range) {
1110
+ throw new ToolError('INVALID_ARG', '"delete" requires a "view_range" [start, end].');
1111
+ }
1112
+ const lines = text === '' ? [] : text.split('\n');
1113
+ const [start, end] = range;
1114
+ if (start > lines.length) {
1115
+ throw new ToolError('RANGE_OUT_OF_BOUNDS', `Line ${start} is out of range (file has ${lines.length} lines).`);
1116
+ }
1117
+ const removed = lines.slice(start - 1, Math.min(end, lines.length));
1118
+ lines.splice(start - 1, Math.min(end, lines.length) - (start - 1));
1119
+ fs.writeFileSync(target, lines.join('\n'), 'utf8');
1120
+ return { ok: true, result: { path: rel(realRoot, target), lineStart: start, lineEnd: Math.min(end, start + removed.length - 1), removed: removed.length } };
1121
+ }
1122
+ default:
1123
+ throw new ToolError('INVALID_ARG', `Unknown str_replace_editor command "${command}". Expected: create | view | str_replace | insert | delete.`);
1124
+ }
1125
+ };
1126
+ // --- mv / cp / delete ------------------------------------------------------
1127
+ /** Recursively copy a file or directory within the authorisation root. Never
1128
+ * follows symlinks so a copy cannot escape the root. */
1129
+ function copyWithinRoot(src, dst) {
1130
+ const sst = fs.statSync(src);
1131
+ if (sst.isDirectory()) {
1132
+ fs.mkdirSync(dst, { recursive: true });
1133
+ for (const name of fs.readdirSync(src)) {
1134
+ const childSrc = path.join(src, name);
1135
+ let cst;
1136
+ try {
1137
+ cst = fs.lstatSync(childSrc);
1138
+ }
1139
+ catch {
1140
+ continue;
1141
+ }
1142
+ if (cst.isSymbolicLink())
1143
+ continue;
1144
+ copyWithinRoot(childSrc, path.join(dst, name));
1145
+ }
1146
+ }
1147
+ else {
1148
+ fs.copyFileSync(src, dst);
1149
+ try {
1150
+ fs.chmodSync(dst, sst.mode & 0o777);
1151
+ }
1152
+ catch {
1153
+ /* best-effort */
1154
+ }
1155
+ }
1156
+ }
1157
+ const mv = async (ctx, args, signal) => {
1158
+ abortIfRequested(signal);
1159
+ const srcArg = requireString(args, 'source');
1160
+ const dstArg = requireString(args, 'destination');
1161
+ const overwrite = optionalBool(args, 'overwrite', false);
1162
+ const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
1163
+ const src = (0, path_utils_1.safeResolve)(ctx.root, srcArg, realRoot);
1164
+ const dst = (0, path_utils_1.safeResolve)(ctx.root, dstArg, realRoot);
1165
+ assertNotSensitive(src);
1166
+ assertNotSensitive(dst);
1167
+ if (path.relative(realRoot, dst) === '') {
1168
+ throw new ToolError('ROOT_PATH', 'The authorisation root itself cannot be a move destination.');
1169
+ }
1170
+ let ss;
1171
+ try {
1172
+ ss = fs.statSync(src);
1173
+ }
1174
+ catch {
1175
+ throw new ToolError('NOT_FOUND', `Source does not exist: ${srcArg}`);
1176
+ }
1177
+ if (fs.existsSync(dst)) {
1178
+ if (!overwrite) {
1179
+ return { ok: false, error: `Destination already exists: ${dstArg} (pass overwrite:true to replace).` };
1180
+ }
1181
+ if (!permitsDangerCommand(ctx.permissionPreset, 'danger')) {
1182
+ return { ok: false, needsApproval: true, error: `Overwrites existing path ${dstArg}; requires approval.` };
1183
+ }
1184
+ }
1185
+ try {
1186
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
1187
+ try {
1188
+ fs.renameSync(src, dst);
1189
+ }
1190
+ catch (err) {
1191
+ const code = err.code;
1192
+ if (code === 'EXDEV' || code === 'EPERM') {
1193
+ copyWithinRoot(src, dst);
1194
+ fs.rmSync(src, { recursive: true, force: true });
1195
+ }
1196
+ else {
1197
+ throw err;
1198
+ }
1199
+ }
1200
+ }
1201
+ catch (err) {
1202
+ const msg = err instanceof Error ? err.message : String(err);
1203
+ return { ok: false, error: `move failed: ${msg}` };
1204
+ }
1205
+ return { ok: true, result: { from: rel(realRoot, src), to: rel(realRoot, dst) } };
1206
+ };
1207
+ const cp = async (ctx, args, signal) => {
1208
+ abortIfRequested(signal);
1209
+ const srcArg = requireString(args, 'source');
1210
+ const dstArg = requireString(args, 'destination');
1211
+ const overwrite = optionalBool(args, 'overwrite', false);
1212
+ const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
1213
+ const src = (0, path_utils_1.safeResolve)(ctx.root, srcArg, realRoot);
1214
+ const dst = (0, path_utils_1.safeResolve)(ctx.root, dstArg, realRoot);
1215
+ assertNotSensitive(src);
1216
+ assertNotSensitive(dst);
1217
+ if (path.relative(realRoot, dst) === '') {
1218
+ throw new ToolError('ROOT_PATH', 'The authorisation root itself cannot be a copy destination.');
1219
+ }
1220
+ let ss;
1221
+ try {
1222
+ ss = fs.statSync(src);
1223
+ }
1224
+ catch {
1225
+ throw new ToolError('NOT_FOUND', `Source does not exist: ${srcArg}`);
1226
+ }
1227
+ if (fs.existsSync(dst)) {
1228
+ if (!overwrite) {
1229
+ return { ok: false, error: `Destination already exists: ${dstArg} (pass overwrite:true to replace).` };
1230
+ }
1231
+ if (!permitsDangerCommand(ctx.permissionPreset, 'danger')) {
1232
+ return { ok: false, needsApproval: true, error: `Overwrites existing path ${dstArg}; requires approval.` };
1233
+ }
1234
+ }
1235
+ try {
1236
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
1237
+ if (path.relative(src, dst) === '') {
1238
+ throw new ToolError('INVALID_ARG', 'Source and destination cannot be the same.');
1239
+ }
1240
+ copyWithinRoot(src, dst);
1241
+ }
1242
+ catch (err) {
1243
+ const msg = err instanceof Error ? err.message : String(err);
1244
+ return { ok: false, error: `copy failed: ${msg}` };
1245
+ }
1246
+ return { ok: true, result: { from: rel(realRoot, src), to: rel(realRoot, dst) } };
1247
+ };
1248
+ const deleteFile = async (ctx, args, signal) => {
1249
+ abortIfRequested(signal);
1250
+ const pathStr = requireString(args, 'path');
1251
+ const recursive = optionalBool(args, 'recursive', false);
1252
+ const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
1253
+ const target = (0, path_utils_1.safeResolve)(ctx.root, pathStr, realRoot);
1254
+ assertNotSensitive(target);
1255
+ if (path.relative(realRoot, target) === '') {
1256
+ throw new ToolError('ROOT_PATH', 'Refusing to delete the authorisation root.');
1257
+ }
1258
+ let st;
1259
+ try {
1260
+ st = fs.statSync(target);
1261
+ }
1262
+ catch {
1263
+ throw new ToolError('NOT_FOUND', `Path does not exist: ${pathStr}`);
1264
+ }
1265
+ // Delete is destructive: always require human confirmation unless the most
1266
+ // permissive preset is active.
1267
+ if (!permitsDangerCommand(ctx.permissionPreset, 'danger')) {
1268
+ return { ok: false, needsApproval: true, error: `Deleting ${pathStr} requires approval.` };
1269
+ }
1270
+ try {
1271
+ if (st.isDirectory() && !recursive) {
1272
+ if (fs.readdirSync(target).length !== 0) {
1273
+ throw new ToolError('DIR_NOT_EMPTY', `Directory is not empty: ${pathStr} (pass recursive:true).`);
1274
+ }
1275
+ }
1276
+ fs.rmSync(target, { recursive, force: false });
1277
+ }
1278
+ catch (err) {
1279
+ if (err instanceof ToolError)
1280
+ throw err;
1281
+ const msg = err instanceof Error ? err.message : String(err);
1282
+ return { ok: false, error: `delete failed: ${msg}` };
1283
+ }
1284
+ return { ok: true, result: { path: rel(realRoot, target) } };
1285
+ };
1286
+ // --- json pretty-print -----------------------------------------------------
1287
+ function sortKeysDeep(value) {
1288
+ if (Array.isArray(value))
1289
+ return value.map(sortKeysDeep);
1290
+ if (value && typeof value === 'object') {
1291
+ const out = {};
1292
+ for (const k of Object.keys(value).sort()) {
1293
+ out[k] = sortKeysDeep(value[k]);
1294
+ }
1295
+ return out;
1296
+ }
1297
+ return value;
1298
+ }
1299
+ const jsonTool = async (ctx, args, signal) => {
1300
+ abortIfRequested(signal);
1301
+ const pathStr = optionalString(args, 'path');
1302
+ const indent = optionalInt(args, 'indent', 2, { min: 0, max: 16 });
1303
+ const sortKeys = optionalBool(args, 'sortKeys', false);
1304
+ let raw;
1305
+ let outPath;
1306
+ if (pathStr) {
1307
+ const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
1308
+ const target = (0, path_utils_1.safeResolve)(ctx.root, pathStr, realRoot);
1309
+ assertNotSensitive(target);
1310
+ let st;
1311
+ try {
1312
+ st = fs.statSync(target);
1313
+ }
1314
+ catch {
1315
+ throw new ToolError('NOT_FOUND', `Path does not exist: ${pathStr}`);
1316
+ }
1317
+ if (st.size > MAX_WRITE_BYTES) {
1318
+ throw new ToolError('TOO_LARGE', `File exceeds ${MAX_WRITE_BYTES} bytes.`);
1319
+ }
1320
+ const buf = fs.readFileSync(target);
1321
+ if (!isUtf8(buf)) {
1322
+ throw new ToolError('BINARY_FILE', 'json only supports UTF-8 text files.');
1323
+ }
1324
+ raw = buf.toString('utf8');
1325
+ outPath = rel(realRoot, target);
1326
+ }
1327
+ else {
1328
+ const v = args.value;
1329
+ if (typeof v === 'string') {
1330
+ raw = v;
1331
+ }
1332
+ else if (v !== undefined && v !== null) {
1333
+ raw = JSON.stringify(v);
1334
+ }
1335
+ else {
1336
+ throw new ToolError('INVALID_ARG', '"json" requires either "value" (string) or "path".');
1337
+ }
1338
+ }
1339
+ let parsed;
1340
+ try {
1341
+ parsed = JSON.parse(raw);
1342
+ }
1343
+ catch (err) {
1344
+ const msg = err instanceof Error ? err.message : String(err);
1345
+ throw new ToolError('INVALID_JSON', `Invalid JSON: ${msg}`);
1346
+ }
1347
+ const pretty = JSON.stringify(sortKeys ? sortKeysDeep(parsed) : parsed, null, indent);
1348
+ return { ok: true, result: { json: pretty.length > ctx.maxBytes ? `${pretty.slice(0, ctx.maxBytes)}\n…(truncated)` : pretty, ...(outPath ? { path: outPath } : {}) } };
1349
+ };
605
1350
  // --- skills ----------------------------------------------------------------
606
1351
  /** Load a skill (`.dsh/skills/<name>.md` under the workspace root). No-op when
607
1352
  * no skills directory or no matching skill exists. */
@@ -623,10 +1368,18 @@ const TOOLS = {
623
1368
  read_file: readFile,
624
1369
  write_file: writeFile,
625
1370
  edit_file: editFile,
1371
+ str_replace_editor: strReplaceEditor,
626
1372
  run_command: runCommandTool,
627
1373
  web_search: webSearch,
628
1374
  git,
629
1375
  generate,
1376
+ search,
1377
+ glob,
1378
+ tree,
1379
+ mv,
1380
+ cp,
1381
+ delete_file: deleteFile,
1382
+ json: jsonTool,
630
1383
  skill: skillTool,
631
1384
  };
632
1385
  /** Build a tool registry bound to a single execution context. */