@theokit/sdk-tools 0.14.0 → 0.15.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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 324835f: M15 — complete the surface-agnostic tool injection. `search_text`, `glob_files`, and `edit_file` now
8
+ accept an optional `filesystem` (`FilesystemProvider`), joining `shell_exec`/`git_diff` (`sandbox`) and
9
+ `interactive_shell`/`write_stdin` (`interactive`). When a backend is injected the recursive walk / read
10
+ / backup / write go through it in project-relative path space (so the tool runs unchanged on a local
11
+ disk, a cluster container, or a Tauri desktop); when omitted the local `fs` path is byte-identical to
12
+ before. Backward compatibility is proven by conformance tests that run each tool through the real
13
+ `LocalFilesystem`/`LocalSandbox` backends and assert identical output to the local path. Additive — no
14
+ breaking change.
15
+
3
16
  ## 0.11.1
4
17
 
5
18
  ### Patch Changes
package/dist/index.cjs CHANGED
@@ -7,11 +7,12 @@ var zod = require('zod');
7
7
  var fs = require('fs');
8
8
  var pathSafety = require('@theokit/sdk/path-safety');
9
9
  var persistence = require('@theokit/sdk/persistence');
10
+ var filesystem = require('@theokit/sdk/filesystem');
10
11
  var child_process = require('child_process');
12
+ var sandbox = require('@theokit/sdk/sandbox');
11
13
  var interactive = require('@theokit/sdk/interactive');
12
14
  var promises$1 = require('dns/promises');
13
15
  var net = require('net');
14
- var filesystem = require('@theokit/sdk/filesystem');
15
16
 
16
17
  // src/apply-patch.ts
17
18
  var PathTraversalError = class extends sdk.ConfigurationError {
@@ -350,8 +351,79 @@ ${find}`);
350
351
  }
351
352
 
352
353
  // src/edit-file.ts
354
+ function computeEdit(content, old_string, new_string) {
355
+ const exactIdx = content.indexOf(old_string);
356
+ if (exactIdx !== -1) {
357
+ return {
358
+ ok: true,
359
+ result: content.slice(0, exactIdx) + new_string + content.slice(exactIdx + old_string.length)
360
+ };
361
+ }
362
+ const normalizedContent = normalizeWhitespace(content);
363
+ const normalizedOld = normalizeWhitespace(old_string);
364
+ const normalizedIdx = normalizedContent.indexOf(normalizedOld);
365
+ if (normalizedIdx !== -1) {
366
+ const span = findOriginalSpan(content, normalizedContent, normalizedIdx, normalizedOld.length);
367
+ return {
368
+ ok: true,
369
+ result: content.slice(0, span.start) + new_string + content.slice(span.end)
370
+ };
371
+ }
372
+ try {
373
+ return { ok: true, result: replaceUnique(content, old_string, new_string) };
374
+ } catch (err) {
375
+ if (err instanceof ContextMatchError) return { ok: false, error: "no_match" };
376
+ throw err;
377
+ }
378
+ }
379
+ async function editViaBackend(filesystem$1, ctx, path, old_string, new_string) {
380
+ const backend = await filesystem.resolveFilesystem(filesystem$1, ctx ?? {});
381
+ let content;
382
+ try {
383
+ content = await backend.readFile(path);
384
+ } catch {
385
+ return JSON.stringify({ ok: false, error: "not_found", path });
386
+ }
387
+ const outcome = computeEdit(content, old_string, new_string);
388
+ if (!outcome.ok) return JSON.stringify({ ok: false, error: "no_match", path });
389
+ await backend.writeFile(`${path}.bak`, content);
390
+ await backend.writeFile(path, outcome.result);
391
+ return JSON.stringify({ ok: true, replacements: 1 });
392
+ }
393
+ async function editViaLocal(projectRoot, path, old_string, new_string) {
394
+ const absolutePath = safePathJoin(projectRoot, path);
395
+ let content;
396
+ try {
397
+ content = await promises.readFile(absolutePath, "utf-8");
398
+ } catch (err) {
399
+ const e = err;
400
+ if (e.code === "ENOENT") {
401
+ return JSON.stringify({ ok: false, error: "not_found", path });
402
+ }
403
+ throw err;
404
+ }
405
+ const outcome = computeEdit(content, old_string, new_string);
406
+ if (!outcome.ok) return JSON.stringify({ ok: false, error: "no_match", path });
407
+ await promises.copyFile(absolutePath, `${absolutePath}.bak`);
408
+ await promises.writeFile(absolutePath, outcome.result, "utf-8");
409
+ return JSON.stringify({ ok: true, replacements: 1 });
410
+ }
411
+ function editScopeError(path, projectRoot) {
412
+ if (isForbiddenPath(path)) {
413
+ return JSON.stringify({ ok: false, error: "forbidden_path", path });
414
+ }
415
+ try {
416
+ assertNoSymlinkEscape(safePathJoin(projectRoot, path), projectRoot);
417
+ return null;
418
+ } catch (err) {
419
+ if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
420
+ return JSON.stringify({ ok: false, error: "path_traversal", path });
421
+ }
422
+ throw err;
423
+ }
424
+ }
353
425
  function createEditFileTool(opts) {
354
- const { projectRoot } = opts;
426
+ const { projectRoot, filesystem } = opts;
355
427
  return sdk.Tool.create({
356
428
  name: "edit_file",
357
429
  description: "Make an exact string replacement in a project-relative file. Replaces the FIRST occurrence of old_string with new_string (a whitespace-normalized fallback is attempted if the exact match fails) and writes a .bak backup first. Read the file first so old_string matches the on-disk text exactly; include enough surrounding context to make it unique \u2014 only the first match is replaced, so a too-short old_string can edit the wrong location. old_string must be non-empty and differ from new_string; to change every occurrence, call edit_file repeatedly. Returns { ok, replacements } or { ok: false, error }.",
@@ -360,67 +432,13 @@ function createEditFileTool(opts) {
360
432
  old_string: zod.z.string().min(1).describe("String to find in the file."),
361
433
  new_string: zod.z.string().describe("Replacement string.")
362
434
  }),
363
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: unified diff parsing is inherently complex
364
- handler: async ({ path, old_string, new_string }) => {
435
+ handler: async ({ path, old_string, new_string }, ctx) => {
365
436
  if (old_string === new_string) {
366
437
  return JSON.stringify({ ok: false, error: "no_change", path });
367
438
  }
368
- if (isForbiddenPath(path)) {
369
- return JSON.stringify({ ok: false, error: "forbidden_path", path });
370
- }
371
- let absolutePath;
372
- try {
373
- absolutePath = safePathJoin(projectRoot, path);
374
- assertNoSymlinkEscape(absolutePath, projectRoot);
375
- } catch (err) {
376
- if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
377
- return JSON.stringify({ ok: false, error: "path_traversal", path });
378
- }
379
- throw err;
380
- }
381
- let content;
382
- try {
383
- content = await promises.readFile(absolutePath, "utf-8");
384
- } catch (err) {
385
- const e = err;
386
- if (e.code === "ENOENT") {
387
- return JSON.stringify({ ok: false, error: "not_found", path });
388
- }
389
- throw err;
390
- }
391
- const exactIdx = content.indexOf(old_string);
392
- if (exactIdx !== -1) {
393
- await promises.copyFile(absolutePath, `${absolutePath}.bak`);
394
- const result = content.slice(0, exactIdx) + new_string + content.slice(exactIdx + old_string.length);
395
- await promises.writeFile(absolutePath, result, "utf-8");
396
- return JSON.stringify({ ok: true, replacements: 1 });
397
- }
398
- const normalizedContent = normalizeWhitespace(content);
399
- const normalizedOld = normalizeWhitespace(old_string);
400
- const normalizedIdx = normalizedContent.indexOf(normalizedOld);
401
- if (normalizedIdx !== -1) {
402
- const span = findOriginalSpan(
403
- content,
404
- normalizedContent,
405
- normalizedIdx,
406
- normalizedOld.length
407
- );
408
- await promises.copyFile(absolutePath, `${absolutePath}.bak`);
409
- const result = content.slice(0, span.start) + new_string + content.slice(span.end);
410
- await promises.writeFile(absolutePath, result, "utf-8");
411
- return JSON.stringify({ ok: true, replacements: 1 });
412
- }
413
- try {
414
- const result = replaceUnique(content, old_string, new_string);
415
- await promises.copyFile(absolutePath, `${absolutePath}.bak`);
416
- await promises.writeFile(absolutePath, result, "utf-8");
417
- return JSON.stringify({ ok: true, replacements: 1 });
418
- } catch (err) {
419
- if (err instanceof ContextMatchError) {
420
- return JSON.stringify({ ok: false, error: "no_match", path });
421
- }
422
- throw err;
423
- }
439
+ const scopeErr = editScopeError(path, projectRoot);
440
+ if (scopeErr !== null) return scopeErr;
441
+ return filesystem !== void 0 ? editViaBackend(filesystem, ctx, path, old_string, new_string) : editViaLocal(projectRoot, path, old_string, new_string);
424
442
  }
425
443
  });
426
444
  }
@@ -533,11 +551,27 @@ function attachChildSettlers(child, gate, onClose, onError, resolve2) {
533
551
  // src/git-diff.ts
534
552
  var DEFAULT_TIMEOUT_MS = 3e4;
535
553
  var DEFAULT_MAX_STDOUT_BYTES = 5 * 1024 * 1024;
554
+ function shq(arg) {
555
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
556
+ }
557
+ async function diffViaSandbox(sandbox$1, ctx, cached, path, projectRoot, timeoutMs) {
558
+ const scopeCheck = checkPathScope(path, projectRoot);
559
+ if (scopeCheck !== null) return scopeCheck;
560
+ const command = ["git", ...buildDiffArgs(cached, path)].map(shq).join(" ");
561
+ const backend = await sandbox.resolveSandbox(sandbox$1, ctx ?? {});
562
+ const r = await backend.execute(command, { timeoutMs });
563
+ if (r.timedOut) return JSON.stringify({ ok: false, error: "timeout", timeoutMs });
564
+ if (r.exitCode !== 0) {
565
+ return /not a git repository/i.test(r.stderr) ? JSON.stringify({ ok: false, error: "not_a_repo" }) : JSON.stringify({ ok: false, error: "git_failed", stderr: r.stderr });
566
+ }
567
+ return JSON.stringify({ ok: true, diff: r.stdout, truncated: false });
568
+ }
536
569
  function createGitDiffTool(opts) {
537
570
  const {
538
571
  projectRoot,
539
572
  timeoutMs = DEFAULT_TIMEOUT_MS,
540
- maxStdoutBytes = DEFAULT_MAX_STDOUT_BYTES
573
+ maxStdoutBytes = DEFAULT_MAX_STDOUT_BYTES,
574
+ sandbox
541
575
  } = opts;
542
576
  return sdk.Tool.create({
543
577
  name: "git_diff",
@@ -546,7 +580,10 @@ function createGitDiffTool(opts) {
546
580
  path: zod.z.string().optional().describe("Optional project-relative file or dir scope."),
547
581
  cached: zod.z.boolean().optional().describe("If true, show staged changes (git diff --cached). Default false.")
548
582
  }),
549
- handler: async ({ path: path$1, cached }) => {
583
+ handler: async ({ path: path$1, cached }, ctx) => {
584
+ if (sandbox !== void 0) {
585
+ return diffViaSandbox(sandbox, ctx, cached, path$1, projectRoot, timeoutMs);
586
+ }
550
587
  if (!fs.existsSync(path.join(projectRoot, ".git"))) {
551
588
  return JSON.stringify({ ok: false, error: "not_a_repo" });
552
589
  }
@@ -620,7 +657,7 @@ function runGitProcess(cwd, args, timeoutMs, maxStdoutBytes) {
620
657
  }
621
658
  var DEFAULT_EXCLUDES = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", ".theo"]);
622
659
  function createGlobTool(opts) {
623
- const { projectRoot } = opts;
660
+ const { projectRoot, filesystem: filesystem$1 } = opts;
624
661
  return sdk.Tool.create({
625
662
  name: "glob_files",
626
663
  description: "Find files by glob pattern across the project \u2014 fast at any repo size. Use glob_files when you know the filename SHAPE; use search_text when you know the file CONTENT; use read_file when you know the exact path. The pattern supports * and ** wildcards (e.g. '**/*.ts', 'src/**/*.json'); node_modules/.git/dist/.theo are excluded and results are relative paths. Returns { ok, files } or { ok: false, error }.",
@@ -628,20 +665,18 @@ function createGlobTool(opts) {
628
665
  pattern: zod.z.string().min(1).describe("Glob pattern (e.g. '**/*.ts', 'src/**/*.json')."),
629
666
  cwd: zod.z.string().optional().describe("Project-relative subdirectory to search from.")
630
667
  }),
631
- handler: async ({ pattern, cwd }) => {
632
- let searchRoot = projectRoot;
633
- if (cwd) {
634
- try {
635
- searchRoot = safePathJoin(projectRoot, cwd);
636
- assertNoSymlinkEscape(searchRoot, projectRoot);
637
- } catch (err) {
638
- if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
639
- return JSON.stringify({ ok: false, error: "path_traversal", path: cwd });
640
- }
641
- throw err;
642
- }
643
- }
668
+ handler: async ({ pattern, cwd }, ctx) => {
669
+ const scopeErr = globScopeError(cwd, projectRoot);
670
+ if (scopeErr !== null) return scopeErr;
644
671
  const regex = globToRegex(pattern);
672
+ if (filesystem$1 !== void 0) {
673
+ const backend = await filesystem.resolveFilesystem(filesystem$1, ctx ?? {});
674
+ const searchRel = cwd ?? "";
675
+ const found = [];
676
+ await walkDirBackend(backend, searchRel, searchRel, regex, found);
677
+ return JSON.stringify({ ok: true, files: found.sort(), count: found.length });
678
+ }
679
+ const searchRoot = cwd ? safePathJoin(projectRoot, cwd) : projectRoot;
645
680
  const files = [];
646
681
  await walkDir(searchRoot, searchRoot, regex, files);
647
682
  const relativePaths = files.map((f) => path.relative(projectRoot, f)).sort();
@@ -649,6 +684,18 @@ function createGlobTool(opts) {
649
684
  }
650
685
  });
651
686
  }
687
+ function globScopeError(cwd, projectRoot) {
688
+ if (!cwd) return null;
689
+ try {
690
+ assertNoSymlinkEscape(safePathJoin(projectRoot, cwd), projectRoot);
691
+ return null;
692
+ } catch (err) {
693
+ if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
694
+ return JSON.stringify({ ok: false, error: "path_traversal", path: cwd });
695
+ }
696
+ throw err;
697
+ }
698
+ }
652
699
  async function walkDir(base, dir, pattern, results) {
653
700
  let entries;
654
701
  try {
@@ -667,6 +714,33 @@ async function walkDir(base, dir, pattern, results) {
667
714
  }
668
715
  }
669
716
  }
717
+ async function walkDirBackend(backend, base, dir, pattern, results) {
718
+ let names;
719
+ try {
720
+ names = await backend.list(dir);
721
+ } catch {
722
+ return;
723
+ }
724
+ for (const name of names) {
725
+ await walkBackendEntry(backend, base, dir, name, pattern, results);
726
+ }
727
+ }
728
+ async function walkBackendEntry(backend, base, dir, name, pattern, results) {
729
+ if (DEFAULT_EXCLUDES.has(name)) return;
730
+ const fullRel = dir === "" ? name : `${dir}/${name}`;
731
+ const relPath = base === "" ? fullRel : path.relative(base, fullRel);
732
+ let st;
733
+ try {
734
+ st = await backend.stat(fullRel);
735
+ } catch {
736
+ return;
737
+ }
738
+ if (st.isDirectory) {
739
+ await walkDirBackend(backend, base, fullRel, pattern, results);
740
+ } else if (st.isFile && pattern.test(relPath)) {
741
+ results.push(fullRel);
742
+ }
743
+ }
670
744
  function globToRegex(pattern) {
671
745
  let regexStr = "";
672
746
  let i = 0;
@@ -1738,7 +1812,8 @@ function createSearchTextTool(opts) {
1738
1812
  const {
1739
1813
  projectRoot,
1740
1814
  maxMatches = DEFAULT_MAX_MATCHES,
1741
- maxFileSize = DEFAULT_MAX_FILE_SIZE
1815
+ maxFileSize = DEFAULT_MAX_FILE_SIZE,
1816
+ filesystem: filesystem$1
1742
1817
  } = opts;
1743
1818
  return sdk.Tool.create({
1744
1819
  name: "search_text",
@@ -1747,9 +1822,7 @@ function createSearchTextTool(opts) {
1747
1822
  query: zod.z.string().min(1).describe("Literal text to search for. Case-sensitive."),
1748
1823
  path: zod.z.string().optional().describe("Optional project-relative directory to scope the search.")
1749
1824
  }),
1750
- handler: async ({ query, path }) => {
1751
- const scope = resolveSearchScope(path, projectRoot);
1752
- if ("error" in scope) return scope.error;
1825
+ handler: async ({ query, path }, ctx) => {
1753
1826
  const state = {
1754
1827
  matches: [],
1755
1828
  totalMatches: 0,
@@ -1759,6 +1832,20 @@ function createSearchTextTool(opts) {
1759
1832
  maxFileSize,
1760
1833
  projectRoot
1761
1834
  };
1835
+ if (filesystem$1 !== void 0) {
1836
+ const scopeRel = resolveScopeRel(path, projectRoot);
1837
+ if ("error" in scopeRel) return scopeRel.error;
1838
+ const backend = await filesystem.resolveFilesystem(filesystem$1, ctx ?? {});
1839
+ await walkBackend(backend, scopeRel.rel, state);
1840
+ return JSON.stringify({
1841
+ ok: true,
1842
+ matches: state.matches,
1843
+ truncated: state.truncated,
1844
+ totalMatches: state.totalMatches
1845
+ });
1846
+ }
1847
+ const scope = resolveSearchScope(path, projectRoot);
1848
+ if ("error" in scope) return scope.error;
1762
1849
  await walk(scope.scopeAbs, state);
1763
1850
  return JSON.stringify({
1764
1851
  ok: true,
@@ -1846,11 +1933,78 @@ async function scanFile(absPath, relPath, state) {
1846
1933
  if (!recordMatch(state, relPath, i + 1, line)) return;
1847
1934
  }
1848
1935
  }
1936
+ function resolveScopeRel(path, projectRoot) {
1937
+ const scopeRel = path === void 0 || path === "" || path === "." ? "" : path;
1938
+ if (scopeRel === "") return { rel: "" };
1939
+ try {
1940
+ assertNoSymlinkEscape(safePathJoin(projectRoot, scopeRel), projectRoot);
1941
+ return { rel: scopeRel };
1942
+ } catch (err) {
1943
+ if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
1944
+ return { error: JSON.stringify({ ok: false, error: "path_traversal", path }) };
1945
+ }
1946
+ throw err;
1947
+ }
1948
+ }
1949
+ async function walkBackend(backend, dirRel, state) {
1950
+ if (state.truncated) return;
1951
+ let names;
1952
+ try {
1953
+ names = await backend.list(dirRel);
1954
+ } catch {
1955
+ return;
1956
+ }
1957
+ for (const name of names) {
1958
+ if (state.truncated) return;
1959
+ await handleBackendEntry(backend, dirRel, name, state);
1960
+ }
1961
+ }
1962
+ async function handleBackendEntry(backend, dirRel, name, state) {
1963
+ const entryRel = dirRel === "" ? name : `${dirRel}/${name}`;
1964
+ if (isForbiddenPath(entryRel)) return;
1965
+ let st;
1966
+ try {
1967
+ st = await backend.stat(entryRel);
1968
+ } catch {
1969
+ return;
1970
+ }
1971
+ if (st.isDirectory) {
1972
+ await walkBackend(backend, entryRel, state);
1973
+ } else if (st.isFile) {
1974
+ await scanFileBackend(backend, entryRel, st.size, state);
1975
+ }
1976
+ }
1977
+ async function scanFileBackend(backend, relPath, size, state) {
1978
+ if (size > state.maxFileSize) return;
1979
+ let content;
1980
+ try {
1981
+ content = await backend.readFile(relPath);
1982
+ } catch {
1983
+ return;
1984
+ }
1985
+ if (content.slice(0, BINARY_PROBE_BYTES2).includes("\0")) return;
1986
+ const lines = content.split("\n");
1987
+ for (let i = 0; i < lines.length; i += 1) {
1988
+ const line = lines[i];
1989
+ if (!line.includes(state.query)) continue;
1990
+ if (!recordMatch(state, relPath, i + 1, line)) return;
1991
+ }
1992
+ }
1849
1993
  var DEFAULT_TIMEOUT_MS3 = 3e4;
1850
1994
  var MAX_TIMEOUT_MS = 3e5;
1851
1995
  var MAX_OUTPUT_BYTES = 5 * 1024 * 1024;
1996
+ async function execViaSandbox(sandbox$1, ctx, command, timeoutMs) {
1997
+ const backend = await sandbox.resolveSandbox(sandbox$1, ctx ?? {});
1998
+ const r = await backend.execute(command, { timeoutMs });
1999
+ return r.timedOut ? JSON.stringify({ ok: false, error: "timeout", timeout_ms: timeoutMs }) : JSON.stringify({ ok: true, stdout: r.stdout, stderr: r.stderr, exit_code: r.exitCode });
2000
+ }
1852
2001
  function createShellTool(opts) {
1853
- const { projectRoot, defaultTimeoutMs = DEFAULT_TIMEOUT_MS3, allowCatastrophic = false } = opts;
2002
+ const {
2003
+ projectRoot,
2004
+ defaultTimeoutMs = DEFAULT_TIMEOUT_MS3,
2005
+ allowCatastrophic = false,
2006
+ sandbox
2007
+ } = opts;
1854
2008
  return sdk.Tool.create({
1855
2009
  name: "shell_exec",
1856
2010
  description: "Execute a shell command in the project directory. Use this for terminal operations \u2014 running tests, git, package managers, build tools. Do NOT use it for file operations (reading, writing, editing, finding files): prefer the specialized read_file/write_file/edit_file/glob_files/search_text tools, which are path-checked and safer. Only commit, push, or change git state when the user explicitly asks. timeout_ms defaults to 30000 (max 300000); stdout/stderr are capped (~5 MB). Returns { ok, stdout, stderr, exit_code } or { ok: false, error }.",
@@ -1858,7 +2012,7 @@ function createShellTool(opts) {
1858
2012
  command: zod.z.string().min(1).describe("Shell command to execute."),
1859
2013
  timeout_ms: zod.z.number().int().positive().optional().describe("Timeout in milliseconds (default 30000, max 300000).")
1860
2014
  }),
1861
- handler: async ({ command, timeout_ms }) => {
2015
+ handler: async ({ command, timeout_ms }, ctx) => {
1862
2016
  if (!allowCatastrophic) {
1863
2017
  const reason = catastrophicShellReason(command);
1864
2018
  if (reason) {
@@ -1867,8 +2021,8 @@ function createShellTool(opts) {
1867
2021
  }
1868
2022
  }
1869
2023
  const timeoutMs = Math.min(timeout_ms ?? defaultTimeoutMs, MAX_TIMEOUT_MS);
1870
- const result = await runShell(projectRoot, command, timeoutMs);
1871
- return result;
2024
+ if (sandbox !== void 0) return execViaSandbox(sandbox, ctx, command, timeoutMs);
2025
+ return runShell(projectRoot, command, timeoutMs);
1872
2026
  }
1873
2027
  });
1874
2028
  }