Add initial project files and configurations for clawd_code
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
// base class for all tools
|
||||
|
||||
abstract class BaseTool {
|
||||
String get name;
|
||||
String get description;
|
||||
|
||||
Future<String> execute(Map<String, dynamic> input);
|
||||
|
||||
// helper to get a required string field
|
||||
String requireString(Map<String, dynamic> input, String key) {
|
||||
final val = input[key];
|
||||
if (val == null) throw ArgumentError("Missing required field: $key");
|
||||
return val.toString();
|
||||
}
|
||||
|
||||
String? optionalString(Map<String, dynamic> input, String key) {
|
||||
final val = input[key];
|
||||
if (val == null) return null;
|
||||
return val.toString();
|
||||
}
|
||||
|
||||
int? optionalInt(Map<String, dynamic> input, String key) {
|
||||
final val = input[key];
|
||||
if (val == null) return null;
|
||||
if (val is int) return val;
|
||||
if (val is String) return int.tryParse(val);
|
||||
return null;
|
||||
}
|
||||
|
||||
bool optionalBool(Map<String, dynamic> input, String key, {bool defaultVal = false}) {
|
||||
final val = input[key];
|
||||
if (val == null) return defaultVal;
|
||||
if (val is bool) return val;
|
||||
if (val is String) return val == "true" || val == "1";
|
||||
return defaultVal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import "dart:async";
|
||||
import "dart:io";
|
||||
import "dart:convert";
|
||||
|
||||
import "base_tool.dart";
|
||||
|
||||
// default timeouts (ms)
|
||||
const int _defaultTimeoutMs = 120000;
|
||||
const int _maxTimeoutMs = 600000;
|
||||
|
||||
class BashTool extends BaseTool {
|
||||
@override
|
||||
final String name = "Bash";
|
||||
|
||||
@override
|
||||
final String description = "Execute a bash command and return its output.";
|
||||
|
||||
@override
|
||||
Future<String> execute(Map<String, dynamic> input) async {
|
||||
final command = requireString(input, "command");
|
||||
final timeoutMs = optionalInt(input, "timeout") ?? _defaultTimeoutMs;
|
||||
final workingDirectory = optionalString(input, "cwd");
|
||||
|
||||
if (command.trim().isEmpty) {
|
||||
throw ArgumentError("command must not be empty");
|
||||
}
|
||||
|
||||
// clamp timeout to max
|
||||
final effectiveTimeout = timeoutMs.clamp(1, _maxTimeoutMs);
|
||||
|
||||
final result = await _runCommand(
|
||||
command,
|
||||
Duration(milliseconds: effectiveTimeout),
|
||||
workingDirectory: workingDirectory,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<String> _runCommand(
|
||||
String command,
|
||||
Duration timeout, {
|
||||
String? workingDirectory,
|
||||
}) async {
|
||||
Process proc;
|
||||
|
||||
try {
|
||||
proc = await Process.start(
|
||||
"/bin/sh",
|
||||
["-c", command],
|
||||
runInShell: false,
|
||||
workingDirectory: workingDirectory,
|
||||
);
|
||||
} catch (e) {
|
||||
throw StateError("Failed to start process: $e");
|
||||
}
|
||||
|
||||
final stdoutBuf = StringBuffer();
|
||||
final stderrBuf = StringBuffer();
|
||||
|
||||
final stdoutDone = proc.stdout
|
||||
.transform(utf8.decoder)
|
||||
.listen((chunk) => stdoutBuf.write(chunk));
|
||||
|
||||
final stderrDone = proc.stderr
|
||||
.transform(utf8.decoder)
|
||||
.listen((chunk) => stderrBuf.write(chunk));
|
||||
|
||||
int exitCode;
|
||||
try {
|
||||
exitCode = await proc.exitCode.timeout(
|
||||
timeout,
|
||||
onTimeout: () {
|
||||
proc.kill(ProcessSignal.sigkill);
|
||||
throw TimeoutException(
|
||||
"Command timed out after ${timeout.inMilliseconds}ms",
|
||||
timeout,
|
||||
);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await stdoutDone.cancel();
|
||||
await stderrDone.cancel();
|
||||
}
|
||||
|
||||
final stdout = stdoutBuf.toString();
|
||||
final stderr = stderrBuf.toString();
|
||||
|
||||
if (exitCode != 0) {
|
||||
final errPart = stderr.isNotEmpty ? "\n$stderr" : "";
|
||||
// match original behaviour - include exit code and stderr
|
||||
return "${stdout}${errPart}\nExit code: $exitCode";
|
||||
}
|
||||
|
||||
// combine stdout + stderr like the original tool
|
||||
final combined = StringBuffer();
|
||||
combined.write(stdout);
|
||||
if (stderr.isNotEmpty) combined.write(stderr);
|
||||
return combined.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import "dart:io";
|
||||
import "dart:convert";
|
||||
|
||||
import "base_tool.dart";
|
||||
|
||||
|
||||
// max file size we'll try to edit in memory (1 GiB)
|
||||
const int _maxEditFileSize = 1024 * 1024 * 1024;
|
||||
|
||||
class FileEditTool extends BaseTool {
|
||||
@override
|
||||
final String name = "Edit";
|
||||
|
||||
@override
|
||||
final String description =
|
||||
"Performs exact string replacements in files. "
|
||||
"Requires old_string and new_string. "
|
||||
"Set replace_all=true to replace all occurrences.";
|
||||
|
||||
|
||||
@override
|
||||
Future<String> execute(Map<String, dynamic> input) async {
|
||||
final filePath = requireString(input, "file_path");
|
||||
final oldString = requireString(input, "old_string");
|
||||
final newString = requireString(input, "new_string");
|
||||
final replaceAll = optionalBool(input, "replace_all");
|
||||
|
||||
if (oldString == newString) {
|
||||
return "Error: old_string and new_string are identical - no changes to make.";
|
||||
}
|
||||
|
||||
final file = File(filePath);
|
||||
|
||||
if (!await file.exists()) {
|
||||
// empty old_string on non-existant file = create new file
|
||||
if (oldString.isEmpty) {
|
||||
final parent = file.parent;
|
||||
if (!await parent.exists()) {
|
||||
await parent.create(recursive: true);
|
||||
}
|
||||
await file.writeAsString(newString, encoding: utf8, flush: true);
|
||||
return "File created successfully at: $filePath";
|
||||
}
|
||||
return "Error: File does not exist: $filePath";
|
||||
}
|
||||
|
||||
// size check
|
||||
final stat = await file.stat();
|
||||
if (stat.size > _maxEditFileSize) {
|
||||
return "Error: File is too large to edit (${stat.size} bytes).";
|
||||
}
|
||||
|
||||
|
||||
String content;
|
||||
try {
|
||||
content = await file.readAsString(encoding: utf8);
|
||||
} catch (_) {
|
||||
try {
|
||||
final bytes = await file.readAsBytes();
|
||||
content = latin1.decode(bytes);
|
||||
} catch (e) {
|
||||
return "Error: Could not read file: $e";
|
||||
}
|
||||
}
|
||||
|
||||
// normalise line endings
|
||||
content = content.replaceAll("\r\n", "\n");
|
||||
|
||||
if (oldString.isEmpty) {
|
||||
// empty old_string on existing file - only valid if file is also empty
|
||||
if (content.trim().isNotEmpty) {
|
||||
return "Error: Cannot create new file - file already exists with content.";
|
||||
}
|
||||
await file.writeAsString(newString, encoding: utf8, flush: true);
|
||||
return "The file $filePath has been updated successfully.";
|
||||
}
|
||||
|
||||
if (!content.contains(oldString)) {
|
||||
return "Error: String to replace not found in file.\nString: $oldString";
|
||||
}
|
||||
|
||||
// check for multiple matches when replace_all is false
|
||||
if (!replaceAll) {
|
||||
final matches = oldString.allMatches(content).length;
|
||||
|
||||
if (matches > 1) {
|
||||
return "Error: Found $matches matches of the string to replace, but replace_all is false. "
|
||||
"Either set replace_all=true or provide more context to uniquely identify the instance.";
|
||||
}
|
||||
}
|
||||
|
||||
final updated = replaceAll
|
||||
? content.replaceAll(oldString, newString)
|
||||
: content.replaceFirst(oldString, newString);
|
||||
|
||||
try {
|
||||
await file.writeAsString(updated, encoding: utf8, flush: true);
|
||||
} catch (e) {
|
||||
return "Error: Failed to write file: $e";
|
||||
}
|
||||
|
||||
if (replaceAll) {
|
||||
return "The file $filePath has been updated. All occurrences were successfully replaced.";
|
||||
}
|
||||
|
||||
return "The file $filePath has been updated successfully.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import "dart:io";
|
||||
import "dart:convert";
|
||||
|
||||
import "base_tool.dart";
|
||||
|
||||
|
||||
// blocked paths that would hang or make no sense to read
|
||||
const _blockedPaths = {
|
||||
"/dev/zero", "/dev/random", "/dev/urandom", "/dev/full",
|
||||
"/dev/stdin", "/dev/tty", "/dev/console",
|
||||
"/dev/stdout", "/dev/stderr",
|
||||
"/dev/fd/0", "/dev/fd/1", "/dev/fd/2",
|
||||
};
|
||||
|
||||
// max lines we'll add numbers to before giving up
|
||||
const int _defaultLineLimit = 2000;
|
||||
|
||||
|
||||
class FileReadTool extends BaseTool {
|
||||
@override
|
||||
final String name = "Read";
|
||||
|
||||
@override
|
||||
final String description =
|
||||
"Reads a file from the local filesystem. "
|
||||
"Supports offset and limit params to read specific portions. "
|
||||
"Returns content with line numbers in cat -n format.";
|
||||
|
||||
|
||||
@override
|
||||
Future<String> execute(Map<String, dynamic> input) async {
|
||||
final filePath = requireString(input, "file_path");
|
||||
final offset = optionalInt(input, "offset") ?? 0;
|
||||
final limit = optionalInt(input, "limit");
|
||||
|
||||
// check blocked device paths
|
||||
if (_blockedPaths.contains(filePath)) {
|
||||
return "Error: Reading from $filePath is not allowed.";
|
||||
}
|
||||
|
||||
final file = File(filePath);
|
||||
|
||||
if (!await file.exists()) {
|
||||
return "Error: File not found: $filePath";
|
||||
}
|
||||
|
||||
// check if its a directory
|
||||
final stat = await file.stat();
|
||||
if (stat.type == FileSystemEntityType.directory) {
|
||||
return "Error: Path is a directory, not a file: $filePath";
|
||||
}
|
||||
|
||||
String content;
|
||||
try {
|
||||
content = await file.readAsString(encoding: utf8);
|
||||
} catch (e) {
|
||||
// maybe its latin1 or something
|
||||
try {
|
||||
final bytes = await file.readAsBytes();
|
||||
content = latin1.decode(bytes);
|
||||
} catch (_) {
|
||||
return "Error: Could not read file (binary or encoding issue): $filePath";
|
||||
}
|
||||
}
|
||||
|
||||
// normalise line endings
|
||||
content = content.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
|
||||
|
||||
final lines = content.split("\n");
|
||||
|
||||
// apply offset + limit
|
||||
final effectiveOffset = offset.clamp(0, lines.length);
|
||||
final effectiveEnd = limit != null
|
||||
? (effectiveOffset + limit).clamp(0, lines.length)
|
||||
: lines.length;
|
||||
|
||||
final sliced = lines.sublist(effectiveOffset, effectiveEnd);
|
||||
|
||||
// add line numbers like cat -n (1-indexed, based on original file position)
|
||||
final buf = StringBuffer();
|
||||
for (var i = 0; i < sliced.length; i++) {
|
||||
final lineNum = effectiveOffset + i + 1;
|
||||
buf.writeln("${lineNum.toString().padLeft(6)}\t${sliced[i]}");
|
||||
}
|
||||
|
||||
final result = buf.toString();
|
||||
|
||||
if (result.isEmpty) {
|
||||
return "(empty file)";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import "dart:io";
|
||||
import "dart:convert";
|
||||
|
||||
import "base_tool.dart";
|
||||
|
||||
|
||||
class FileWriteTool extends BaseTool {
|
||||
@override
|
||||
final String name = "Write";
|
||||
|
||||
@override
|
||||
final String description =
|
||||
"Writes a file to the local filesystem. "
|
||||
"Overwrites the file if it already exists. "
|
||||
"Creates parent directories as needed.";
|
||||
|
||||
|
||||
@override
|
||||
Future<String> execute(Map<String, dynamic> input) async {
|
||||
final filePath = requireString(input, "file_path");
|
||||
final content = requireString(input, "content");
|
||||
|
||||
if (filePath.isEmpty) {
|
||||
throw ArgumentError("file_path must not be empty");
|
||||
}
|
||||
|
||||
final file = File(filePath);
|
||||
|
||||
// figure out if this is a create or update
|
||||
final existed = await file.exists();
|
||||
|
||||
// make sure parent dir exists
|
||||
final parent = file.parent;
|
||||
if (!await parent.exists()) {
|
||||
await parent.create(recursive: true);
|
||||
}
|
||||
|
||||
try {
|
||||
await file.writeAsString(content, encoding: utf8, flush: true);
|
||||
} catch (e) {
|
||||
return "Error: Failed to write file: $e";
|
||||
}
|
||||
|
||||
if (existed) {
|
||||
return "The file $filePath has been updated successfully.";
|
||||
}
|
||||
|
||||
return "File created successfully at: $filePath";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import "dart:io";
|
||||
|
||||
import "base_tool.dart";
|
||||
|
||||
|
||||
class GlobTool extends BaseTool {
|
||||
@override
|
||||
final String name = "Glob";
|
||||
|
||||
@override
|
||||
final String description =
|
||||
"Fast file pattern matching. Supports glob patterns like \"**/*.js\". "
|
||||
"Returns matching file paths sorted by modification time.";
|
||||
|
||||
@override
|
||||
Future<String> execute(Map<String, dynamic> input) async {
|
||||
final pattern = requireString(input, "pattern");
|
||||
final pathArg = optionalString(input, "path");
|
||||
|
||||
final searchDir = pathArg != null ? Directory(pathArg) : Directory.current;
|
||||
|
||||
if (!await searchDir.exists()) {
|
||||
return "Error: Directory does not exist: ${searchDir.path}";
|
||||
}
|
||||
|
||||
final start = DateTime.now();
|
||||
|
||||
final matched = <FileSystemEntity>[];
|
||||
|
||||
await for (final entity in searchDir.list(recursive: true, followLinks: false)) {
|
||||
if (entity is File) {
|
||||
final rel = entity.path.substring(searchDir.path.length);
|
||||
// strip leading slash
|
||||
final relClean = rel.startsWith("/") ? rel.substring(1) : rel;
|
||||
if (_matchGlob(pattern, relClean)) {
|
||||
matched.add(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const maxResults = 100;
|
||||
final truncated = matched.length > maxResults;
|
||||
final limited = truncated ? matched.sublist(0, maxResults) : matched;
|
||||
|
||||
// sort by mtime desc (same as original)
|
||||
final withStat = <MapEntry<FileSystemEntity, DateTime>>[];
|
||||
for (final f in limited) {
|
||||
try {
|
||||
final st = await f.stat();
|
||||
withStat.add(MapEntry(f, st.modified));
|
||||
} catch (_) {
|
||||
withStat.add(MapEntry(f, DateTime.fromMillisecondsSinceEpoch(0)));
|
||||
}
|
||||
}
|
||||
|
||||
withStat.sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
final filenames = withStat.map((e) {
|
||||
final rel = e.key.path.substring(searchDir.path.length);
|
||||
return rel.startsWith("/") ? rel.substring(1) : rel;
|
||||
}).toList();
|
||||
|
||||
final durationMs = DateTime.now().difference(start).inMilliseconds;
|
||||
|
||||
if (filenames.isEmpty) {
|
||||
return "No files found";
|
||||
}
|
||||
|
||||
final buf = StringBuffer();
|
||||
for (final f in filenames) {
|
||||
buf.writeln(f);
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
buf.writeln("(Results are truncated. Consider using a more specific path or pattern.)");
|
||||
}
|
||||
|
||||
// small summary footer
|
||||
buf.write("Found ${filenames.length} file${filenames.length == 1 ? "" : "s"} in ${durationMs}ms");
|
||||
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
// basic glob matching - handles ** and *
|
||||
bool _matchGlob(String pattern, String path) {
|
||||
return _globMatch(pattern, path);
|
||||
}
|
||||
|
||||
bool _globMatch(String pattern, String text) {
|
||||
// convert glob to regex-ish check
|
||||
final regexStr = _globToRegex(pattern);
|
||||
final regex = RegExp(regexStr);
|
||||
return regex.hasMatch(text);
|
||||
}
|
||||
|
||||
String _globToRegex(String pattern) {
|
||||
final buf = StringBuffer("^");
|
||||
|
||||
int i = 0;
|
||||
while (i < pattern.length) {
|
||||
final c = pattern[i];
|
||||
|
||||
if (c == "*") {
|
||||
if (i + 1 < pattern.length && pattern[i + 1] == "*") {
|
||||
// ** matches anything including slashes
|
||||
buf.write(".*");
|
||||
i += 2;
|
||||
// skip optional trailing slash after **
|
||||
if (i < pattern.length && pattern[i] == "/") i++;
|
||||
} else {
|
||||
// * matches anything except slash
|
||||
buf.write("[^/]*");
|
||||
i++;
|
||||
}
|
||||
} else if (c == "?") {
|
||||
buf.write("[^/]");
|
||||
i++;
|
||||
} else if (c == ".") {
|
||||
buf.write("\\.");
|
||||
i++;
|
||||
} else if (c == "{") {
|
||||
// {a,b,c} style alternation
|
||||
final close = pattern.indexOf("}", i);
|
||||
if (close == -1) {
|
||||
buf.write("\\{");
|
||||
i++;
|
||||
} else {
|
||||
final inner = pattern.substring(i + 1, close);
|
||||
final parts = inner.split(",").map(_globToRegex).join("|");
|
||||
buf.write("(?:$parts)");
|
||||
i = close + 1;
|
||||
}
|
||||
} else if ("[]()|^".contains(c)) {
|
||||
buf.write("\\$c");
|
||||
i++;
|
||||
} else {
|
||||
buf.write(c);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
buf.write(r"$");
|
||||
return buf.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import "dart:io";
|
||||
import "dart:convert";
|
||||
|
||||
import "base_tool.dart";
|
||||
|
||||
|
||||
// directories to skip during grep searches
|
||||
const _vcsSkip = {".git", ".svn", ".hg", ".bzr", ".jj", ".sl"};
|
||||
|
||||
const int _defaultHeadLimit = 250;
|
||||
|
||||
class GrepTool extends BaseTool {
|
||||
@override
|
||||
final String name = "Grep";
|
||||
|
||||
@override
|
||||
final String description =
|
||||
"A powerful search tool. Supports full regex syntax. "
|
||||
"Filter files with glob parameter. "
|
||||
"Output modes: content, files_with_matches, count.";
|
||||
|
||||
|
||||
@override
|
||||
Future<String> execute(Map<String, dynamic> input) async {
|
||||
final pattern = requireString(input, "pattern");
|
||||
final pathArg = optionalString(input, "path");
|
||||
final glob = optionalString(input, "glob");
|
||||
final outputMode = optionalString(input, "output_mode") ?? "files_with_matches";
|
||||
final caseInsensitive = optionalBool(input, "-i");
|
||||
final showLineNumbers = optionalBool(input, "-n", defaultVal: true);
|
||||
final multiline = optionalBool(input, "multiline");
|
||||
final fileType = optionalString(input, "type");
|
||||
final headLimit = optionalInt(input, "head_limit");
|
||||
final offset = optionalInt(input, "offset") ?? 0;
|
||||
|
||||
final contextLines = optionalInt(input, "context") ?? optionalInt(input, "-C");
|
||||
final contextBefore = optionalInt(input, "-B");
|
||||
final contextAfter = optionalInt(input, "-A");
|
||||
|
||||
// try ripgrep first since thats what the original does
|
||||
final rgPath = await _findRipgrep();
|
||||
if (rgPath != null) {
|
||||
return _runWithRipgrep(
|
||||
rgPath: rgPath,
|
||||
pattern: pattern,
|
||||
path: pathArg,
|
||||
glob: glob,
|
||||
outputMode: outputMode,
|
||||
caseInsensitive: caseInsensitive,
|
||||
showLineNumbers: showLineNumbers,
|
||||
multiline: multiline,
|
||||
fileType: fileType,
|
||||
headLimit: headLimit,
|
||||
offset: offset,
|
||||
contextLines: contextLines,
|
||||
contextBefore: contextBefore,
|
||||
contextAfter: contextAfter,
|
||||
);
|
||||
}
|
||||
|
||||
// fallback - pure dart implementation
|
||||
return _runPureDart(
|
||||
pattern: pattern,
|
||||
path: pathArg,
|
||||
glob: glob,
|
||||
outputMode: outputMode,
|
||||
caseInsensitive: caseInsensitive,
|
||||
showLineNumbers: showLineNumbers,
|
||||
headLimit: headLimit,
|
||||
offset: offset,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<String?> _findRipgrep() async {
|
||||
for (final candidate in ["rg", "/usr/bin/rg", "/usr/local/bin/rg"]) {
|
||||
try {
|
||||
final res = await Process.run("which", [candidate]);
|
||||
if ((res.exitCode == 0) && (res.stdout as String).trim().isNotEmpty) {
|
||||
return (res.stdout as String).trim();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Future<String> _runWithRipgrep({
|
||||
required String rgPath,
|
||||
required String pattern,
|
||||
String? path,
|
||||
String? glob,
|
||||
required String outputMode,
|
||||
required bool caseInsensitive,
|
||||
required bool showLineNumbers,
|
||||
required bool multiline,
|
||||
String? fileType,
|
||||
int? headLimit,
|
||||
required int offset,
|
||||
int? contextLines,
|
||||
int? contextBefore,
|
||||
int? contextAfter,
|
||||
}) async {
|
||||
final searchPath = path ?? Directory.current.path;
|
||||
|
||||
final args = <String>["--hidden"];
|
||||
|
||||
for (final dir in _vcsSkip) {
|
||||
args.addAll(["--glob", "!$dir"]);
|
||||
}
|
||||
|
||||
args.addAll(["--max-columns", "500"]);
|
||||
|
||||
if (multiline) args.addAll(["-U", "--multiline-dotall"]);
|
||||
if (caseInsensitive) args.add("-i");
|
||||
|
||||
if (outputMode == "files_with_matches") {
|
||||
args.add("-l");
|
||||
} else if (outputMode == "count") {
|
||||
args.add("-c");
|
||||
}
|
||||
|
||||
if (showLineNumbers && outputMode == "content") args.add("-n");
|
||||
|
||||
if (outputMode == "content") {
|
||||
if (contextLines != null) {
|
||||
args.addAll(["-C", "$contextLines"]);
|
||||
} else {
|
||||
if (contextBefore != null) args.addAll(["-B", "$contextBefore"]);
|
||||
if (contextAfter != null) args.addAll(["-A", "$contextAfter"]);
|
||||
}
|
||||
}
|
||||
|
||||
// pattern starting with dash needs -e flag
|
||||
if (pattern.startsWith("-")) {
|
||||
args.addAll(["-e", pattern]);
|
||||
} else {
|
||||
args.add(pattern);
|
||||
}
|
||||
|
||||
if (fileType != null) args.addAll(["--type", fileType]);
|
||||
|
||||
if (glob != null && glob.isNotEmpty) {
|
||||
final parts = glob.split(RegExp(r"\s+"));
|
||||
for (final p in parts) {
|
||||
if (p.isEmpty) continue;
|
||||
args.addAll(["--glob", p]);
|
||||
}
|
||||
}
|
||||
|
||||
final result = await Process.run(rgPath, [...args, searchPath],
|
||||
stdoutEncoding: utf8, stderrEncoding: utf8);
|
||||
|
||||
// exit code 1 = no matches (normal), 2 = error
|
||||
if (result.exitCode == 2) {
|
||||
return "Error: ${result.stderr}";
|
||||
}
|
||||
|
||||
final lines = (result.stdout as String)
|
||||
.split("\n")
|
||||
.where((l) => l.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
return _formatResults(lines, outputMode, headLimit, offset);
|
||||
}
|
||||
|
||||
|
||||
Future<String> _runPureDart({
|
||||
required String pattern,
|
||||
String? path,
|
||||
String? glob,
|
||||
required String outputMode,
|
||||
required bool caseInsensitive,
|
||||
required bool showLineNumbers,
|
||||
int? headLimit,
|
||||
required int offset,
|
||||
}) async {
|
||||
final searchDir = Directory(path ?? Directory.current.path);
|
||||
if (!await searchDir.exists()) {
|
||||
return "Error: Path does not exist: ${searchDir.path}";
|
||||
}
|
||||
|
||||
final regex = RegExp(pattern, caseSensitive: !caseInsensitive, multiLine: true);
|
||||
|
||||
final matchedFiles = <String>[];
|
||||
final contentLines = <String>[];
|
||||
var totalMatches = 0;
|
||||
|
||||
await for (final entity in searchDir.list(recursive: true, followLinks: false)) {
|
||||
if (entity is! File) continue;
|
||||
|
||||
// skip vcs dirs
|
||||
final parts = entity.path.split("/");
|
||||
if (parts.any((p) => _vcsSkip.contains(p))) continue;
|
||||
|
||||
// glob filter
|
||||
if (glob != null) {
|
||||
final filename = entity.path.split("/").last;
|
||||
if (!_simpleGlobMatch(glob, filename)) continue;
|
||||
}
|
||||
|
||||
String content;
|
||||
try {
|
||||
content = await entity.readAsString(encoding: utf8);
|
||||
} catch (_) {
|
||||
continue; // skip binary/unreadable
|
||||
}
|
||||
|
||||
final fileMatches = regex.allMatches(content).length;
|
||||
if (fileMatches == 0) continue;
|
||||
|
||||
final relPath = entity.path.startsWith(searchDir.path)
|
||||
? entity.path.substring(searchDir.path.length + 1)
|
||||
: entity.path;
|
||||
|
||||
if (outputMode == "files_with_matches") {
|
||||
matchedFiles.add(relPath);
|
||||
} else if (outputMode == "count") {
|
||||
contentLines.add("$relPath:$fileMatches");
|
||||
totalMatches += fileMatches;
|
||||
} else {
|
||||
// content mode
|
||||
final fileLines = content.split("\n");
|
||||
for (var i = 0; i < fileLines.length; i++) {
|
||||
if (regex.hasMatch(fileLines[i])) {
|
||||
final prefix = showLineNumbers ? "$relPath:${i + 1}:" : "$relPath:";
|
||||
contentLines.add("$prefix${fileLines[i]}");
|
||||
}
|
||||
}
|
||||
matchedFiles.add(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (outputMode == "files_with_matches") {
|
||||
return _formatResults(matchedFiles, outputMode, headLimit, offset);
|
||||
}
|
||||
|
||||
return _formatResults(contentLines, outputMode, headLimit, offset);
|
||||
}
|
||||
|
||||
|
||||
String _formatResults(List<String> lines, String outputMode, int? headLimit, int offset) {
|
||||
// apply offset + head_limit
|
||||
final effectiveLimit = (headLimit == 0) ? null : (headLimit ?? _defaultHeadLimit);
|
||||
|
||||
List<String> sliced;
|
||||
if (effectiveLimit == null) {
|
||||
sliced = lines.sublist(offset.clamp(0, lines.length));
|
||||
} else {
|
||||
final start = offset.clamp(0, lines.length);
|
||||
final end = (start + effectiveLimit).clamp(0, lines.length);
|
||||
sliced = lines.sublist(start, end);
|
||||
}
|
||||
|
||||
if (sliced.isEmpty) {
|
||||
return outputMode == "files_with_matches" ? "No files found" : "No matches found";
|
||||
}
|
||||
|
||||
return sliced.join("\n");
|
||||
}
|
||||
|
||||
|
||||
bool _simpleGlobMatch(String pattern, String text) {
|
||||
final regex = RegExp(
|
||||
"^${pattern.replaceAll(".", "\\.").replaceAll("*", ".*").replaceAll("?", ".")}\$",
|
||||
);
|
||||
return regex.hasMatch(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import "base_tool.dart";
|
||||
import "bash_tool.dart";
|
||||
import "glob_tool.dart";
|
||||
import "grep_tool.dart";
|
||||
import "file_read_tool.dart";
|
||||
import "file_write_tool.dart";
|
||||
import "file_edit_tool.dart";
|
||||
|
||||
|
||||
// registry that holds all available tools by name
|
||||
class ToolRegistry {
|
||||
final Map<String, BaseTool> _tools = {};
|
||||
|
||||
ToolRegistry() {
|
||||
_register(BashTool());
|
||||
_register(GlobTool());
|
||||
_register(GrepTool());
|
||||
_register(FileReadTool());
|
||||
_register(FileWriteTool());
|
||||
_register(FileEditTool());
|
||||
}
|
||||
|
||||
void _register(BaseTool tool) {
|
||||
_tools[tool.name] = tool;
|
||||
}
|
||||
|
||||
|
||||
BaseTool? getTool(String name) => _tools[name];
|
||||
|
||||
List<BaseTool> get allTools => _tools.values.toList();
|
||||
|
||||
List<String> get toolNames => _tools.keys.toList();
|
||||
|
||||
// execute a tool by name
|
||||
Future<String> execute(String toolName, Map<String, dynamic> input) async {
|
||||
final tool = _tools[toolName];
|
||||
if (tool == null) {
|
||||
return "Error: Unknown tool \"$toolName\". Available tools: ${toolNames.join(", ")}";
|
||||
}
|
||||
|
||||
return tool.execute(input);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user