Add initial project files and configurations for clawd_code

This commit is contained in:
ImBenji
2026-04-03 17:48:07 +01:00
parent 7541a5279b
commit c88a1badc7
273 changed files with 28339 additions and 0 deletions
+37
View File
@@ -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;
}
}
+100
View File
@@ -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();
}
}
+108
View File
@@ -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.";
}
}
+94
View File
@@ -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;
}
}
+50
View File
@@ -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";
}
}
+146
View File
@@ -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();
}
}
+269
View File
@@ -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);
}
}
+43
View File
@@ -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);
}
}