From 3ceb0e4884fb78a3156909a3a983994c8d5369a1 Mon Sep 17 00:00:00 2001 From: TomiEckert Date: Wed, 4 Mar 2026 07:59:35 +0100 Subject: [PATCH] initial commit --- .gitignore | 4 + AnchorCli.csproj | 22 ++ AnchorConfig.cs | 40 ++++ AppJsonContext.cs | 23 +++ Commands/ClearCommand.cs | 14 ++ Commands/CommandDispatcher.cs | 31 +++ Commands/CommandRegistry.cs | 18 ++ Commands/CompactCommand.cs | 38 ++++ Commands/ExitCommand.cs | 15 ++ Commands/HelpCommand.cs | 33 +++ Commands/ICommand.cs | 8 + Commands/StatusCommand.cs | 34 ++++ ContextCompactor.cs | 157 +++++++++++++++ Hashline/HashlineEncoder.cs | 84 ++++++++ Hashline/HashlineValidator.cs | 113 +++++++++++ IDEAS.md | 85 ++++++++ OpenRouter/ModelInfo.cs | 48 +++++ OpenRouter/PricingProvider.cs | 52 +++++ OpenRouter/TokenTracker.cs | 80 ++++++++ Program.cs | 366 ++++++++++++++++++++++++++++++++++ README.md | 128 ++++++++++++ SetupTui.cs | 44 ++++ Tools/CommandTool.cs | 97 +++++++++ Tools/DirTools.cs | 86 ++++++++ Tools/EditTools.cs | 331 ++++++++++++++++++++++++++++++ Tools/FileTools.cs | 309 ++++++++++++++++++++++++++++ installer.sh | 20 ++ 27 files changed, 2280 insertions(+) create mode 100644 .gitignore create mode 100644 AnchorCli.csproj create mode 100644 AnchorConfig.cs create mode 100644 AppJsonContext.cs create mode 100644 Commands/ClearCommand.cs create mode 100644 Commands/CommandDispatcher.cs create mode 100644 Commands/CommandRegistry.cs create mode 100644 Commands/CompactCommand.cs create mode 100644 Commands/ExitCommand.cs create mode 100644 Commands/HelpCommand.cs create mode 100644 Commands/ICommand.cs create mode 100644 Commands/StatusCommand.cs create mode 100644 ContextCompactor.cs create mode 100644 Hashline/HashlineEncoder.cs create mode 100644 Hashline/HashlineValidator.cs create mode 100644 IDEAS.md create mode 100644 OpenRouter/ModelInfo.cs create mode 100644 OpenRouter/PricingProvider.cs create mode 100644 OpenRouter/TokenTracker.cs create mode 100644 Program.cs create mode 100644 README.md create mode 100644 SetupTui.cs create mode 100644 Tools/CommandTool.cs create mode 100644 Tools/DirTools.cs create mode 100644 Tools/EditTools.cs create mode 100644 Tools/FileTools.cs create mode 100755 installer.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..503d513 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +bin +obj +.vscode +publish \ No newline at end of file diff --git a/AnchorCli.csproj b/AnchorCli.csproj new file mode 100644 index 0000000..371ec96 --- /dev/null +++ b/AnchorCli.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + AnchorCli + enable + enable + true + true + true + anchor + + + + + + + + + + diff --git a/AnchorConfig.cs b/AnchorConfig.cs new file mode 100644 index 0000000..da15967 --- /dev/null +++ b/AnchorConfig.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AnchorCli; + +internal sealed class AnchorConfig +{ + public string ApiKey { get; set; } = ""; + public string Model { get; set; } = "qwen/qwen3.5-27b"; + + // ── Persistence ────────────────────────────────────────────────────── + + private static string ConfigPath => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "anchor", "config.json"); + + public static AnchorConfig Load() + { + try + { + if (File.Exists(ConfigPath)) + { + var json = File.ReadAllText(ConfigPath); + return JsonSerializer.Deserialize(json, AppJsonContext.Default.AnchorConfig) + ?? new AnchorConfig(); + } + } + catch { /* fall through to defaults */ } + + return new AnchorConfig(); + } + + public void Save() + { + Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath)!); + var json = JsonSerializer.Serialize(this, AppJsonContext.Default.AnchorConfig); + File.WriteAllText(ConfigPath, json); + } +} diff --git a/AppJsonContext.cs b/AppJsonContext.cs new file mode 100644 index 0000000..39f19c8 --- /dev/null +++ b/AppJsonContext.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using AnchorCli.OpenRouter; + +namespace AnchorCli; + +/// +/// Source-generated JSON serializer context for Native AOT compatibility. +/// Covers all parameter / return types used by AIFunction tool methods +/// and the OpenRouter models API. +/// +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(string[]))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(string[][]))] +[JsonSerializable(typeof(ModelsResponse))] +[JsonSerializable(typeof(ModelInfo))] +[JsonSerializable(typeof(ModelPricing))] +[JsonSerializable(typeof(AnchorConfig))] +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +internal partial class AppJsonContext : JsonSerializerContext; diff --git a/Commands/ClearCommand.cs b/Commands/ClearCommand.cs new file mode 100644 index 0000000..6a623be --- /dev/null +++ b/Commands/ClearCommand.cs @@ -0,0 +1,14 @@ +using Spectre.Console; +namespace AnchorCli.Commands; + +public class ClearCommand : ICommand +{ + public string Name => "clear"; + public string Description => "Clear the terminal screen"; + + public Task ExecuteAsync(string[] args, CancellationToken ct) + { + AnsiConsole.Clear(); + return Task.CompletedTask; + } +} diff --git a/Commands/CommandDispatcher.cs b/Commands/CommandDispatcher.cs new file mode 100644 index 0000000..007cb58 --- /dev/null +++ b/Commands/CommandDispatcher.cs @@ -0,0 +1,31 @@ +using Spectre.Console; +namespace AnchorCli.Commands; + +public class CommandDispatcher +{ + private readonly CommandRegistry _registry; + + public CommandDispatcher(CommandRegistry registry) + { + _registry = registry; + } + + public async Task TryExecuteAsync(string input, CancellationToken ct) + { + if (!input.StartsWith('/')) return false; + + var parts = input[1..].Split(' ', 2); + var commandName = parts[0]; + var args = parts.Length > 1 ? new[] { parts[1] } : Array.Empty(); + + var command = _registry.GetCommand(commandName); + if (command == null) + { + AnsiConsole.MarkupLine($"[red]Unknown command: /{commandName}[/]"); + return true; + } + + await command.ExecuteAsync(args, ct); + return true; + } +} diff --git a/Commands/CommandRegistry.cs b/Commands/CommandRegistry.cs new file mode 100644 index 0000000..8293aa6 --- /dev/null +++ b/Commands/CommandRegistry.cs @@ -0,0 +1,18 @@ +namespace AnchorCli.Commands; + +public class CommandRegistry +{ + private readonly Dictionary _commands = new(StringComparer.OrdinalIgnoreCase); + + public void Register(ICommand command) + { + _commands[command.Name] = command; + } + + public ICommand? GetCommand(string name) + { + return _commands.TryGetValue(name, out var cmd) ? cmd : null; + } + + public IEnumerable GetAllCommands() => _commands.Values; +} diff --git a/Commands/CompactCommand.cs b/Commands/CompactCommand.cs new file mode 100644 index 0000000..b838c04 --- /dev/null +++ b/Commands/CompactCommand.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.AI; +using Spectre.Console; + +namespace AnchorCli.Commands; + +internal class CompactCommand(ContextCompactor compactor, List history) : ICommand +{ + public string Name => "compact"; + public string Description => "Manually compact conversation history via LLM summarization"; + + public async Task ExecuteAsync(string[] args, CancellationToken ct) + { + if (history.Count < 5) + { + AnsiConsole.MarkupLine("[dim grey]Nothing to compact (too few messages).[/]"); + return; + } + + AnsiConsole.MarkupLine("[yellow]⚠ Manually compacting conversation history...[/]"); + + bool compacted = await AnsiConsole.Status() + .Spinner(Spinner.Known.BouncingBar) + .SpinnerStyle(Style.Parse("yellow")) + .StartAsync("Compacting context...", async ctx => + await compactor.TryCompactAsync(history, ct)); + + if (compacted) + { + AnsiConsole.MarkupLine( + $"[green]✓ Context compacted ({history.Count} messages remaining)[/]"); + } + else + { + AnsiConsole.MarkupLine( + "[dim grey] (compaction skipped — not enough history to compress)[/]"); + } + } +} diff --git a/Commands/ExitCommand.cs b/Commands/ExitCommand.cs new file mode 100644 index 0000000..f14ff91 --- /dev/null +++ b/Commands/ExitCommand.cs @@ -0,0 +1,15 @@ +using Spectre.Console; +namespace AnchorCli.Commands; + +public class ExitCommand : ICommand +{ + public string Name => "exit"; + public string Description => "Exit the application"; + + public Task ExecuteAsync(string[] args, CancellationToken ct) + { + AnsiConsole.MarkupLine("[green]Goodbye![/]"); + Environment.Exit(0); + return Task.CompletedTask; + } +} diff --git a/Commands/HelpCommand.cs b/Commands/HelpCommand.cs new file mode 100644 index 0000000..7789149 --- /dev/null +++ b/Commands/HelpCommand.cs @@ -0,0 +1,33 @@ +using Spectre.Console; +using System.Linq; +namespace AnchorCli.Commands; + +public class HelpCommand : ICommand +{ + private readonly CommandRegistry _registry; + + public string Name => "help"; + public string Description => "Show available commands"; + + public HelpCommand(CommandRegistry registry) + { + _registry = registry; + } + + public Task ExecuteAsync(string[] args, CancellationToken ct) + { + AnsiConsole.MarkupLine("[cyan]Available commands:[/]"); + + var table = new Table(); + table.AddColumn("Command"); + table.AddColumn("Description"); + + foreach (var cmd in _registry.GetAllCommands().OrderBy(c => c.Name)) + { + table.AddRow($"[bold]{cmd.Name}[/]", cmd.Description); + } + + AnsiConsole.Write(table); + return Task.CompletedTask; + } +} diff --git a/Commands/ICommand.cs b/Commands/ICommand.cs new file mode 100644 index 0000000..c4f3e46 --- /dev/null +++ b/Commands/ICommand.cs @@ -0,0 +1,8 @@ +namespace AnchorCli.Commands; + +public interface ICommand +{ + string Name { get; } + string Description { get; } + Task ExecuteAsync(string[] args, CancellationToken ct); +} diff --git a/Commands/StatusCommand.cs b/Commands/StatusCommand.cs new file mode 100644 index 0000000..de7f3a6 --- /dev/null +++ b/Commands/StatusCommand.cs @@ -0,0 +1,34 @@ +using Spectre.Console; +namespace AnchorCli.Commands; + +public class StatusCommand : ICommand +{ + private readonly string _model; + private readonly string _endpoint; + + public StatusCommand(string model, string endpoint) + { + _model = model; + _endpoint = endpoint; + } + + public string Name => "status"; + public string Description => "Show current settings (model, endpoint, working directory)"; + + public Task ExecuteAsync(string[] args, CancellationToken ct) + { + var table = new Table() + .Border(TableBorder.Rounded) + .BorderColor(Color.Grey) + .AddColumn(new TableColumn("[dim]Setting[/]").NoWrap()) + .AddColumn(new TableColumn("[dim]Value[/]")); + + table.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(_model)}[/]"); + table.AddRow("[grey]Endpoint[/]", $"[blue]{Markup.Escape(_endpoint)}[/]"); + table.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDirectory)}[/]"); + + AnsiConsole.Write(table); + AnsiConsole.WriteLine(); + return Task.CompletedTask; + } +} diff --git a/ContextCompactor.cs b/ContextCompactor.cs new file mode 100644 index 0000000..cadd9ab --- /dev/null +++ b/ContextCompactor.cs @@ -0,0 +1,157 @@ +using Microsoft.Extensions.AI; +using System.Text.RegularExpressions; +using Spectre.Console; + +namespace AnchorCli; + +/// +/// Compacts the chat history when context usage grows too large. +/// Asks the same model to summarize the conversation, then replaces +/// the history with [system prompt, summary, last N user/assistant turns]. +/// +internal sealed partial class ContextCompactor(IChatClient client) +{ + /// Number of recent user+assistant turn pairs to keep verbatim. + private const int KeepRecentTurns = 2; + + /// Minimum result length to consider for compaction. + private const int MinResultLength = 300; + + /// Matches hashline-encoded output: "lineNumber:hash|content" + private static readonly Regex HashlinePattern = + MyRegex(); + + private readonly IChatClient _client = client; + + /// + /// Compacts large hashline-encoded tool results from previous turns. + /// This is the cheapest and most impactful optimization — no LLM call needed. + /// A 300-line ReadFile result (~10K tokens) becomes a one-line note (~20 tokens). + /// + /// The chat history to compact in-place. + /// + /// Index of the first message added during the current turn. + /// Messages before this index are from previous turns and eligible for compaction. + /// + /// Number of tool results that were compacted. + public static int CompactStaleToolResults(List history, int currentTurnStartIndex) + { + int compacted = 0; + + for (int i = 0; i < currentTurnStartIndex && i < history.Count; i++) + { + var msg = history[i]; + + foreach (var content in msg.Contents) + { + if (content is FunctionResultContent frc && + frc.Result is string resultStr && + resultStr.Length >= MinResultLength && + HashlinePattern.IsMatch(resultStr)) + { + // Count lines and replace with compact summary + int lineCount = resultStr.Count(c => c == '\n'); + frc.Result = $"[File content: {lineCount} lines — already consumed. Re-read the file if you need fresh anchors.]"; + compacted++; + } + } + } + + return compacted; + } + + /// + /// Compacts the history in-place via LLM summarization. Returns true if compaction was performed. + /// + public async Task TryCompactAsync( + List history, + CancellationToken ct = default) + { + // Need at least: system + some conversation to compact + if (history.Count < 5) + return false; + + // Split: system prompt (index 0) | middle (compactable) | tail (keep verbatim) + var systemMsg = history[0]; + + // Find the cut point: keep the last N user+assistant pairs + int keepFromIndex = FindKeepIndex(history); + if (keepFromIndex <= 1) + return false; // Nothing to compact + + // Extract the middle section to summarize + var toSummarize = history.Skip(1).Take(keepFromIndex - 1).ToList(); + var tail = history.Skip(keepFromIndex).ToList(); + + // Build a summarization prompt + var summaryMessages = new List + { + new(ChatRole.System, """ + You are a conversation summarizer. Summarize the following coding conversation + between a user and an AI assistant. Focus on: + - What files were read, created, or modified (and their paths) + - What changes were made and why + - Any decisions or preferences expressed by the user + - Current state of the work (what's done, what's pending) + Be concise but preserve all actionable context. Output a single summary paragraph. + Do NOT include any hashline anchors (lineNumber:hash|) in your summary. + """) + }; + summaryMessages.AddRange(toSummarize); + summaryMessages.Add(new(ChatRole.User, + "Summarize the above conversation concisely, preserving all important context for continuing the work.")); + + string summary; + try + { + var response = await _client.GetResponseAsync(summaryMessages, cancellationToken: ct); + summary = response.Text ?? "(summary unavailable)"; + } + catch + { + // If summarization fails, don't compact + return false; + } + + // Rebuild history: system + summary + recent turns + history.Clear(); + history.Add(systemMsg); + history.Add(new(ChatRole.User, + "[CONTEXT COMPACTED — The following is a summary of the earlier conversation]\n" + summary)); + history.Add(new(ChatRole.Assistant, + "Understood, I have the context from our earlier conversation. I'll continue from where we left off.")); + history.AddRange(tail); + + return true; + } + + /// + /// Finds the index from which to keep messages verbatim (the last N turn pairs). + /// + private static int FindKeepIndex(List history) + { + int pairsFound = 0; + int idx = history.Count - 1; + + while (idx > 0 && pairsFound < KeepRecentTurns) + { + // Walk backwards looking for user+assistant pairs + if (history[idx].Role == ChatRole.Assistant && idx > 1 && + history[idx - 1].Role == ChatRole.User) + { + pairsFound++; + idx -= 2; + } + else + { + idx--; + } + } + + // idx+1 is the first message to keep + return Math.Max(1, idx + 1); + } + + [GeneratedRegex(@"^\d+:[0-9a-fA-F]{2}\|", RegexOptions.Multiline | RegexOptions.Compiled)] + private static partial Regex MyRegex(); +} diff --git a/Hashline/HashlineEncoder.cs b/Hashline/HashlineEncoder.cs new file mode 100644 index 0000000..b62fcc5 --- /dev/null +++ b/Hashline/HashlineEncoder.cs @@ -0,0 +1,84 @@ +namespace AnchorCli.Hashline; + +/// +/// Encodes file lines with Hashline anchors in the format: {lineNumber}:{hash}|{content} +/// +/// Hash algorithm: Adler-8 with a position seed. +/// raw = ((sum_of_bytes % 251) ^ (count_of_bytes % 251)) & 0xFF +/// final = raw ^ (lineNumber & 0xFF) +/// +/// The position seed ensures duplicate lines produce different tags, +/// preventing ambiguous anchor resolution on large files. +/// +internal static class HashlineEncoder +{ + /// + /// Computes the 2-char hex Hashline tag for a single line. + /// + /// Raw line content (without newline). + /// 1-indexed line number within the file. + public static string ComputeHash(ReadOnlySpan line, int lineNumber) + { + int sum = 0; + int count = line.Length; + + foreach (char c in line) + { + // Use the lower byte of each character — sufficient for source code + sum += c & 0xFF; + } + + int raw = ((sum % 251) ^ (count % 251)) & 0xFF; + int final = raw ^ (lineNumber & 0xFF); + + return final.ToString("x2"); + } + + /// + /// Encodes all lines with Hashline prefixes. + /// + /// All lines of the file (without trailing newlines). + /// A single string with each line prefixed as "{n}:{hash}|{content}\n". + public static string Encode(string[] lines) => Encode(lines, startLine: 1, endLine: lines.Length); + + /// + /// Encodes a window of lines with Hashline prefixes. + /// Line numbers in the output reflect their true position in the file. + /// + /// All lines of the file. + /// 1-indexed first line to include (inclusive). + /// 1-indexed last line to include (inclusive). Clamped to file length. + public static string Encode(string[] lines, int startLine, int endLine) + { + if (lines.Length == 0) return string.Empty; + + startLine = Math.Max(1, startLine); + endLine = Math.Min(lines.Length, endLine <= 0 ? lines.Length : endLine); + + var sb = new System.Text.StringBuilder(); + + for (int i = startLine; i <= endLine; i++) + { + string line = lines[i - 1]; + string hash = ComputeHash(line.AsSpan(), i); + sb.Append(i).Append(':').Append(hash).Append('|').AppendLine(line); + } + + return sb.ToString(); + } + + /// + /// Computes a short file-level fingerprint: XOR of all per-line hashes (as bytes). + /// Useful for cheap full-file staleness checks. + /// + public static string FileFingerprint(string[] lines) + { + int fp = 0; + for (int i = 0; i < lines.Length; i++) + { + string hash = ComputeHash(lines[i].AsSpan(), i + 1); + fp ^= Convert.ToInt32(hash, 16); + } + return fp.ToString("x2"); + } +} diff --git a/Hashline/HashlineValidator.cs b/Hashline/HashlineValidator.cs new file mode 100644 index 0000000..baca6ba --- /dev/null +++ b/Hashline/HashlineValidator.cs @@ -0,0 +1,113 @@ +namespace AnchorCli.Hashline; + +/// +/// Validates and resolves Hashline anchors of the form "{lineNumber}:{hash}" against +/// the current state of a file's lines. +/// +/// An anchor is valid if and only if BOTH conditions hold: +/// 1. The referenced line number exists in the file. +/// 2. The hash at that line number matches what the encoder would produce today. +/// +/// This dual-check makes stale edits fail loudly rather than silently corrupting a +/// different line that happens to share the same hash. +/// +internal static class HashlineValidator +{ + /// + /// Parses a "{lineNumber}:{hash}" anchor string. + /// + /// True and the parsed values if the format is valid; false otherwise. + public static bool TryParseAnchor(string anchor, out int lineNumber, out string hash) + { + lineNumber = 0; + hash = string.Empty; + + int colonIndex = anchor.IndexOf(':'); + if (colonIndex <= 0 || colonIndex == anchor.Length - 1) + return false; + + ReadOnlySpan numPart = anchor.AsSpan(0, colonIndex); + ReadOnlySpan hashPart = anchor.AsSpan(colonIndex + 1); + + if (!int.TryParse(numPart, out lineNumber) || lineNumber < 1) + return false; + + hash = hashPart.ToString(); + return hash.Length == 2; + } + + /// + /// Resolves an anchor to a 0-based index into , + /// verifying both the line number and the content hash. + /// + /// Anchor string, e.g. "5:a3". + /// Current file lines (without newlines). + /// Resolved 0-based index on success. + /// Human-readable error message on failure. + /// True if the anchor is valid and current; false otherwise. + public static bool TryResolve( + string anchor, + string[] lines, + out int zeroBasedIndex, + out string error) + { + zeroBasedIndex = -1; + error = string.Empty; + + if (!TryParseAnchor(anchor, out int lineNumber, out string expectedHash)) + { + error = $"Anchor '{anchor}' is not in 'lineNumber:hash' format (e.g. '5:a3')."; + return false; + } + + if (lineNumber < 1 || lineNumber > lines.Length) + { + error = $"Anchor '{anchor}': line {lineNumber} is out of range " + + $"(file has {lines.Length} line(s))."; + return false; + } + + string actualHash = HashlineEncoder.ComputeHash(lines[lineNumber - 1].AsSpan(), lineNumber); + if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase)) + { + error = $"Anchor '{anchor}': hash mismatch at line {lineNumber} " + + $"(expected '{expectedHash}', got '{actualHash}'). " + + $"The file has changed — re-read before editing."; + return false; + } + + zeroBasedIndex = lineNumber - 1; + return true; + } + + /// + /// Validates both a start and end anchor, and ensures start <= end. + /// + public static bool TryResolveRange( + string startAnchor, + string endAnchor, + string[] lines, + out int startIndex, + out int endIndex, + out string error) + { + startIndex = -1; + endIndex = -1; + error = string.Empty; + + if (!TryResolve(startAnchor, lines, out startIndex, out error)) + return false; + + if (!TryResolve(endAnchor, lines, out endIndex, out error)) + return false; + + if (startIndex > endIndex) + { + error = $"Range error: start anchor '{startAnchor}' (line {startIndex + 1}) " + + $"is after end anchor '{endAnchor}' (line {endIndex + 1})."; + return false; + } + + return true; + } +} diff --git a/IDEAS.md b/IDEAS.md new file mode 100644 index 0000000..3d5e11f --- /dev/null +++ b/IDEAS.md @@ -0,0 +1,85 @@ +# Command Ideas for AnchorCli + +## Basic Commands + +### `/help` +Display a list of available commands and their descriptions. + +### `/clear` +Clear the terminal screen. + +### `/history` +Show the current chat history. + +### `/reset` +Clear the conversation history and start fresh. + +### `/status` +Show current settings (model, endpoint, working directory). + +## Navigation Commands + +### `/cwd` or `/pwd` +Show current working directory. + +### `/cd ` +Change the current working directory. + +### `/ls` or `/dir` +List files in the current directory (alias for ListDir). + +## Model/Config Commands + +### `/model ` +Change the AI model being used. + +### `/endpoint ` +Change the API endpoint. + +### `/config` +Show or edit configuration settings. + +## File Operations + +### `/save ` +Save the current conversation to a file. + +### `/load ` +Load a previous conversation from a file. + +### `/export ` +Export chat history in a different format (JSON, markdown). + +## Utility Commands + +### `/version` +Show AnchorCli version information. + +### `/about` +Show information about the tool. + +### `/tools` +List available AI tools and their capabilities. + +### `/debug` +Toggle debug mode for more verbose output. + +## Advanced Ideas + +### `/undo` +Undo the last file edit (would require edit history tracking). + +### `/diff ` +Show differences between current and original file state. + +### `/search ` +Quick file search across the project. + +### `/stats` +Show statistics (files edited, commands run, etc.). + +### `/alias ` +Create custom command aliases. + +### `/macro ` +Create and run multi-step macros. diff --git a/OpenRouter/ModelInfo.cs b/OpenRouter/ModelInfo.cs new file mode 100644 index 0000000..62802f5 --- /dev/null +++ b/OpenRouter/ModelInfo.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Serialization; + +namespace AnchorCli.OpenRouter; + +/// +/// Represents the response from OpenRouter's /api/v1/models endpoint. +/// +internal sealed class ModelsResponse +{ + [JsonPropertyName("data")] + public List Data { get; set; } = []; +} + +/// +/// A single model entry from the OpenRouter API. +/// +internal sealed class ModelInfo +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("pricing")] + public ModelPricing? Pricing { get; set; } + + [JsonPropertyName("context_length")] + public int ContextLength { get; set; } +} + +/// +/// Pricing info for a model. All values are USD per token (as strings). +/// +internal sealed class ModelPricing +{ + /// USD per input token. + [JsonPropertyName("prompt")] + public string Prompt { get; set; } = "0"; + + /// USD per output token. + [JsonPropertyName("completion")] + public string Completion { get; set; } = "0"; + + /// Fixed USD cost per API request. + [JsonPropertyName("request")] + public string Request { get; set; } = "0"; +} diff --git a/OpenRouter/PricingProvider.cs b/OpenRouter/PricingProvider.cs new file mode 100644 index 0000000..3a28f16 --- /dev/null +++ b/OpenRouter/PricingProvider.cs @@ -0,0 +1,52 @@ +using System.Globalization; +using System.Net.Http.Json; +using System.Text.Json; + +namespace AnchorCli.OpenRouter; + +/// +/// Fetches and caches model pricing from the OpenRouter API. +/// +internal sealed class PricingProvider +{ + private const string ModelsUrl = "https://openrouter.ai/api/v1/models"; + + private static readonly HttpClient Http = new(); + private Dictionary? _models; + + /// + /// Fetches the full model list from OpenRouter (cached after first call). + /// + public async Task> GetAllModelsAsync( + CancellationToken ct = default) + { + if (_models != null) return _models; + + var response = await Http.GetAsync(ModelsUrl, ct); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(ct); + var result = JsonSerializer.Deserialize(json, AppJsonContext.Default.ModelsResponse); + + _models = result?.Data?.ToDictionary(m => m.Id) ?? []; + return _models; + } + + /// + /// Looks up pricing for a specific model ID. Returns null if not found. + /// + public async Task GetModelInfoAsync( + string modelId, CancellationToken ct = default) + { + var models = await GetAllModelsAsync(ct); + return models.GetValueOrDefault(modelId); + } + + /// + /// Parses a pricing string (USD per token) to decimal. Returns 0 on failure. + /// + public static decimal ParsePrice(string? priceStr) => + decimal.TryParse(priceStr, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) + ? v + : 0m; +} diff --git a/OpenRouter/TokenTracker.cs b/OpenRouter/TokenTracker.cs new file mode 100644 index 0000000..1fd4ff5 --- /dev/null +++ b/OpenRouter/TokenTracker.cs @@ -0,0 +1,80 @@ +namespace AnchorCli.OpenRouter; + +/// +/// Tracks token usage and calculates costs for the session. +/// +internal sealed class TokenTracker +{ + public long SessionInputTokens { get; private set; } + public long SessionOutputTokens { get; private set; } + public int RequestCount { get; private set; } + + /// Maximum context window for the model (tokens). 0 = unknown. + public int ContextLength { get; set; } + + /// Input tokens from the most recent API response — approximates current context size. + public int LastInputTokens { get; private set; } + + /// USD per input token. + public decimal InputPrice { get; set; } + + /// USD per output token. + public decimal OutputPrice { get; set; } + + /// Fixed USD per API request. + public decimal RequestPrice { get; set; } + + /// + /// Record usage from one response (may span multiple LLM rounds). + /// + public void AddUsage(int inputTokens, int outputTokens) + { + SessionInputTokens += inputTokens; + SessionOutputTokens += outputTokens; + LastInputTokens = inputTokens; + RequestCount++; + } + + /// + /// Returns true if the context is getting too large and should be compacted. + /// Triggers at min(75% of model context, 100K tokens). + /// + public bool ShouldCompact() + { + if (LastInputTokens <= 0) return false; + + int threshold = ContextLength > 0 + ? Math.Min((int)(ContextLength * 0.75), 100_000) + : 100_000; + + return LastInputTokens >= threshold; + } + + /// Context usage as a percentage (0-100). Returns -1 if context length is unknown. + public double ContextUsagePercent => + ContextLength > 0 && LastInputTokens > 0 + ? (double)LastInputTokens / ContextLength * 100.0 + : -1; + + /// + /// Calculate cost for a single response. + /// + public decimal CalculateCost(int inputTokens, int outputTokens) => + inputTokens * InputPrice + + outputTokens * OutputPrice + + RequestPrice; + + /// + /// Total session cost. + /// + public decimal SessionCost => + SessionInputTokens * InputPrice + + SessionOutputTokens * OutputPrice + + RequestCount * RequestPrice; + + public static string FormatTokens(long count) => + count >= 1_000 ? $"{count / 1_000.0:F1}k" : count.ToString("N0"); + + public static string FormatCost(decimal cost) => + cost < 0.01m ? $"${cost:F4}" : $"${cost:F2}"; +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..f112982 --- /dev/null +++ b/Program.cs @@ -0,0 +1,366 @@ +using System.ClientModel; +using Microsoft.Extensions.AI; +using OpenAI; +using AnchorCli; +using AnchorCli.Tools; +using AnchorCli.Commands; +using AnchorCli.OpenRouter; +using Spectre.Console; + +// ── Setup subcommand ───────────────────────────────────────────────────── +if (args.Length > 0 && args[0].Equals("setup", StringComparison.OrdinalIgnoreCase)) +{ + SetupTui.Run(); + return; +} + +// ── Config ────────────────────────────────────────────────────────────── +const string endpoint = "https://openrouter.ai/api/v1"; +var cfg = AnchorConfig.Load(); +string apiKey = cfg.ApiKey; +string model = cfg.Model; + +if (string.IsNullOrWhiteSpace(apiKey)) +{ + AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]"); + return; +} + +// ── Fetch model pricing from OpenRouter ───────────────────────────────── +var pricingProvider = new PricingProvider(); +var tokenTracker = new TokenTracker(); + +ModelInfo? modelInfo = null; +await AnsiConsole.Status() + .Spinner(Spinner.Known.BouncingBar) + .SpinnerStyle(Style.Parse("cornflowerblue")) + .StartAsync("Fetching model pricing...", async ctx => + { + try + { + modelInfo = await pricingProvider.GetModelInfoAsync(model); + if (modelInfo?.Pricing != null) + { + tokenTracker.InputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Prompt); + tokenTracker.OutputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Completion); + tokenTracker.RequestPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Request); + } + } + catch { /* pricing is best-effort */ } + }); + +// ── Pretty header ─────────────────────────────────────────────────────── +AnsiConsole.Write( + new FigletText("anchor") + .Color(Color.CornflowerBlue)); + +AnsiConsole.Write( + new Rule("[dim]AI-powered coding assistant[/]") + .RuleStyle(Style.Parse("cornflowerblue dim")) + .LeftJustified()); + +AnsiConsole.WriteLine(); + +var infoTable = new Table() + .Border(TableBorder.Rounded) + .BorderColor(Color.Grey) + .AddColumn(new TableColumn("[dim]Setting[/]").NoWrap()) + .AddColumn(new TableColumn("[dim]Value[/]")); + +infoTable.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(modelInfo?.Name ?? model)}[/]"); +infoTable.AddRow("[grey]Endpoint[/]", $"[blue]OpenRouter[/]"); +infoTable.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDirectory)}[/]"); + +if (modelInfo?.Pricing != null) +{ + var inM = tokenTracker.InputPrice * 1_000_000m; + var outM = tokenTracker.OutputPrice * 1_000_000m; + infoTable.AddRow("[grey]Pricing[/]", + $"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]"); +} +if (modelInfo != null) +{ + infoTable.AddRow("[grey]Context[/]", + $"[dim]{modelInfo.ContextLength:N0} tokens[/]"); +} + +AnsiConsole.Write(infoTable); +AnsiConsole.WriteLine(); + +// ── Build the chat client with tool-calling support ───────────────────── +var openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions +{ + Endpoint = new Uri(endpoint) +}); + +IChatClient innerClient = openAiClient.GetChatClient(model).AsIChatClient(); + +// ── Tool call logging via Spectre ─────────────────────────────────────── +static void ToolLog(string message) +{ + AnsiConsole.MarkupLine($"[dim grey] ● {Markup.Escape(message)}[/]"); +} + +CommandTool.Log = +DirTools.Log = +FileTools.Log = +EditTools.Log = ToolLog; + +// ── Collect all tool methods ──────────────────────────────────────────── +var jsonOptions = AppJsonContext.Default.Options; + +var tools = new List +{ + AIFunctionFactory.Create(FileTools.ReadFile, serializerOptions: jsonOptions), + AIFunctionFactory.Create(FileTools.GrepFile, serializerOptions: jsonOptions), + AIFunctionFactory.Create(FileTools.ListDir, serializerOptions: jsonOptions), + AIFunctionFactory.Create(EditTools.ReplaceLines, serializerOptions: jsonOptions), + AIFunctionFactory.Create(EditTools.InsertAfter, serializerOptions: jsonOptions), + AIFunctionFactory.Create(EditTools.DeleteRange, serializerOptions: jsonOptions), + AIFunctionFactory.Create(EditTools.CreateFile, serializerOptions: jsonOptions), + AIFunctionFactory.Create(EditTools.DeleteFile, serializerOptions: jsonOptions), + AIFunctionFactory.Create(EditTools.RenameFile, serializerOptions: jsonOptions), + AIFunctionFactory.Create(EditTools.CopyFile, serializerOptions: jsonOptions), + AIFunctionFactory.Create(DirTools.CreateDir, serializerOptions: jsonOptions), + AIFunctionFactory.Create(DirTools.RenameDir, serializerOptions: jsonOptions), + AIFunctionFactory.Create(DirTools.DeleteDir, serializerOptions: jsonOptions), + AIFunctionFactory.Create(FileTools.FindFiles, serializerOptions: jsonOptions), + AIFunctionFactory.Create(FileTools.GrepRecursive, serializerOptions: jsonOptions), + AIFunctionFactory.Create(FileTools.GetFileInfo, serializerOptions: jsonOptions), + AIFunctionFactory.Create(EditTools.AppendToFile, serializerOptions: jsonOptions), + AIFunctionFactory.Create(CommandTool.ExecuteCommand, serializerOptions: jsonOptions), +}; + +// Wrap with automatic function invocation +IChatClient agent = new ChatClientBuilder(innerClient) + .UseFunctionInvocation() + .Build(); + +// ── Context compactor ────────────────────────────────────────────────── +var compactor = new ContextCompactor(innerClient); + +if (modelInfo != null) + tokenTracker.ContextLength = modelInfo.ContextLength; + +// ── Chat history with system prompt ───────────────────────────────────── +List history = +[ + new(ChatRole.System, """ + You are anchor, a coding assistant that edits files using the Hashline technique. + + ## Reading files + When you read a file, lines are returned in the format: lineNumber:hash|content + The "lineNumber:hash|" prefix is METADATA for anchoring — it is NOT part of the file. + + ## Editing files + To edit, reference anchors as "lineNumber:hash" in startAnchor/endAnchor parameters. + The newLines/initialLines parameter must contain RAW SOURCE CODE ONLY. + ❌ WRONG: ["5:a3| public void Foo()"] + ✅ RIGHT: [" public void Foo()"] + Never include the "lineNumber:hash|" prefix in content you write — it will corrupt the file. + + ## Workflow + 1. Always read a file before editing it. + 2. After a mutation, verify the returned fingerprint. + 3. Edit from bottom to top so line numbers don't shift. + 4. If an anchor fails validation, re-read the file to get fresh anchors. + + Keep responses concise. You have access to the current working directory. + """) +]; + +// ── Command system ───────────────────────────────────────────────────── +var commandRegistry = new CommandRegistry(); +commandRegistry.Register(new ExitCommand()); +commandRegistry.Register(new HelpCommand(commandRegistry)); +commandRegistry.Register(new ClearCommand()); +commandRegistry.Register(new StatusCommand(model, endpoint)); +commandRegistry.Register(new CompactCommand(compactor, history)); + +var commandDispatcher = new CommandDispatcher(commandRegistry); + + +// ── REPL ──────────────────────────────────────────────────────────────── +AnsiConsole.MarkupLine("[dim]Type your message, or use [bold]/help[/] to see commands.[/]"); +AnsiConsole.MarkupLine("[dim]Press [bold]Ctrl+C[/] to cancel the current response.[/]"); +AnsiConsole.WriteLine(); + +// Ctrl+C cancellation: cancel the current response, not the process +CancellationTokenSource? responseCts = null; + +Console.CancelKeyPress += (_, e) => +{ + e.Cancel = true; // Prevent process termination + responseCts?.Cancel(); +}; + +while (true) +{ + string input = ReadLine.Read("❯ "); + + if (string.IsNullOrWhiteSpace(input)) continue; + + // Try to execute slash command + if (await commandDispatcher.TryExecuteAsync(input, default)) continue; + + history.Add(new ChatMessage(ChatRole.User, input)); + + // Track where this turn starts so we can compact previous turns' tool results + int turnStartIndex = history.Count; + + AnsiConsole.WriteLine(); + + // Create a fresh CancellationTokenSource for this response + responseCts = new CancellationTokenSource(); + string fullResponse = ""; + + try + { + var options = new ChatOptions { Tools = tools }; + + // Get the async enumerator so we can split into spinner + streaming phases + await using var stream = agent + .GetStreamingResponseAsync(history, options, responseCts.Token) + .GetAsyncEnumerator(responseCts.Token); + + string? firstChunk = null; + int respIn = 0, respOut = 0; + + // Helper: extract usage from a streaming update's raw OpenAI representation + void CaptureUsage(ChatResponseUpdate update) + { + if (update.RawRepresentation is OpenAI.Chat.StreamingChatCompletionUpdate raw + && raw.Usage != null) + { + respIn += raw.Usage.InputTokenCount; + respOut += raw.Usage.OutputTokenCount; + } + } + + // Phase 1: Show BouncingBar spinner while agent thinks & invokes tools + await AnsiConsole.Status() + .Spinner(Spinner.Known.BouncingBar) + .SpinnerStyle(Style.Parse("cornflowerblue")) + .StartAsync("Thinking...", async ctx => + { + while (await stream.MoveNextAsync()) + { + responseCts.Token.ThrowIfCancellationRequested(); + CaptureUsage(stream.Current); + if (!string.IsNullOrEmpty(stream.Current.Text)) + { + firstChunk = stream.Current.Text; + fullResponse = firstChunk; + return; // Break out → stops the spinner + } + } + }); + + // Phase 2: Stream text tokens directly to the console + if (firstChunk != null) + { + AnsiConsole.Markup(Markup.Escape(firstChunk)); + } + + while (await stream.MoveNextAsync()) + { + responseCts.Token.ThrowIfCancellationRequested(); + CaptureUsage(stream.Current); + var text = stream.Current.Text; + if (!string.IsNullOrEmpty(text)) + { + AnsiConsole.Markup(Markup.Escape(text)); + } + fullResponse += text; + } + + // Record usage and display cost + if (respIn > 0 || respOut > 0) + { + tokenTracker.AddUsage(respIn, respOut); + var cost = tokenTracker.CalculateCost(respIn, respOut); + var ctxPct = tokenTracker.ContextUsagePercent; + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine( + $"[dim grey] {TokenTracker.FormatTokens(respIn)}↑ {TokenTracker.FormatTokens(respOut)}↓" + + $" {TokenTracker.FormatCost(cost)}" + + (ctxPct >= 0 ? $" ctx:{ctxPct:F0}%" : "") + + $" │ session: {TokenTracker.FormatCost(tokenTracker.SessionCost)}[/]"); + } + else + { + AnsiConsole.WriteLine(); + } + + AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim"))); + AnsiConsole.WriteLine(); + + history.Add(new ChatMessage(ChatRole.Assistant, fullResponse)); + + // ── Compact stale ReadFile/Grep results from previous turns ─ + int compactedResults = ContextCompactor.CompactStaleToolResults(history, turnStartIndex); + if (compactedResults > 0) + { + AnsiConsole.MarkupLine( + $"[dim grey] ♻ Compacted {compactedResults} stale tool result(s) from previous turns[/]"); + } + + // ── Auto-compact context if approaching the limit ─────────── + if (tokenTracker.ShouldCompact()) + { + var pct = tokenTracker.ContextUsagePercent; + AnsiConsole.MarkupLine( + $"[yellow]⚠ Context at {pct:F0}% — compacting conversation history...[/]"); + + bool compacted = await AnsiConsole.Status() + .Spinner(Spinner.Known.BouncingBar) + .SpinnerStyle(Style.Parse("yellow")) + .StartAsync("Compacting context...", async ctx => + await compactor.TryCompactAsync(history, default)); + + if (compacted) + { + AnsiConsole.MarkupLine( + $"[green]✓ Context compacted ({history.Count} messages remaining)[/]"); + } + else + { + AnsiConsole.MarkupLine( + "[dim grey] (compaction skipped — not enough history to compress)[/]"); + } + AnsiConsole.WriteLine(); + } + } + catch (OperationCanceledException) + { + // Keep partial response in history so the agent has context + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[dim grey] ✗ Cancelled[/]"); + AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim"))); + AnsiConsole.WriteLine(); + + if (!string.IsNullOrEmpty(fullResponse)) + { + history.Add(new ChatMessage(ChatRole.Assistant, fullResponse)); + } + history.Add(new ChatMessage(ChatRole.User, + "[Response cancelled by user. Acknowledge briefly and wait for the next instruction. Do not repeat what was already said.]")); + } + catch (Exception ex) + { + AnsiConsole.WriteLine(); + AnsiConsole.Write( + new Panel($"[red]{Markup.Escape(ex.Message)}[/]") + .Header("[bold red] Error [/]") + .BorderColor(Color.Red) + .RoundedBorder() + .Padding(1, 0)); + AnsiConsole.WriteLine(); + } + finally + { + responseCts?.Dispose(); + responseCts = null; + } +} + diff --git a/README.md b/README.md new file mode 100644 index 0000000..4605325 --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# AnchorCli + +An AI-powered coding assistant built as a .NET 10.0 console application, featuring the **Hashline** technique for safe, precise file editing. + +## What is Hashline? + +AnchorCli's unique approach to file editing. Every line returned by file tools is prefixed with a content-derived hash anchor: + +``` +1:a3| function hello() { +2:f1| return "world"; +3:0e| } +``` + +When editing, you reference these `line:hash` anchors instead of reproducing old content. Before any mutation, both the line number **and** hash are validated — stale anchors are rejected immediately. + +This eliminates: +- Whitespace/indentation reproduction errors +- Silent edits to the wrong line in large files +- Entire-file rewrites just to change one line + +## Features + +- **Interactive REPL**: Chat with an AI model to edit files, manage directories, and execute commands +- **Comprehensive Toolset**: 12+ tools for file operations, editing, directory management, and command execution +- **AOT-Ready**: Native AOT compilation for ~12 MB binaries with no .NET runtime dependency +- **Rich CLI**: Beautiful terminal output using Spectre.Console with tables, rules, and colored text +- **Streaming Responses**: Real-time AI response streaming in the terminal +- **OpenAI-Compatible**: Works with any OpenAI-compatible API (OpenAI, Ollama, Cerebras, Groq, OpenRouter, etc.) + +## Requirements + +- .NET 10 SDK +- `clang` (for native AOT publish on Linux) + +## Quick Start + +```bash +export ANCHOR_API_KEY=your_key_here +export ANCHOR_MODEL=qwen3.5-27b # optional, default: gpt-4o +export ANCHOR_ENDPOINT=https://api.openai.com/v1 # optional + +dotnet run --project AnchorCli +``` + +## Native AOT Build + +```bash +dotnet publish AnchorCli -r linux-x64 -c Release +./AnchorCli/bin/Release/net10.0/linux-x64/publish/anchor +``` + +The resulting binary is ~12 MB, has no .NET runtime dependency, and starts instantly. + +## Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `ANCHOR_API_KEY` | *(required)* | API key for the LLM provider | +| `ANCHOR_ENDPOINT` | `https://api.openai.com/v1` | Any OpenAI-compatible endpoint | +| `ANCHOR_MODEL` | `gpt-4o` | Model name | +| `ANCHOR_MAX_TOKENS` | `4096` | Max response tokens | + +## Available Tools + +**File Operations:** +- `read_file` - Read a file (or a window) with Hashline-tagged lines +- `grep_file` - Search a file by regex — results are pre-tagged for immediate editing +- `find_files` - Search for files matching glob patterns +- `get_file_info` - Get detailed file information (size, permissions, etc.) + +**Edit Operations:** +- `replace_lines` - Replace a range identified by `line:hash` anchors +- `insert_after` - Insert lines after an anchor +- `delete_range` - Delete a range between two anchors +- `create_file` - Create a new file with optional initial content +- `delete_file` - Delete a file permanently +- `rename_file` - Rename or move a file +- `copy_file` - Copy a file to a new location +- `append_to_file` - Append lines to the end of a file + +**Directory Operations:** +- `list_dir` - List directory contents +- `create_dir` - Create a new directory (with parent directories) +- `rename_dir` - Rename or move a directory +- `delete_dir` - Delete a directory and all its contents + +**Command Execution:** +- `execute_command` - Run shell commands (with user approval) + +## Project Structure + +``` +AnchorCli/ +├── Program.cs # Entry point + REPL loop + AI client setup +├── Config/AppConfig.cs # Environment variable configuration +├── Hashline/ +│ ├── HashlineEncoder.cs # Adler-8 + position-seed hashing +│ └── HashlineValidator.cs # Anchor resolution + validation +├── Tools/ +│ ├── FileTools.cs # read_file, grep_file, find_files, get_file_info +│ ├── EditTools.cs # replace_lines, insert_after, delete_range, create/delete/rename/copy/append +│ ├── DirTools.cs # list_dir, create_dir, rename_dir, delete_dir +│ └── CommandTool.cs # execute_command +└── Json/AppJsonContext.cs # Source-generated JSON context (AOT) +``` + +## How It Works + +1. **Setup**: The AI client is configured with your API credentials and model preferences +2. **REPL Loop**: You interact with the AI through a conversational interface +3. **Tool Calling**: The AI can call any of the available tools to read/edit files, manage directories, or execute commands +4. **Hashline Validation**: All file edits are validated using the Hashline technique to ensure precision +5. **Safe Execution**: Commands require explicit user approval before running + +## Supported Models + +AnchorCli works with any OpenAI-compatible API endpoint, including: +- OpenAI (gpt-4o, gpt-4.1, etc.) +- Ollama (local models) +- Cerebras +- Groq +- OpenRouter (qwen3.5-27b, etc.) +- Any custom OpenAI-compatible server + +## License + +MIT License - See LICENSE file for details. diff --git a/SetupTui.cs b/SetupTui.cs new file mode 100644 index 0000000..a5e3ccf --- /dev/null +++ b/SetupTui.cs @@ -0,0 +1,44 @@ +using Spectre.Console; + +namespace AnchorCli; + +internal static class SetupTui +{ + public static void Run() + { + var config = AnchorConfig.Load(); + + AnsiConsole.Write(new Rule("[cornflowerblue]anchor setup[/]").LeftJustified()); + AnsiConsole.WriteLine(); + + // ── API Key ─────────────────────────────────────────────────── + string maskedKey = config.ApiKey.Length > 8 + ? config.ApiKey[..4] + new string('*', config.ApiKey.Length - 8) + config.ApiKey[^4..] + : config.ApiKey.Length > 0 ? "****" : "[dim](not set)[/]"; + + AnsiConsole.MarkupLine($" Current API key: [dim]{Markup.Escape(maskedKey)}[/]"); + string newKey = AnsiConsole.Prompt( + new TextPrompt(" New API key [dim](Enter to keep)[/]:") + .AllowEmpty() + .Secret('*')); + + if (!string.IsNullOrWhiteSpace(newKey)) + config.ApiKey = newKey.Trim(); + + AnsiConsole.WriteLine(); + + // ── Model ───────────────────────────────────────────────────── + AnsiConsole.MarkupLine($" Current model: [cyan]{Markup.Escape(config.Model)}[/]"); + string newModel = AnsiConsole.Prompt( + new TextPrompt(" New model [dim](Enter to keep)[/]:") + .AllowEmpty()); + + if (!string.IsNullOrWhiteSpace(newModel)) + config.Model = newModel.Trim(); + + // ── Save ────────────────────────────────────────────────────── + AnsiConsole.WriteLine(); + config.Save(); + AnsiConsole.MarkupLine("[green]✓ Config saved.[/]"); + } +} diff --git a/Tools/CommandTool.cs b/Tools/CommandTool.cs new file mode 100644 index 0000000..d66f5be --- /dev/null +++ b/Tools/CommandTool.cs @@ -0,0 +1,97 @@ +using System.ComponentModel; +using System.Diagnostics; +using Spectre.Console; + +namespace AnchorCli.Tools; + +/// +/// Command execution tool with user approval. +/// +internal static class CommandTool +{ + public static Action 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}"; + } + } +} diff --git a/Tools/DirTools.cs b/Tools/DirTools.cs new file mode 100644 index 0000000..3d7816f --- /dev/null +++ b/Tools/DirTools.cs @@ -0,0 +1,86 @@ +using System; +using System.ComponentModel; + +namespace AnchorCli.Tools; + +/// +/// Directory manipulation tools exposed to the LLM as AIFunctions. +/// +internal static class DirTools +{ + public static Action 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); +} diff --git a/Tools/EditTools.cs b/Tools/EditTools.cs new file mode 100644 index 0000000..365f35b --- /dev/null +++ b/Tools/EditTools.cs @@ -0,0 +1,331 @@ +using System.ComponentModel; +using System.Text.RegularExpressions; +using AnchorCli.Hashline; + +namespace AnchorCli.Tools; + +/// +/// 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. +/// +internal static class EditTools +{ + public static Action Log { get; set; } = Console.WriteLine; + + /// + /// Matches accidental hashline prefixes the LLM may include in new content. + /// Format: "lineNumber:hexHash|" e.g. "5:a3|" + /// + private static readonly Regex HashlinePrefix = + new(@"^\d+:[0-9a-fA-F]{2}\|", RegexOptions.Compiled); + + /// + /// Strips accidental hashline prefixes from lines the LLM sends as new content. + /// Logs a warning when stripping occurs. + /// + 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(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(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(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}"; + } + } +} diff --git a/Tools/FileTools.cs b/Tools/FileTools.cs new file mode 100644 index 0000000..c25e9ed --- /dev/null +++ b/Tools/FileTools.cs @@ -0,0 +1,309 @@ +using System; +using System.ComponentModel; +using System.Text.RegularExpressions; +using AnchorCli.Hashline; +using Spectre.Console; + +namespace AnchorCli.Tools; + +/// +/// Read-only file tools exposed to the LLM as AIFunctions. +/// All methods are static with primitive parameters for AOT compatibility. +/// +internal static class FileTools +{ + public static Action 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(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}"; + } + } + + /// + /// 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. + /// + private static readonly HashSet SkipDirs = new(StringComparer.OrdinalIgnoreCase) + { + ".git", "bin", "obj", "node_modules", ".vs", "publish", ".svn", "__pycache__" + }; + + private static IEnumerable 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 + // --------------------------------------------------------------------------- + + /// + /// Resolves a path relative to the current working directory. + /// Does NOT restrict to a sandbox — anchor is a local tool running as the user. + /// + internal static string ResolvePath(string path) => + Path.IsPathRooted(path) ? path : Path.GetFullPath(path, Environment.CurrentDirectory); +} diff --git a/installer.sh b/installer.sh new file mode 100755 index 0000000..850e7a2 --- /dev/null +++ b/installer.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +set -e + +echo "Publishing project..." +dotnet publish -c Release -r linux-x64 -o ./publish + +echo "Finding binary..." +BINARY=$(find ./publish -type f -name "anchor" | head -n 1) + +if [ -z "$BINARY" ]; then + echo "Error: Could not find binary in ./publish" + exit 1 +fi + +echo "Found binary: $BINARY" +echo "Copying to /usr/bin..." +sudo cp "$BINARY" /usr/bin/ + +echo "Installation complete!"