1
0

initial commit

This commit is contained in:
2026-03-04 07:59:35 +01:00
commit 3ceb0e4884
27 changed files with 2280 additions and 0 deletions

309
Tools/FileTools.cs Normal file
View File

@@ -0,0 +1,309 @@
using System;
using System.ComponentModel;
using System.Text.RegularExpressions;
using AnchorCli.Hashline;
using Spectre.Console;
namespace AnchorCli.Tools;
/// <summary>
/// Read-only file tools exposed to the LLM as AIFunctions.
/// All methods are static with primitive parameters for AOT compatibility.
/// </summary>
internal static class FileTools
{
public static Action<string> Log { get; set; } = Console.WriteLine;
[Description("Read a file and return its lines tagged with Hashline anchors in the format lineNumber:hash|content. Optionally restrict to a line window.")]
public static string ReadFile(
[Description("Path to the file to read. Can be relative to the working directory or absolute.")] string path,
[Description("First line to return, 1-indexed inclusive. Defaults to 1.")] int startLine = 1,
[Description("Last line to return, 1-indexed inclusive. Use 0 for end of file. Defaults to 0.")] int endLine = 0)
{
path = ResolvePath(path);
Log($"Reading file: {path}");
if (!File.Exists(path))
return $"ERROR: File not found: {path}";
try
{
string[] lines = File.ReadAllLines(path);
if (lines.Length == 0)
return $"(empty file: {path})";
return HashlineEncoder.Encode(lines, startLine, endLine);
}
catch (Exception ex)
{
return $"ERROR reading '{path}': {ex.Message}";
}
}
[Description("Search a file for lines matching a regex pattern. Returns only matching lines, already tagged with Hashline anchors so you can reference them in edit operations immediately.")]
public static string GrepFile(
[Description("Path to the file to search.")] string path,
[Description("Regular expression pattern to search for.")] string pattern)
{
path = ResolvePath(path);
Log($"Searching file: {path}");
if (!File.Exists(path))
return $"ERROR: File not found: {path}";
Regex regex;
try
{
regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
}
catch (Exception ex)
{
return $"ERROR: Invalid regex pattern '{pattern}': {ex.Message}";
}
try
{
string[] lines = File.ReadAllLines(path);
var sb = new System.Text.StringBuilder();
int matchCount = 0;
for (int i = 0; i < lines.Length; i++)
{
if (regex.IsMatch(lines[i]))
{
int lineNumber = i + 1;
string hash = HashlineEncoder.ComputeHash(lines[i].AsSpan(), lineNumber);
sb.Append(lineNumber).Append(':').Append(hash).Append('|').AppendLine(lines[i]);
matchCount++;
}
}
if (matchCount == 0)
return $"(no matches for '{pattern}' in {path})";
return sb.ToString();
}
catch (Exception ex)
{
return $"ERROR searching '{path}': {ex.Message}";
}
}
[Description("List the files and subdirectories in a directory.")]
public static string ListDir(
[Description("Path to the directory to list. Defaults to the current working directory.")] string path = ".")
{
path = ResolvePath(path);
Log($"Listing directory: {path}");
if (!Directory.Exists(path))
return $"ERROR: Directory not found: {path}";
try
{
var sb = new System.Text.StringBuilder();
sb.AppendLine($"Directory: {path}");
foreach (string dir in Directory.GetDirectories(path))
sb.AppendLine($" [dir] {Path.GetFileName(dir)}/");
foreach (string file in Directory.GetFiles(path))
{
var info = new FileInfo(file);
sb.AppendLine($" [file] {info.Name} ({info.Length} bytes)");
}
return sb.ToString();
}
catch (Exception ex)
{
return $"ERROR listing '{path}': {ex.Message}";
}
}
[Description("Search for files matching a glob/wildcard pattern (e.g., '*.cs', 'src/**/*.js'). Returns full paths of matching files.")]
public static string FindFiles(
[Description("Path to start the search (directory).")] string path,
[Description("Glob pattern to match files (e.g., '*.cs', '**/*.json'). Supports * and ** wildcards.")] string pattern)
{
path = ResolvePath(path);
Log($"Finding files: {pattern} in {path}");
if (!Directory.Exists(path))
return $"ERROR: Directory not found: {path}";
try
{
var searchOption = pattern.Contains("**")
? System.IO.SearchOption.AllDirectories
: System.IO.SearchOption.TopDirectoryOnly;
string[] files = Directory.GetFiles(path, pattern.Replace("**/", ""), searchOption);
var sb = new System.Text.StringBuilder();
if (files.Length == 0)
return $"(no files matching '{pattern}' in {path})";
sb.AppendLine($"Found {files.Length} file(s) matching '{pattern}':");
foreach (var file in files)
{
sb.AppendLine($" {file}");
}
return sb.ToString();
}
catch (Exception ex)
{
return $"ERROR searching for files: {ex.Message}";
}
}
[Description("Search for a regex pattern across all files in a directory tree. Returns matches with file:line:hash|content format.")]
public static string GrepRecursive(
[Description("Path to the directory to search recursively.")] string path,
[Description("Regular expression pattern to search for.")] string pattern,
[Description("Optional glob pattern to filter which files to search (e.g., '*.cs'). Defaults to all files.")] string? filePattern = null)
{
path = ResolvePath(path);
Log($"Recursive grep: {pattern} in {path}" + (filePattern != null ? $" (files: {filePattern})" : ""));
if (!Directory.Exists(path))
return $"ERROR: Directory not found: {path}";
Regex regex;
try
{
regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
}
catch (Exception ex)
{
return $"ERROR: Invalid regex pattern '{pattern}': {ex.Message}";
}
try
{
string globPattern = filePattern?.Replace("**/", "") ?? "*";
var sb = new System.Text.StringBuilder();
int totalMatches = 0;
foreach (var file in EnumerateFilesRecursive(path, globPattern))
{
try
{
// Skip binary files: check first 512 bytes for null chars
using var probe = new StreamReader(file);
var buf = new char[512];
int read = probe.Read(buf, 0, buf.Length);
if (new ReadOnlySpan<char>(buf, 0, read).Contains('\0'))
continue;
}
catch { continue; }
try
{
string[] lines = File.ReadAllLines(file);
for (int i = 0; i < lines.Length; i++)
{
if (regex.IsMatch(lines[i]))
{
int lineNumber = i + 1;
string hash = HashlineEncoder.ComputeHash(lines[i].AsSpan(), lineNumber);
sb.Append(file).Append(':').Append(lineNumber).Append(':').Append(hash).Append('|').AppendLine(lines[i]);
totalMatches++;
}
}
}
catch
{
// Skip files that can't be read
}
}
if (totalMatches == 0)
return $"(no matches for '{pattern}' in {path})";
return $"Found {totalMatches} match(es):\n" + sb.ToString();
}
catch (Exception ex)
{
return $"ERROR in recursive grep: {ex.Message}";
}
}
/// <summary>
/// Safely enumerates files recursively, skipping inaccessible and non-useful directories.
/// Unlike Directory.GetFiles(..., AllDirectories), this doesn't crash on the first
/// permission-denied directory — it just skips it and continues.
/// </summary>
private static readonly HashSet<string> SkipDirs = new(StringComparer.OrdinalIgnoreCase)
{
".git", "bin", "obj", "node_modules", ".vs", "publish", ".svn", "__pycache__"
};
private static IEnumerable<string> EnumerateFilesRecursive(string dir, string pattern)
{
string[] files;
try { files = Directory.GetFiles(dir, pattern); }
catch { yield break; }
foreach (var f in files)
yield return f;
string[] subdirs;
try { subdirs = Directory.GetDirectories(dir); }
catch { yield break; }
foreach (var sub in subdirs)
{
if (SkipDirs.Contains(Path.GetFileName(sub)))
continue;
foreach (var f in EnumerateFilesRecursive(sub, pattern))
yield return f;
}
}
[Description("Get detailed information about a file (size, permissions, last modified, type, etc.).")]
public static string GetFileInfo(
[Description("Path to the file to get information about.")] string path)
{
path = ResolvePath(path);
Log($"Getting file info: {path}");
if (!File.Exists(path))
return $"ERROR: File not found: {path}";
try
{
var info = new FileInfo(path);
var sb = new System.Text.StringBuilder();
sb.AppendLine($"File: {path}");
sb.AppendLine($" Name: {info.Name}");
sb.AppendLine($" Size: {info.Length} bytes ({info.Length / 1024f:F1} KB)");
sb.AppendLine($" Type: {info.Extension}".Replace(".", ""));
sb.AppendLine($" Created: {info.CreationTime}");
sb.AppendLine($" Modified: {info.LastWriteTime}");
sb.AppendLine($" Accessed: {info.LastAccessTime}");
sb.AppendLine($" IsReadOnly: {info.IsReadOnly}");
return sb.ToString();
}
catch (Exception ex)
{
return $"ERROR getting file info: {ex.Message}";
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// <summary>
/// Resolves a path relative to the current working directory.
/// Does NOT restrict to a sandbox — anchor is a local tool running as the user.
/// </summary>
internal static string ResolvePath(string path) =>
Path.IsPathRooted(path) ? path : Path.GetFullPath(path, Environment.CurrentDirectory);
}