@agi-cli/sdk 0.1.58 → 0.1.60

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agi-cli/sdk",
3
- "version": "0.1.58",
3
+ "version": "0.1.60",
4
4
  "description": "AI agent SDK for building intelligent assistants - tree-shakable and comprehensive",
5
5
  "author": "ntishxyz",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  import { tool, type Tool } from 'ai';
2
2
  import { z } from 'zod';
3
- import { exec } from 'node:child_process';
3
+ import { spawn } from 'node:child_process';
4
4
  import DESCRIPTION from './bash.txt' with { type: 'text' };
5
5
 
6
6
  function normalizePath(p: string) {
@@ -31,7 +31,7 @@ export function buildBashTool(projectRoot: string): {
31
31
  description: DESCRIPTION,
32
32
  inputSchema: z
33
33
  .object({
34
- cmd: z.string().describe('Shell command to run (bash -lc <cmd>)'),
34
+ cmd: z.string().describe('Shell command to run (bash -c <cmd>)'),
35
35
  cwd: z
36
36
  .string()
37
37
  .default('.')
@@ -41,27 +41,45 @@ export function buildBashTool(projectRoot: string): {
41
41
  .optional()
42
42
  .default(false)
43
43
  .describe('If true, do not throw on non-zero exit'),
44
+ timeout: z
45
+ .number()
46
+ .optional()
47
+ .default(300000)
48
+ .describe('Timeout in milliseconds (default: 300000 = 5 minutes)'),
44
49
  })
45
50
  .strict(),
46
51
  async execute({
47
52
  cmd,
48
53
  cwd,
49
54
  allowNonZeroExit,
55
+ timeout = 300000,
50
56
  }: {
51
57
  cmd: string;
52
58
  cwd?: string;
53
59
  allowNonZeroExit?: boolean;
60
+ timeout?: number;
54
61
  }) {
55
62
  const absCwd = resolveSafePath(projectRoot, cwd || '.');
56
63
 
57
64
  return new Promise((resolve, reject) => {
58
- const proc = exec(`bash -lc '${cmd.replace(/'/g, "'\\''")}'`, {
65
+ // Use spawn with shell: true for cross-platform compatibility
66
+ const proc = spawn(cmd, {
59
67
  cwd: absCwd,
60
- maxBuffer: 10 * 1024 * 1024,
68
+ shell: true,
69
+ stdio: ['ignore', 'pipe', 'pipe'],
61
70
  });
62
71
 
63
72
  let stdout = '';
64
73
  let stderr = '';
74
+ let didTimeout = false;
75
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
76
+
77
+ if (timeout > 0) {
78
+ timeoutId = setTimeout(() => {
79
+ didTimeout = true;
80
+ proc.kill();
81
+ }, timeout);
82
+ }
65
83
 
66
84
  proc.stdout?.on('data', (chunk) => {
67
85
  stdout += chunk.toString();
@@ -72,8 +90,16 @@ export function buildBashTool(projectRoot: string): {
72
90
  });
73
91
 
74
92
  proc.on('close', (exitCode) => {
75
- if (exitCode !== 0 && !allowNonZeroExit) {
76
- const msg = (stderr || stdout || `Command failed: ${cmd}`).trim();
93
+ if (timeoutId) clearTimeout(timeoutId);
94
+
95
+ if (didTimeout) {
96
+ reject(new Error(`Command timed out after ${timeout}ms: ${cmd}`));
97
+ } else if (exitCode !== 0 && !allowNonZeroExit) {
98
+ const errorMsg =
99
+ stderr.trim() ||
100
+ stdout.trim() ||
101
+ `Command failed with exit code ${exitCode}`;
102
+ const msg = `${errorMsg}\n\nCommand: ${cmd}\nExit code: ${exitCode}`;
77
103
  reject(new Error(msg));
78
104
  } else {
79
105
  resolve({ exitCode: exitCode ?? 0, stdout, stderr });
@@ -81,7 +107,12 @@ export function buildBashTool(projectRoot: string): {
81
107
  });
82
108
 
83
109
  proc.on('error', (err) => {
84
- reject(err);
110
+ if (timeoutId) clearTimeout(timeoutId);
111
+ reject(
112
+ new Error(
113
+ `Command execution failed: ${err.message}\n\nCommand: ${cmd}`,
114
+ ),
115
+ );
85
116
  });
86
117
  });
87
118
  },
@@ -0,0 +1,116 @@
1
+ import { tool, type Tool } from 'ai';
2
+ import { z } from 'zod';
3
+ import fg from 'fast-glob';
4
+ import { join } from 'node:path';
5
+ import { stat } from 'node:fs/promises';
6
+ import DESCRIPTION from './glob.txt' with { type: 'text' };
7
+ import { defaultIgnoreGlobs } from './ignore.ts';
8
+
9
+ function expandTilde(p: string) {
10
+ const home = process.env.HOME || process.env.USERPROFILE || '';
11
+ if (!home) return p;
12
+ if (p === '~') return home;
13
+ if (p.startsWith('~/')) return `${home}/${p.slice(2)}`;
14
+ return p;
15
+ }
16
+
17
+ export function buildGlobTool(projectRoot: string): {
18
+ name: string;
19
+ tool: Tool;
20
+ } {
21
+ const globTool = tool({
22
+ description: DESCRIPTION,
23
+ inputSchema: z.object({
24
+ pattern: z
25
+ .string()
26
+ .min(1)
27
+ .describe(
28
+ 'Glob pattern to match files (e.g., "*.ts", "**/*.tsx", "src/**/*.{js,ts}")',
29
+ ),
30
+ path: z
31
+ .string()
32
+ .optional()
33
+ .describe('Directory to search in (default: project root)'),
34
+ ignore: z
35
+ .array(z.string())
36
+ .optional()
37
+ .describe('Additional glob patterns to exclude'),
38
+ limit: z
39
+ .number()
40
+ .int()
41
+ .min(1)
42
+ .max(1000)
43
+ .optional()
44
+ .default(100)
45
+ .describe('Maximum number of files to return'),
46
+ }),
47
+ async execute({
48
+ pattern,
49
+ path = '.',
50
+ ignore,
51
+ limit = 100,
52
+ }: {
53
+ pattern: string;
54
+ path?: string;
55
+ ignore?: string[];
56
+ limit?: number;
57
+ }) {
58
+ const p = expandTilde(String(path || '.')).trim();
59
+ const isAbs = p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p);
60
+ const searchPath = p ? (isAbs ? p : join(projectRoot, p)) : projectRoot;
61
+
62
+ // Build ignore patterns
63
+ const ignorePatterns = defaultIgnoreGlobs(ignore);
64
+
65
+ try {
66
+ // Use fast-glob to find matching files
67
+ const files = await fg(pattern, {
68
+ cwd: searchPath,
69
+ ignore: ignorePatterns,
70
+ onlyFiles: true,
71
+ absolute: false,
72
+ dot: false,
73
+ });
74
+
75
+ // Get file stats for sorting by modification time
76
+ const filesWithStats = await Promise.all(
77
+ files.map(async (file) => {
78
+ const fullPath = join(searchPath, file);
79
+ try {
80
+ const stats = await stat(fullPath);
81
+ return {
82
+ file,
83
+ mtime: stats.mtime.getTime(),
84
+ };
85
+ } catch {
86
+ return {
87
+ file,
88
+ mtime: 0,
89
+ };
90
+ }
91
+ }),
92
+ );
93
+
94
+ // Sort by modification time (most recent first) and limit
95
+ filesWithStats.sort((a, b) => b.mtime - a.mtime);
96
+ const limitedFiles = filesWithStats.slice(0, limit).map((f) => f.file);
97
+
98
+ return {
99
+ count: limitedFiles.length,
100
+ total: files.length,
101
+ files: limitedFiles,
102
+ truncated: files.length > limit,
103
+ };
104
+ } catch (error: unknown) {
105
+ const err = error as { message?: string };
106
+ return {
107
+ count: 0,
108
+ total: 0,
109
+ files: [],
110
+ error: `Glob search failed: ${err.message || String(error)}`,
111
+ };
112
+ }
113
+ },
114
+ });
115
+ return { name: 'glob', tool: globTool };
116
+ }
@@ -0,0 +1,10 @@
1
+ - Find files matching glob patterns (e.g., "*.ts", "**/*.tsx", "src/**/*.{js,ts}")
2
+ - Returns a list of matching file paths relative to the search directory
3
+ - Supports standard glob syntax: * (any chars), ** (any dirs), {a,b} (alternatives), [abc] (character sets)
4
+ - Automatically excludes common build/cache folders (node_modules, dist, .git, etc.)
5
+ - Results sorted by modification time (most recent first)
6
+
7
+ Usage tips:
8
+ - Use this tool to find files by name or extension patterns
9
+ - Use Grep or Ripgrep tools to search file contents
10
+ - Combine with path parameter to search in specific directories only
@@ -3,7 +3,6 @@ import { z } from 'zod';
3
3
  import { exec } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
  import { join } from 'node:path';
6
- import { stat } from 'node:fs/promises';
7
6
  import DESCRIPTION from './grep.txt' with { type: 'text' };
8
7
  import { defaultIgnoreGlobs } from './ignore.ts';
9
8
 
@@ -64,11 +63,7 @@ export function buildGrepTool(projectRoot: string): {
64
63
  } catch (error: unknown) {
65
64
  const err = error as { code?: number; stderr?: string };
66
65
  if (err.code === 1) {
67
- return {
68
- title: pattern,
69
- metadata: { matches: 0, truncated: false },
70
- output: 'No files found',
71
- };
66
+ return { count: 0, matches: [] };
72
67
  }
73
68
  const err2 = error as { stderr?: string; message?: string };
74
69
  throw new Error(`ripgrep failed: ${err2.stderr || err2.message}`);
@@ -76,10 +71,9 @@ export function buildGrepTool(projectRoot: string): {
76
71
 
77
72
  const lines = output.trim().split('\n');
78
73
  const matches: Array<{
79
- path: string;
80
- modTime: number;
81
- lineNum: number;
82
- lineText: string;
74
+ file: string;
75
+ line: number;
76
+ text: string;
83
77
  }> = [];
84
78
 
85
79
  for (const line of lines) {
@@ -92,47 +86,16 @@ export function buildGrepTool(projectRoot: string): {
92
86
  const lineText = line.slice(idx2 + 1);
93
87
  const lineNum = parseInt(lineNumStr, 10);
94
88
  if (!filePath || !Number.isFinite(lineNum)) continue;
95
- const stats = await stat(filePath)
96
- .then((s) => s.mtime.getTime())
97
- .catch(() => 0);
98
- matches.push({ path: filePath, modTime: stats, lineNum, lineText });
89
+ matches.push({ file: filePath, line: lineNum, text: lineText });
99
90
  }
100
91
 
101
- matches.sort((a, b) => b.modTime - a.modTime);
102
-
103
- const limit = 100;
92
+ const limit = 500;
104
93
  const truncated = matches.length > limit;
105
94
  const finalMatches = truncated ? matches.slice(0, limit) : matches;
106
95
 
107
- if (finalMatches.length === 0) {
108
- return {
109
- title: pattern,
110
- metadata: { matches: 0, truncated: false },
111
- output: 'No files found',
112
- };
113
- }
114
-
115
- const outputLines = [`Found ${finalMatches.length} matches`];
116
- let currentFile = '';
117
- for (const match of finalMatches) {
118
- if (currentFile !== match.path) {
119
- if (currentFile !== '') outputLines.push('');
120
- currentFile = match.path;
121
- outputLines.push(`${match.path}:`);
122
- }
123
- outputLines.push(` Line ${match.lineNum}: ${match.lineText}`);
124
- }
125
- if (truncated) {
126
- outputLines.push('');
127
- outputLines.push(
128
- '(Results are truncated. Consider using a more specific path or pattern.)',
129
- );
130
- }
131
-
132
96
  return {
133
- title: pattern,
134
- metadata: { matches: finalMatches.length, truncated },
135
- output: outputLines.join('\n'),
97
+ count: finalMatches.length,
98
+ matches: finalMatches,
136
99
  };
137
100
  },
138
101
  });
@@ -70,16 +70,57 @@ async function applyEnvelopedPatch(projectRoot: string, patch: string) {
70
70
  if (oldLines.length > 0) {
71
71
  const oldText = oldLines.join('\n');
72
72
  const newText = newLines.join('\n');
73
+
74
+ // Try exact match first
73
75
  if (existingContent.includes(oldText)) {
74
76
  newContent = existingContent.replace(oldText, newText);
75
77
  } else {
76
- // Can't find exact match, this is where enveloped format fails
77
- // Provide more context about what couldn't be found
78
- const preview = oldText.substring(0, 100);
79
- return {
80
- ok: false,
81
- error: `Cannot find content to replace in ${currentFile}. Looking for: "${preview}${oldText.length > 100 ? '...' : ''}"`,
82
- };
78
+ // Try normalizing whitespace for more flexible matching
79
+ const normalizeWhitespace = (s: string) =>
80
+ s.replace(/\s+/g, ' ').trim();
81
+ const normalizedOld = normalizeWhitespace(oldText);
82
+ const normalizedExisting = normalizeWhitespace(existingContent);
83
+
84
+ if (normalizedExisting.includes(normalizedOld)) {
85
+ // Find the actual text in the file by matching normalized version
86
+ const lines = existingContent.split('\n');
87
+ let found = false;
88
+
89
+ for (let i = 0; i < lines.length; i++) {
90
+ const candidate = lines
91
+ .slice(i, i + oldLines.length)
92
+ .join('\n');
93
+ if (normalizeWhitespace(candidate) === normalizedOld) {
94
+ // Replace this section
95
+ const before = lines.slice(0, i).join('\n');
96
+ const after = lines.slice(i + oldLines.length).join('\n');
97
+ newContent =
98
+ before +
99
+ (before ? '\n' : '') +
100
+ newText +
101
+ (after ? '\n' : '') +
102
+ after;
103
+ found = true;
104
+ break;
105
+ }
106
+ }
107
+
108
+ if (!found) {
109
+ const preview = oldText.substring(0, 100);
110
+ return {
111
+ ok: false,
112
+ error: `Cannot find exact location to replace in ${currentFile}. Looking for: "${preview}${oldText.length > 100 ? '...' : ''}"`,
113
+ };
114
+ }
115
+ } else {
116
+ // Can't find even with normalized whitespace
117
+ const preview = oldText.substring(0, 100);
118
+ const filePreview = existingContent.substring(0, 200);
119
+ return {
120
+ ok: false,
121
+ error: `Cannot find content to replace in ${currentFile}.\nLooking for: "${preview}${oldText.length > 100 ? '...' : ''}"\nFile contains: "${filePreview}${existingContent.length > 200 ? '...' : ''}"`,
122
+ };
123
+ }
83
124
  }
84
125
  } else if (newLines.length > 0) {
85
126
  // Just appending new lines
@@ -7,6 +7,7 @@ import { progressUpdateTool } from './builtin/progress.ts';
7
7
  import { buildBashTool } from './builtin/bash.ts';
8
8
  import { buildRipgrepTool } from './builtin/ripgrep.ts';
9
9
  import { buildGrepTool } from './builtin/grep.ts';
10
+ import { buildGlobTool } from './builtin/glob.ts';
10
11
  import { buildApplyPatchTool } from './builtin/patch.ts';
11
12
  import { updatePlanTool } from './builtin/plan.ts';
12
13
  import { editTool } from './builtin/edit.ts';
@@ -107,6 +108,8 @@ export async function discoverProjectTools(
107
108
  tools.set(rg.name, rg.tool);
108
109
  const grep = buildGrepTool(projectRoot);
109
110
  tools.set(grep.name, grep.tool);
111
+ const glob = buildGlobTool(projectRoot);
112
+ tools.set(glob.name, glob.tool);
110
113
  // Patch/apply
111
114
  const ap = buildApplyPatchTool(projectRoot);
112
115
  tools.set(ap.name, ap.tool);