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

97
Tools/CommandTool.cs Normal file
View File

@@ -0,0 +1,97 @@
using System.ComponentModel;
using System.Diagnostics;
using Spectre.Console;
namespace AnchorCli.Tools;
/// <summary>
/// Command execution tool with user approval.
/// </summary>
internal static class CommandTool
{
public static Action<string> Log { get; set; } = Console.WriteLine;
[Description("Execute a shell command after user approval. Prompts with [Y/n] before running. Note: For file editing and operations, use the built-in file tools (ReadFile, ReplaceLines, InsertAfter, DeleteRange, CreateFile, etc.) instead of shell commands.")]
public static string ExecuteCommand(
[Description("The shell command to execute.")] string command)
{
Log($"Command request: {command}");
// Prompt for user approval
AnsiConsole.WriteLine();
AnsiConsole.Write(
new Panel($"[yellow]{Markup.Escape(command)}[/]")
.Header("[bold yellow] Run command? [/]")
.BorderColor(Color.Yellow)
.RoundedBorder()
.Padding(1, 0));
// Drain any buffered keystrokes so stale input doesn't auto-answer
while (Console.KeyAvailable)
Console.ReadKey(intercept: true);
if (!AnsiConsole.Confirm("Execute?", defaultValue: true))
{
AnsiConsole.MarkupLine("[dim grey] ✗ Cancelled by user[/]");
return "ERROR: Command execution cancelled by user";
}
// Execute the command
try
{
var startInfo = new ProcessStartInfo
{
FileName = "/bin/bash",
Arguments = $"-c \"{command}\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
using var process = Process.Start(startInfo);
if (process == null)
{
return "ERROR: Failed to start process";
}
string output = process.StandardOutput.ReadToEnd();
string error = process.StandardError.ReadToEnd();
process.WaitForExit();
// Log the output and error
if (!string.IsNullOrWhiteSpace(output))
{
Log("Output:");
Log(output);
}
if (!string.IsNullOrWhiteSpace(error))
{
Log("Error:");
Log(error);
}
var sb = new System.Text.StringBuilder();
sb.AppendLine($"Command: {command}");
sb.AppendLine($"Exit code: {process.ExitCode}");
if (!string.IsNullOrWhiteSpace(output))
{
sb.AppendLine("Output:");
sb.AppendLine(output);
}
if (!string.IsNullOrWhiteSpace(error))
{
sb.AppendLine("Error:");
sb.AppendLine(error);
}
return sb.ToString();
}
catch (Exception ex)
{
return $"ERROR executing command: {ex.Message}";
}
}
}

86
Tools/DirTools.cs Normal file
View File

@@ -0,0 +1,86 @@
using System;
using System.ComponentModel;
namespace AnchorCli.Tools;
/// <summary>
/// Directory manipulation tools exposed to the LLM as AIFunctions.
/// </summary>
internal static class DirTools
{
public static Action<string> Log { get; set; } = Console.WriteLine;
[Description("Rename or move a directory. Can move a directory to a new location.")]
public static string RenameDir(
[Description("Current path to the directory.")] string sourcePath,
[Description("New path for the directory.")] string destinationPath)
{
sourcePath = ResolvePath(sourcePath);
destinationPath = ResolvePath(destinationPath);
Log($"Renaming/moving directory: {sourcePath} -> {destinationPath}");
if (!Directory.Exists(sourcePath))
return $"ERROR: Directory not found: {sourcePath}";
try
{
var destDir = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir))
{
Directory.CreateDirectory(destDir);
}
Directory.Move(sourcePath, destinationPath);
return $"OK: Directory moved from '{sourcePath}' to '{destinationPath}'";
}
catch (Exception ex)
{
return $"ERROR moving directory '{sourcePath}': {ex.Message}";
}
}
[Description("Delete a directory and all its contents permanently.")]
public static string DeleteDir(
[Description("Path to the directory to delete.")] string path,
[Description("If true, delete recursively. Defaults to true.")] bool recursive = true)
{
path = ResolvePath(path);
Log($"Deleting directory: {path}");
if (!Directory.Exists(path))
return $"ERROR: Directory not found: {path}";
try
{
Directory.Delete(path, recursive);
return $"OK: Directory deleted: '{path}'";
}
catch (Exception ex)
{
return $"ERROR deleting directory '{path}': {ex.Message}";
}
}
[Description("Create a new directory. Creates parent directories if they don't exist. Returns OK on success, or an error message if the directory already exists or creation fails.")]
public static string CreateDir(
[Description("Path to the directory to create.")] string path)
{
path = ResolvePath(path);
Log($"Creating directory: {path}");
if (Directory.Exists(path))
return $"ERROR: Directory already exists: {path}";
try
{
Directory.CreateDirectory(path);
return $"OK (created {path})";
}
catch (Exception ex)
{
return $"ERROR creating directory '{path}': {ex.Message}";
}
}
internal static string ResolvePath(string path) =>
Path.IsPathRooted(path) ? path : Path.GetFullPath(path, Environment.CurrentDirectory);
}

331
Tools/EditTools.cs Normal file
View File

@@ -0,0 +1,331 @@
using System.ComponentModel;
using System.Text.RegularExpressions;
using AnchorCli.Hashline;
namespace AnchorCli.Tools;
/// <summary>
/// Mutating file tools exposed to the LLM as AIFunctions.
/// Every operation validates Hashline anchors (line:hash format) before touching the file.
/// On success, returns "OK fp:{fingerprint}" so the model can verify the mutation.
/// On failure, returns a descriptive ERROR string — no exceptions reach the LLM.
/// </summary>
internal static class EditTools
{
public static Action<string> Log { get; set; } = Console.WriteLine;
/// <summary>
/// Matches accidental hashline prefixes the LLM may include in new content.
/// Format: "lineNumber:hexHash|" e.g. "5:a3|"
/// </summary>
private static readonly Regex HashlinePrefix =
new(@"^\d+:[0-9a-fA-F]{2}\|", RegexOptions.Compiled);
/// <summary>
/// Strips accidental hashline prefixes from lines the LLM sends as new content.
/// Logs a warning when stripping occurs.
/// </summary>
private static string[] SanitizeNewLines(string[] newLines)
{
bool anyStripped = false;
var result = new string[newLines.Length];
for (int i = 0; i < newLines.Length; i++)
{
var match = HashlinePrefix.Match(newLines[i]);
if (match.Success)
{
result[i] = newLines[i][match.Length..];
anyStripped = true;
}
else
{
result[i] = newLines[i];
}
}
if (anyStripped)
{
Log(" ⚠ Stripped hashline prefixes from new content (LLM included anchors in edit content)");
}
return result;
}
[Description("Replace a range of lines in a file, identified by Hashline anchors. Both the line number and hash must match the current file state.")]
public static string ReplaceLines(
[Description("Path to the file.")] string path,
[Description("line:hash anchor of the first line to replace (e.g. '5:a3'). Both the line number AND hash must match.")] string startAnchor,
[Description("line:hash anchor of the last line to replace (e.g. '7:0e'). Use the same as startAnchor to replace a single line.")] string endAnchor,
[Description("New lines to insert in place of the replaced range. Each element becomes one line in the file. IMPORTANT: Write raw source code only. Do NOT include 'lineNumber:hash|' prefixes — those are display-only metadata from ReadFile, not part of the actual file content.")] string[] newLines)
{
newLines = SanitizeNewLines(newLines);
path = FileTools.ResolvePath(path);
Log($"REPLACE_LINES: {path}");
Log($" Range: {startAnchor} -> {endAnchor}");
Log($" Replacing {endAnchor.Split(':')[0]}-{startAnchor.Split(':')[0]} lines with {newLines.Length} new lines");
/*Log($" New content (first 5 lines):");
foreach (var line in newLines.Take(5))
{
Log($" + {line}");
}
if (newLines.Length > 5)
{
Log($" ... and {newLines.Length - 5} more lines");
}*/
if (!File.Exists(path))
return $"ERROR: File not found: {path}";
try
{
string[] lines = File.ReadAllLines(path);
if (!HashlineValidator.TryResolveRange(startAnchor, endAnchor, lines,
out int startIdx, out int endIdx, out string error))
return $"ERROR: {error}";
var result = new List<string>(lines.Length - (endIdx - startIdx + 1) + newLines.Length);
result.AddRange(lines[..startIdx]);
result.AddRange(newLines);
result.AddRange(lines[(endIdx + 1)..]);
File.WriteAllLines(path, result);
return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}";
}
catch (Exception ex)
{
return $"ERROR modifying '{path}': {ex.Message}";
}
}
[Description("Insert new lines immediately after the line identified by a Hashline anchor.")]
public static string InsertAfter(
[Description("Path to the file.")] string path,
[Description("line:hash anchor of the line to insert after (e.g. '3:0e'). Both the line number AND hash must match.")] string anchor,
[Description("Lines to insert after the anchor line. Each element becomes one line in the file. IMPORTANT: Write raw source code only. Do NOT include 'lineNumber:hash|' prefixes — those are display-only metadata from ReadFile, not part of the actual file content.")] string[] newLines)
{
newLines = SanitizeNewLines(newLines);
path = FileTools.ResolvePath(path);
Log($"INSERT_AFTER: {path}");
Log($" Anchor: {anchor}");
Log($" Inserting {newLines.Length} lines after line {anchor.Split(':')[0]}");
/*Log($" New content (first 5 lines):");
foreach (var line in newLines.Take(5))
{
Log($" + {line}");
}
if (newLines.Length > 5)
{
Log($" ... and {newLines.Length - 5} more lines");
}*/
if (!File.Exists(path))
return $"ERROR: File not found: {path}";
try
{
string[] lines = File.ReadAllLines(path);
if (!HashlineValidator.TryResolve(anchor, lines, out int idx, out string error))
return $"ERROR: {error}";
// Log the anchor line we're inserting after
//Log($" Inserting after line {idx + 1}: {lines[idx]}");
var result = new List<string>(lines.Length + newLines.Length);
result.AddRange(lines[..(idx + 1)]);
result.AddRange(newLines);
result.AddRange(lines[(idx + 1)..]);
File.WriteAllLines(path, result);
return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}";
}
catch (Exception ex)
{
return $"ERROR modifying '{path}': {ex.Message}";
}
}
[Description("Delete a range of lines from a file, identified by Hashline anchors.")]
public static string DeleteRange(
[Description("Path to the file.")] string path,
[Description("line:hash anchor of the first line to delete (e.g. '4:7c'). Both the line number AND hash must match.")] string startAnchor,
[Description("line:hash anchor of the last line to delete (e.g. '6:19'). Both the line number AND hash must match.")] string endAnchor)
{
path = FileTools.ResolvePath(path);
Log($"Deleting lines in file: {path}");
if (!File.Exists(path))
return $"ERROR: File not found: {path}";
try
{
string[] lines = File.ReadAllLines(path);
if (!HashlineValidator.TryResolveRange(startAnchor, endAnchor, lines,
out int startIdx, out int endIdx, out string error))
return $"ERROR: {error}";
var result = new List<string>(lines.Length - (endIdx - startIdx + 1));
result.AddRange(lines[..startIdx]);
result.AddRange(lines[(endIdx + 1)..]);
File.WriteAllLines(path, result);
return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}";
}
catch (Exception ex)
{
return $"ERROR modifying '{path}': {ex.Message}";
}
}
[Description("Create a new empty file, or a file with initial content. Creates missing parent directories automatically. If the agent doesn't succeed with initial content, they can also create an empty file first and add the content using AppendToFile.")]
public static string CreateFile(
[Description("Path to the new file to create.")] string path,
[Description("Optional initial content lines. If omitted, creates an empty file. IMPORTANT: Write raw source code only. Do NOT include 'lineNumber:hash|' prefixes — those are display-only metadata from ReadFile, not part of the actual file content.")] string[]? initialLines = null)
{
path = FileTools.ResolvePath(path);
Log($"Creating file: {path}");
if (File.Exists(path))
return $"ERROR: File already exists: {path}";
try
{
if (initialLines is not null)
initialLines = SanitizeNewLines(initialLines);
string? dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
if (initialLines is not null && initialLines.Length > 0)
File.WriteAllLines(path, initialLines);
else
File.WriteAllText(path, "");
return $"OK fp:{HashlineEncoder.FileFingerprint(initialLines ?? [])}";
}
catch (Exception ex)
{
return $"ERROR creating '{path}': {ex.Message}";
}
}
[Description("Delete a file permanently from the disk.")]
public static string DeleteFile(
[Description("Path to the file to delete.")] string path)
{
path = FileTools.ResolvePath(path);
Log($"Deleting file: {path}");
if (!File.Exists(path))
return $"ERROR: File not found: {path}";
try
{
File.Delete(path);
return $"OK (deleted)";
}
catch (Exception ex)
{
return $"ERROR deleting '{path}': {ex.Message}";
}
}
[Description("Rename or move a file. Can move a file to a new directory (which will be created if it doesn't exist).")]
public static string RenameFile(
[Description("Current path to the file.")] string sourcePath,
[Description("New path for the file.")] string destinationPath)
{
sourcePath = FileTools.ResolvePath(sourcePath);
destinationPath = FileTools.ResolvePath(destinationPath);
Log($"Renaming file: {sourcePath} -> {destinationPath}");
if (!File.Exists(sourcePath))
return $"ERROR: Source file not found: {sourcePath}";
if (File.Exists(destinationPath))
return $"ERROR: Destination file already exists: {destinationPath}";
try
{
string? dir = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
File.Move(sourcePath, destinationPath);
return $"OK (moved to {destinationPath})";
}
catch (Exception ex)
{
return $"ERROR moving file: {ex.Message}";
}
}
[Description("Copy a file to a new location.")]
public static string CopyFile(
[Description("Path to the existing file.")] string sourcePath,
[Description("Path for the copy.")] string destinationPath)
{
sourcePath = FileTools.ResolvePath(sourcePath);
destinationPath = FileTools.ResolvePath(destinationPath);
Log($"Copying file: {sourcePath} -> {destinationPath}");
if (!File.Exists(sourcePath))
return $"ERROR: Source file not found: {sourcePath}";
if (File.Exists(destinationPath))
return $"ERROR: Destination file already exists: {destinationPath}";
try
{
string? dir = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
File.Copy(sourcePath, destinationPath);
return $"OK (copied to {destinationPath})";
}
catch (Exception ex)
{
return $"ERROR copying file: {ex.Message}";
}
}
[Description("Append lines to the end of a file without reading it first. Creates the file if it doesn't exist.")]
public static string AppendToFile(
[Description("Path to the file to append to.")] string path,
[Description("Lines to append to the end of the file. IMPORTANT: Write raw source code only. Do NOT include 'lineNumber:hash|' prefixes — those are display-only metadata from ReadFile, not part of the actual file content.")] string[] lines)
{
lines = SanitizeNewLines(lines);
path = FileTools.ResolvePath(path);
Log($"Appending to file: {path}");
Log($" Appending {lines.Length} lines");
try
{
string? dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
if (!File.Exists(path))
{
File.WriteAllText(path, "");
Log($" (created new file)");
}
using (var writer = new System.IO.StreamWriter(path, true))
{
foreach (var line in lines)
{
writer.WriteLine(line);
}
}
string[] allLines = File.ReadAllLines(path);
return $"OK fp:{HashlineEncoder.FileFingerprint([.. allLines])}";
}
catch (Exception ex)
{
return $"ERROR appending to '{path}': {ex.Message}";
}
}
}

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);
}