1
0

Compare commits

..

32 Commits

Author SHA1 Message Date
3f187348d7 update gitignore 2026-03-16 16:18:33 +01:00
fe006a5256 feat: make cursor visible on exit 2026-03-12 00:49:12 +01:00
ef30a2254b chore: update .gitignore 2026-03-12 00:04:34 +01:00
1e943e6566 refactor: apply Single Responsibility Principle to Program.cs and ReplLoop.cs
extracted responsibilities from Program.cs (208→46 lines) and ReplLoop.cs (274→174 lines) into focused service classes: HeaderRenderer, SessionManager, ApplicationStartup, ResponseStreamer, SpinnerService, UsageDisplayer, and ContextCompactionService. Each class now has a single, well-defined responsibility, improving testability and maintainability.
2026-03-11 16:59:06 +01:00
ccfa7e1b9d chore: Remove obsolete planning and documentation files. 2026-03-11 14:28:37 +01:00
c9515a822d feat: add new input processors (readline), add new figlet font 2026-03-11 14:19:57 +01:00
75bbdda37d feat: enable UTF-8 encoding for console, and fix tool log formatting 2026-03-11 14:03:17 +01:00
46d32c43ba feat: update tool logs to be more coherent 2026-03-11 13:56:00 +01:00
35c8840ed4 refactor: Use collection expressions and add explicit names for AI tool registration. 2026-03-11 13:48:53 +01:00
e2ab10813c Refactor README to condense content, move detailed sections to external documentation files, and update slash commands. 2026-03-06 23:44:18 +01:00
acc04af4bc docs: restructure README, move detailed content to docs/ directory 2026-03-06 23:42:55 +01:00
977d772229 feat: decreased deduplication amount of read results from 5 to 3 2026-03-06 08:40:11 +01:00
a776d978ea feat: saving token data to sessions 2026-03-06 08:32:37 +01:00
91a44bb2a4 feat: add version string to startup and status menu 2026-03-06 08:13:39 +01:00
f687360c2b feat: document new batch editing feature in README.md 2026-03-06 03:22:08 +01:00
4fbbde32e3 feat: add EditTools.cs to implement file manipulation functions like replace_lines, delete_range, and batch_edit, updating README.md to reflect new tools and command descriptions. 2026-03-06 03:20:48 +01:00
8f2c72b3c5 docs: add batch_edit tool and load/save commands to README 2026-03-06 03:07:56 +01:00
829ba7a7f2 feat: Update chat session instructions to recommend batch editing for multiple operations. 2026-03-06 03:04:34 +01:00
8b48b0f866 feat: Add file editing tools with replace, delete, move, and write operations, featuring hashline anchor validation and input sanitization. 2026-03-06 02:48:43 +01:00
82ef63c731 feat: Introduce robust hashline anchor validation and new editing tools. 2026-03-06 02:35:46 +01:00
119e623f5a I need the actual code changes (diff) to generate an accurate and informative commit message. 2026-03-06 01:42:08 +01:00
e98cd3b19c feat: Add commands and functionality to save and load chat sessions. 2026-03-06 01:38:55 +01:00
50414e8b8c feat: introduce FileTools for LLM-accessible file system operations including read, list, find, and grep. 2026-03-06 01:23:19 +01:00
003345edc0 feat: consolidate file write, move, grep, and delete operations into unified tools and update context compaction heuristics 2026-03-06 01:14:56 +01:00
7a6e9785d6 feat: Introduce EditTools with file manipulation functions, replacing RenameFile and CopyFile with MoveFile in ToolRegistry. 2026-03-06 00:52:24 +01:00
1af1665839 feat: Add file editing and manipulation tools with Hashline anchor validation and integrate them into the tool registry. 2026-03-06 00:52:10 +01:00
112f1f3202 docs: Document planned architectural refactor and tool consolidation. 2026-03-05 22:10:54 +01:00
c7e7976d9d feat: Introduce a pluggable LLM provider system with token extraction, pricing, and updated setup configuration. 2026-03-05 22:02:22 +01:00
4476cc7f15 docs: Add a detailed plan for multi-provider support, including pricing and token tracking strategies. 2026-03-05 12:41:55 +01:00
f2c3e5032d feat: update setup TUI with new configuration options and improved user prompts. 2026-03-05 11:58:24 +01:00
941894761a feat: Implement OpenRouter API client for managing headers and retrieving model pricing. 2026-03-05 11:52:08 +01:00
5fb914dbc8 feat: Add OpenRouter API integration for model pricing and headers. 2026-03-05 11:52:01 +01:00
45 changed files with 2933 additions and 1301 deletions

4
.gitignore vendored
View File

@@ -2,3 +2,7 @@ bin
obj obj
.vscode .vscode
publish publish
.anchor
.idea
.vs
.crush

View File

@@ -19,6 +19,10 @@
<PublishAot>false</PublishAot> <PublishAot>false</PublishAot>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Assets\3d.flf" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI" Version="10.3.0" /> <PackageReference Include="Microsoft.Extensions.AI" Version="10.3.0" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" /> <PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />

View File

@@ -7,7 +7,8 @@ internal sealed class AnchorConfig
{ {
public string ApiKey { get; set; } = ""; public string ApiKey { get; set; } = "";
public string Model { get; set; } = "qwen/qwen3.5-397b-a17b"; public string Model { get; set; } = "qwen/qwen3.5-397b-a17b";
public string Provider { get; set; } = "openrouter";
public string Endpoint { get; set; } = "https://openrouter.ai/api/v1";
// ── Persistence ────────────────────────────────────────────────────── // ── Persistence ──────────────────────────────────────────────────────
private static string ConfigPath => private static string ConfigPath =>

View File

@@ -1,5 +1,6 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using AnchorCli.OpenRouter; using AnchorCli.OpenRouter;
using AnchorCli.Tools;
namespace AnchorCli; namespace AnchorCli;
@@ -16,7 +17,12 @@ namespace AnchorCli;
[JsonSerializable(typeof(ModelsResponse))] [JsonSerializable(typeof(ModelsResponse))]
[JsonSerializable(typeof(ModelInfo))] [JsonSerializable(typeof(ModelInfo))]
[JsonSerializable(typeof(ModelPricing))] [JsonSerializable(typeof(ModelPricing))]
[JsonSerializable(typeof(Microsoft.Extensions.AI.ChatMessage))]
[JsonSerializable(typeof(System.Collections.Generic.List<Microsoft.Extensions.AI.ChatMessage>))]
[JsonSerializable(typeof(AnchorConfig))] [JsonSerializable(typeof(AnchorConfig))]
[JsonSerializable(typeof(BatchOperation))]
[JsonSerializable(typeof(BatchOperation[]))]
[JsonSerializable(typeof(TokenMetadata))]
[JsonSourceGenerationOptions( [JsonSourceGenerationOptions(
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]

176
ApplicationStartup.cs Normal file
View File

@@ -0,0 +1,176 @@
using System.ClientModel;
using AnchorCli.Commands;
using AnchorCli.OpenRouter;
using AnchorCli.Providers;
using AnchorCli.Tools;
using Microsoft.Extensions.AI;
using OpenAI;
using Spectre.Console;
namespace AnchorCli;
/// <summary>
/// Encapsulates application startup logic, including configuration loading,
/// API client creation, and component initialization.
/// </summary>
internal sealed class ApplicationStartup
{
private readonly string[] _args;
private AnchorConfig? _config;
private ITokenExtractor? _tokenExtractor;
private ModelInfo? _modelInfo;
private IChatClient? _chatClient;
private TokenTracker? _tokenTracker;
public ApplicationStartup(string[] args)
{
_args = args;
}
public AnchorConfig Config => _config ?? throw new InvalidOperationException("Run InitializeAsync first");
public string ApiKey => _config?.ApiKey ?? throw new InvalidOperationException("API key not loaded");
public string Model => _config?.Model ?? throw new InvalidOperationException("Model not loaded");
public string Endpoint => _config?.Endpoint ?? "https://openrouter.ai/api/v1";
public string ProviderName => _tokenExtractor?.ProviderName ?? "Unknown";
public ITokenExtractor TokenExtractor => _tokenExtractor ?? throw new InvalidOperationException("Token extractor not initialized");
public ModelInfo? ModelInfo => _modelInfo;
public IChatClient ChatClient => _chatClient ?? throw new InvalidOperationException("Chat client not initialized");
public TokenTracker TokenTracker => _tokenTracker ?? throw new InvalidOperationException("Token tracker not initialized");
/// <summary>
/// Runs the setup TUI if the "setup" subcommand was passed. Returns true if setup was run.
/// </summary>
public bool HandleSetupSubcommand()
{
if (_args.Length > 0 && _args[0].Equals("setup", StringComparison.OrdinalIgnoreCase))
{
SetupTui.Run();
return true;
}
return false;
}
/// <summary>
/// Initializes the application by loading configuration and creating the chat client.
/// </summary>
public async Task InitializeAsync()
{
// Load configuration
_config = AnchorConfig.Load();
if (string.IsNullOrWhiteSpace(_config.ApiKey))
{
AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]");
throw new InvalidOperationException("API key not configured");
}
// Create token extractor
_tokenExtractor = ProviderFactory.CreateTokenExtractorForEndpoint(Endpoint);
// Fetch model pricing (only for OpenRouter)
if (ProviderFactory.IsOpenRouter(Endpoint))
{
await AnsiConsole.Status()
.Spinner(Spinner.Known.BouncingBar)
.SpinnerStyle(Style.Parse("cornflowerblue"))
.StartAsync("Fetching model pricing...", async ctx =>
{
try
{
var pricingProvider = new OpenRouterProvider();
_modelInfo = await pricingProvider.GetModelInfoAsync(Model);
}
catch
{
// Pricing is best-effort
}
});
}
// Create chat client
var httpClient = new HttpClient();
OpenRouterHeaders.ApplyTo(httpClient);
var openAiClient = new OpenAIClient(
new ApiKeyCredential(ApiKey),
new OpenAIClientOptions
{
Endpoint = new Uri(Endpoint),
Transport = new System.ClientModel.Primitives.HttpClientPipelineTransport(httpClient)
});
_chatClient = openAiClient.GetChatClient(Model).AsIChatClient();
// Initialize token tracker
_tokenTracker = new TokenTracker(new ChatSession(_chatClient))
{
Provider = _tokenExtractor.ProviderName
};
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);
}
if (_modelInfo != null)
{
_tokenTracker.ContextLength = _modelInfo.ContextLength;
}
}
/// <summary>
/// Creates a new ChatSession with the initialized chat client.
/// </summary>
public ChatSession CreateSession()
{
return new ChatSession(ChatClient);
}
/// <summary>
/// Configures tool logging to use Spectre.Console.
/// </summary>
public void ConfigureToolLogging()
{
object consoleLock = new();
void ToolLog(string message)
{
lock (consoleLock)
{
Console.Write("\r" + new string(' ', 40) + "\r");
AnsiConsole.MarkupLine($"[dim grey]{Markup.Escape(message)}[/]");
}
}
CommandTool.Log =
DirTools.Log =
FileTools.Log =
EditTools.Log = ToolLog;
}
/// <summary>
/// Creates and populates a CommandRegistry with all available commands.
/// </summary>
public CommandRegistry CreateCommandRegistry(ChatSession session)
{
var registry = new CommandRegistry();
registry.Register(new ExitCommand());
registry.Register(new HelpCommand(registry));
registry.Register(new ClearCommand());
registry.Register(new StatusCommand(Model, Endpoint));
registry.Register(new CompactCommand(session.Compactor, session.History));
registry.Register(new SetupCommand());
registry.Register(new ResetCommand(session, TokenTracker));
return registry;
}
/// <summary>
/// Creates a HeaderRenderer with the current configuration.
/// </summary>
public HeaderRenderer CreateHeaderRenderer()
{
return new HeaderRenderer(Model, Endpoint, ProviderName, _modelInfo, _tokenTracker);
}
}

818
Assets/3d.flf Normal file
View File

@@ -0,0 +1,818 @@
flf2a$ 8 8 20 -1 1
3d font created by xero <x@xero.nu>
$$@
$$@
$$@
$$@
$$@
$$@
$$@
$$@@
██@
░██@
░██@
░██@
░██@
░░ @
██@
░░ @@
█ █@
░█ ░█@
░ ░ @
@
@
@
@
@@
@
██ ██ @
████████████@
░░░██░░░░██░ @
░██ ░██ @
████████████@
░░░██░░░░██░ @
░░ ░░ @@
█ @
█████@
░█░█░ @
░█████@
░░░█░█@
█████@
░░░█░ @
░ @@
@
██ ██ @
░░ ██ @
██ @
██ @
██ @
██ ██ @
░░ ░░ @@
██ @
█░ █ @
░ ██ @
█░ █ █@
█ ░ █ @
░█ ░█ @
░ ████ █@
░░░░ ░ @@
██@
░░█@
░ @
@
@
@
@
@@
██@
██ @
██ @
░██ @
░██ @
░░██ @
░░██@
░░ @@
██ @
░░██ @
░░██@
░██@
░██@
██ @
██ @
░░ @@
██ @
██ ░██ ██ @
░░██ ░██ ██ @
██████████████@
░░░██░░██░░██░ @
██ ░██ ░░██ @
░░ ░██ ░░ @
░░ @@
@
█ @
░█ @
█████████@
░░░░░█░░░ @
░█ @
░ @
@@
@
@
@
@
@
██@
░░█@
░ @@
@
@
@
█████@
░░░░░ @
@
@
@@
@
@
@
@
@
██@
░██@
░░ @@
██@
██ @
██ @
██ @
██ @
██ @
██ @
░░ @@
████ @
█░░░██@
░█ █░█@
░█ █ ░█@
░██ ░█@
░█ ░█@
░ ████ @
░░░░ @@
██ @
███ @
░░██ @
░██ @
░██ @
░██ @
████@
░░░░ @@
████ @
█░░░ █@
░ ░█@
███ @
█░░ @
█ @
░██████@
░░░░░░ @@
████ @
█░░░ █@
░ ░█@
███ @
░░░ █@
█ ░█@
░ ████ @
░░░░ @@
██ @
█░█ @
█ ░█ @
██████@
░░░░░█ @
░█ @
░█ @
░ @@
██████@
░█░░░░ @
░█████ @
░░░░░ █@
░█@
█ ░█@
░ ████ @
░░░░ @@
████ @
█░░░ █@
░█ ░ @
░█████ @
░█░░░ █@
░█ ░█@
░ ████ @
░░░░ @@
██████@
░░░░░░█@
░█@
█ @
█ @
█ @
█ @
░ @@
████ @
█░░░ █@
░█ ░█@
░ ████ @
█░░░ █@
░█ ░█@
░ ████ @
░░░░ @@
████ @
█░░░ █@
░█ ░█@
░ ████ @
░░░█ @
█ @
█ @
░ @@
@
@
@
@
██@
░░ @
██@
░░ @@
@
@
@
██@
░░ @
██@
░░█@
░ @@
██@
██░ @
██░ @
██░ @
░░ ██ @
░░ ██ @
░░ ██@
░░ @@
@
@
██████@
░░░░░░ @
██████@
░░░░░░ @
@
@@
██ @
░░ ██ @
░░ ██ @
░░ ██@
██░ @
██░ @
██░ @
░░ @@
████ @
██░░██@
░██ ░██@
░░ ██ @
██ @
░░ @
██ @
░░ @@
████ @
█░░░ █@
░█ ██░█@
░█░█ ░█@
░█░ ██ @
░█ ░░ @
░ █████@
░░░░░ @@
██ @
████ @
██░░██ @
██ ░░██ @
██████████@
░██░░░░░░██@
░██ ░██@
░░ ░░ @@
██████ @
░█░░░░██ @
░█ ░██ @
░██████ @
░█░░░░ ██@
░█ ░██@
░███████ @
░░░░░░░ @@
██████ @
██░░░░██@
██ ░░ @
░██ @
░██ @
░░██ ██@
░░██████ @
░░░░░░ @@
███████ @
░██░░░░██ @
░██ ░██@
░██ ░██@
░██ ░██@
░██ ██ @
░███████ @
░░░░░░░ @@
████████@
░██░░░░░ @
░██ @
░███████ @
░██░░░░ @
░██ @
░████████@
░░░░░░░░ @@
████████@
░██░░░░░ @
░██ @
░███████ @
░██░░░░ @
░██ @
░██ @
░░ @@
████████ @
██░░░░░░██@
██ ░░ @
░██ @
░██ █████@
░░██ ░░░░██@
░░████████ @
░░░░░░░░ @@
██ ██@
░██ ░██@
░██ ░██@
░██████████@
░██░░░░░░██@
░██ ░██@
░██ ░██@
░░ ░░ @@
██@
░██@
░██@
░██@
░██@
░██@
░██@
░░ @@
██@
░██@
░██@
░██@
░██@
██ ░██@
░░█████ @
░░░░░ @@
██ ██@
░██ ██ @
░██ ██ @
░████ @
░██░██ @
░██░░██ @
░██ ░░██@
░░ ░░ @@
██ @
░██ @
░██ @
░██ @
░██ @
░██ @
░████████@
░░░░░░░░ @@
████ ████@
░██░██ ██░██@
░██░░██ ██ ░██@
░██ ░░███ ░██@
░██ ░░█ ░██@
░██ ░ ░██@
░██ ░██@
░░ ░░ @@
████ ██@
░██░██ ░██@
░██░░██ ░██@
░██ ░░██ ░██@
░██ ░░██░██@
░██ ░░████@
░██ ░░███@
░░ ░░░ @@
███████ @
██░░░░░██ @
██ ░░██@
░██ ░██@
░██ ░██@
░░██ ██ @
░░███████ @
░░░░░░░ @@
███████ @
░██░░░░██@
░██ ░██@
░███████ @
░██░░░░ @
░██ @
░██ @
░░ @@
███████ @
██░░░░░██ @
██ ░░██ @
░██ ░██ @
░██ ██░██ @
░░██ ░░ ██ @
░░███████ ██@
░░░░░░░ ░░ @@
███████ @
░██░░░░██ @
░██ ░██ @
░███████ @
░██░░░██ @
░██ ░░██ @
░██ ░░██@
░░ ░░ @@
████████@
██░░░░░░ @
░██ @
░█████████@
░░░░░░░░██@
░██@
████████ @
░░░░░░░░ @@
██████████@
░░░░░██░░░ @
░██ @
░██ @
░██ @
░██ @
░██ @
░░ @@
██ ██@
░██ ░██@
░██ ░██@
░██ ░██@
░██ ░██@
░██ ░██@
░░███████ @
░░░░░░░ @@
██ ██@
░██ ░██@
░██ ░██@
░░██ ██ @
░░██ ██ @
░░████ @
░░██ @
░░ @@
██ ██@
░██ ░██@
░██ █ ░██@
░██ ███ ░██@
░██ ██░██░██@
░████ ░░████@
░██░ ░░░██@
░░ ░░ @@
██ ██@
░░██ ██ @
░░██ ██ @
░░███ @
██░██ @
██ ░░██ @
██ ░░██@
░░ ░░ @@
██ ██@
░░██ ██ @
░░████ @
░░██ @
░██ @
░██ @
░██ @
░░ @@
████████@
░░░░░░██ @
██ @
██ @
██ @
██ @
████████@
░░░░░░░░ @@
█████@
░██░░ @
░██ @
░██ @
░██ @
░██ @
░█████@
░░░░░ @@
██ @
░░██ @
░░██ @
░░██ @
░░██ @
░░██ @
░░██@
░░ @@
█████@
░░░░██@
░██@
░██@
░██@
░██@
█████@
░░░░░ @@
██ @
██░ ██ @
██ ░░ ██@
░░ ░░ @
@
@
@
@@
@
@
@
@
@
@
█████@
░░░░░ @@
██@
░█ @
░ @
@
@
@
@
@@
@
@
██████ @
░░░░░░██ @
███████ @
██░░░░██ @
░░████████@
░░░░░░░░ @@
██ @
░██ @
░██ @
░██████ @
░██░░░██@
░██ ░██@
░██████ @
░░░░░ @@
@
@
█████ @
██░░░██@
░██ ░░ @
░██ ██@
░░█████ @
░░░░░ @@
██@
░██@
░██@
██████@
██░░░██@
░██ ░██@
░░██████@
░░░░░░ @@
@
@
█████ @
██░░░██@
░███████@
░██░░░░ @
░░██████@
░░░░░░ @@
████@
░██░ @
██████@
░░░██░ @
░██ @
░██ @
░██ @
░░ @@
@
█████ @
██░░░██@
░██ ░██@
░░██████@
░░░░░██@
█████ @
░░░░░ @@
██ @
░██ @
░██ @
░██████ @
░██░░░██@
░██ ░██@
░██ ░██@
░░ ░░ @@
██@
░░ @
██@
░██@
░██@
░██@
░██@
░░ @@
██@
░░ @
██@
░██@
░██@
██░██@
░░███ @
░░░ @@
██ @
░██ @
░██ ██@
░██ ██ @
░████ @
░██░██ @
░██░░██@
░░ ░░ @@
██@
░██@
░██@
░██@
░██@
░██@
███@
░░░ @@
@
@
██████████ @
░░██░░██░░██@
░██ ░██ ░██@
░██ ░██ ░██@
███ ░██ ░██@
░░░ ░░ ░░ @@
@
@
███████ @
░░██░░░██@
░██ ░██@
░██ ░██@
███ ░██@
░░░ ░░ @@
@
@
██████ @
██░░░░██@
░██ ░██@
░██ ░██@
░░██████ @
░░░░░░ @@
@
██████ @
░██░░░██@
░██ ░██@
░██████ @
░██░░░ @
░██ @
░░ @@
@
████ @
██░░██ @
░██ ░██ @
░░█████ @
░░░░██ @
░███@
░░░ @@
@
@
██████@
░░██░░█@
░██ ░ @
░██ @
░███ @
░░░ @@
@
@
██████@
██░░░░ @
░░█████ @
░░░░░██@
██████ @
░░░░░░ @@
██ @
░██ @
██████@
░░░██░ @
░██ @
░██ @
░░██ @
░░ @@
@
@
██ ██@
░██ ░██@
░██ ░██@
░██ ░██@
░░██████@
░░░░░░ @@
@
@
██ ██@
░██ ░██@
░░██ ░██ @
░░████ @
░░██ @
░░ @@
@
@
███ ██@
░░██ █ ░██@
░██ ███░██@
░████░████@
███░ ░░░██@
░░░ ░░░ @@
@
@
██ ██@
░░██ ██ @
░░███ @
██░██ @
██ ░░██@
░░ ░░ @@
@
██ ██@
░░██ ██ @
░░███ @
░██ @
██ @
██ @
░░ @@
@
@
██████@
░░░░██ @
██ @
██ @
██████@
░░░░░░ @@
███@
██░ @
░██ @
███ @
░░░██ @
░██ @
░░███@
░░░ @@
█@
░█@
░█@
░ @
█@
░█@
░█@
░ @@
███ @
░░░██ @
░██ @
░░███@
██░ @
░██ @
███ @
░░░ @@
██ ███ @
░░███░░██@
░░░ ░░ @
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.AI; using Microsoft.Extensions.AI;
using System.Text.Json;
namespace AnchorCli; namespace AnchorCli;
@@ -8,6 +9,11 @@ internal sealed class ChatSession
public ContextCompactor Compactor { get; } public ContextCompactor Compactor { get; }
public List<ChatMessage> History { get; } public List<ChatMessage> History { get; }
// Token tracking state persisted across sessions
public long SessionInputTokens { get; set; }
public long SessionOutputTokens { get; set; }
public int RequestCount { get; set; }
public ChatSession(IChatClient innerClient) public ChatSession(IChatClient innerClient)
{ {
Compactor = new ContextCompactor(innerClient); Compactor = new ContextCompactor(innerClient);
@@ -43,6 +49,7 @@ internal sealed class ChatSession
2. After reading, edit the file before verifying the returned fingerprint. 2. After reading, edit the file before verifying the returned fingerprint.
3. Edit from bottom to top so line numbers don't shift. 3. Edit from bottom to top so line numbers don't shift.
4. If an anchor fails validation, re-read the relevant range to get fresh anchors. 4. If an anchor fails validation, re-read the relevant range to get fresh anchors.
5. When making multiple edits to a file, use BatchEdit instead of multiple individual calls to prevent anchor invalidation between operations.
Keep responses concise. You have access to the current working directory. Keep responses concise. You have access to the current working directory.
You are running on: {{System.Runtime.InteropServices.RuntimeInformation.OSDescription}} You are running on: {{System.Runtime.InteropServices.RuntimeInformation.OSDescription}}
@@ -69,4 +76,78 @@ internal sealed class ChatSession
yield return update; yield return update;
} }
} }
public async Task SaveAsync(string filePath, CancellationToken cancellationToken = default)
{
// Skip the system message when saving (it will be recreated on load)
var messagesToSave = History.Skip(1).ToList();
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
var json = JsonSerializer.Serialize(messagesToSave, AppJsonContext.Default.ListChatMessage);
// Save token stats to a separate metadata file
var metadataPath = Path.ChangeExtension(filePath, ".metadata.json");
var metadata = new TokenMetadata
{
SessionInputTokens = SessionInputTokens,
SessionOutputTokens = SessionOutputTokens,
RequestCount = RequestCount
};
var metadataJson = JsonSerializer.Serialize(metadata, AppJsonContext.Default.TokenMetadata);
await File.WriteAllTextAsync(metadataPath, metadataJson, cancellationToken);
await File.WriteAllTextAsync(filePath, json, cancellationToken);
}
public async Task LoadAsync(string filePath, CancellationToken cancellationToken = default)
{
var json = await File.ReadAllTextAsync(filePath, cancellationToken);
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
var messages = JsonSerializer.Deserialize<List<ChatMessage>>(json, AppJsonContext.Default.ListChatMessage)
?? new List<ChatMessage>();
// Keep the system message and append loaded messages
var systemMessage = History[0];
History.Clear();
History.Add(systemMessage);
History.AddRange(messages);
// Load token stats from metadata file if it exists
var metadataPath = Path.ChangeExtension(filePath, ".metadata.json");
if (File.Exists(metadataPath))
{
try
{
var metadataJson = await File.ReadAllTextAsync(metadataPath, cancellationToken);
var metadata = JsonSerializer.Deserialize<TokenMetadata>(metadataJson, AppJsonContext.Default.TokenMetadata);
if (metadata != null)
{
SessionInputTokens = metadata.SessionInputTokens;
SessionOutputTokens = metadata.SessionOutputTokens;
RequestCount = metadata.RequestCount;
}
}
catch { /* Ignore metadata load errors */ }
}
}
}
/// <summary>
/// Token tracking metadata serialized with the session.
/// </summary>
internal sealed class TokenMetadata
{
public long SessionInputTokens { get; set; }
public long SessionOutputTokens { get; set; }
public int RequestCount { get; set; }
} }

View File

@@ -9,6 +9,7 @@ public class ExitCommand : ICommand
public Task ExecuteAsync(string[] args, CancellationToken ct) public Task ExecuteAsync(string[] args, CancellationToken ct)
{ {
AnsiConsole.MarkupLine("[green]Goodbye![/]"); AnsiConsole.MarkupLine("[green]Goodbye![/]");
Console.CursorVisible = true;
Environment.Exit(0); Environment.Exit(0);
return Task.CompletedTask; return Task.CompletedTask;
} }

View File

@@ -1,5 +1,5 @@
using Spectre.Console; using Spectre.Console;
using System.Linq; using System.Reflection;
namespace AnchorCli.Commands; namespace AnchorCli.Commands;
public class HelpCommand : ICommand public class HelpCommand : ICommand
@@ -16,6 +16,8 @@ public class HelpCommand : ICommand
public Task ExecuteAsync(string[] args, CancellationToken ct) public Task ExecuteAsync(string[] args, CancellationToken ct)
{ {
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
AnsiConsole.MarkupLine($"[cyan]Anchor CLI v{version}[/]");
AnsiConsole.MarkupLine("[cyan]Available commands:[/]"); AnsiConsole.MarkupLine("[cyan]Available commands:[/]");
var table = new Table(); var table = new Table();

View File

@@ -1,4 +1,5 @@
using Spectre.Console; using Spectre.Console;
using System.Reflection;
namespace AnchorCli.Commands; namespace AnchorCli.Commands;
public class StatusCommand : ICommand public class StatusCommand : ICommand
@@ -25,6 +26,7 @@ public class StatusCommand : ICommand
table.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(_model)}[/]"); table.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(_model)}[/]");
table.AddRow("[grey]Endpoint[/]", $"[blue]{Markup.Escape(_endpoint)}[/]"); table.AddRow("[grey]Endpoint[/]", $"[blue]{Markup.Escape(_endpoint)}[/]");
table.AddRow("[grey]Version[/]", $"[magenta]{Assembly.GetExecutingAssembly().GetName().Version}[/]");
table.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDirectory)}[/]"); table.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDirectory)}[/]");
AnsiConsole.Write(table); AnsiConsole.Write(table);

View File

@@ -0,0 +1,61 @@
using Microsoft.Extensions.AI;
using Spectre.Console;
using AnchorCli.OpenRouter;
namespace AnchorCli;
/// <summary>
/// Handles context compaction when the conversation approaches token limits.
/// </summary>
internal sealed class ContextCompactionService
{
private readonly ContextCompactor _compactor;
private readonly List<ChatMessage> _history;
private readonly TokenTracker _tokenTracker;
public ContextCompactionService(
ContextCompactor compactor,
List<ChatMessage> history,
TokenTracker tokenTracker)
{
_compactor = compactor;
_history = history;
_tokenTracker = tokenTracker;
}
/// <summary>
/// Checks if compaction is needed and performs it if so.
/// Returns true if compaction was performed.
/// </summary>
public async Task<bool> TryCompactAsync()
{
if (!_tokenTracker.ShouldCompact())
{
return false;
}
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();
return compacted;
}
}

View File

@@ -74,7 +74,7 @@ internal sealed partial class ContextCompactor(IChatClient client)
if (filesRead.TryGetValue(filePath, out int count) && count >= 3) if (filesRead.TryGetValue(filePath, out int count) && count >= 3)
{ {
shouldRedact = true; shouldRedact = true;
reason = "deduplication — you read this file 3 or more times later"; reason = "deduplication — you read this file 5 or more times later";
} }
// Rule 2: TTL. If this was read 2 or more user turns ago, redact it. // Rule 2: TTL. If this was read 2 or more user turns ago, redact it.
else if (userTurnsSeen >= 2) else if (userTurnsSeen >= 2)

View File

@@ -62,17 +62,14 @@ internal static class HashlineValidator
if (lineNumber < 1 || lineNumber > lines.Length) if (lineNumber < 1 || lineNumber > lines.Length)
{ {
error = $"Anchor '{anchor}': line {lineNumber} is out of range " + error = $"Anchor '{anchor}': line {lineNumber} is out of range. Re-read the file ({lines.Length} lines).";
$"(file has {lines.Length} line(s)).";
return false; return false;
} }
string actualHash = HashlineEncoder.ComputeHash(lines[lineNumber - 1].AsSpan(), lineNumber); string actualHash = HashlineEncoder.ComputeHash(lines[lineNumber - 1].AsSpan(), lineNumber);
if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase)) if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
{ {
error = $"Anchor '{anchor}': hash mismatch at line {lineNumber} " + error = $"Anchor '{anchor}': hash mismatch at line {lineNumber}. The file has changed — re-read before editing.";
$"(expected '{expectedHash}', got '{actualHash}'). " +
$"The file has changed — re-read before editing.";
return false; return false;
} }
@@ -98,10 +95,7 @@ internal static class HashlineValidator
out int endIndex, out int endIndex,
out string error) out string error)
{ {
startIndex = -1;
endIndex = -1; endIndex = -1;
error = string.Empty;
if (!TryResolve(startAnchor, lines, out startIndex, out error)) if (!TryResolve(startAnchor, lines, out startIndex, out error))
return false; return false;
@@ -110,8 +104,7 @@ internal static class HashlineValidator
if (startIndex > endIndex) if (startIndex > endIndex)
{ {
error = $"Range error: start anchor '{startAnchor}' (line {startIndex + 1}) " + error = $"Range error: start anchor is after end anchor.";
$"is after end anchor '{endAnchor}' (line {endIndex + 1}).";
return false; return false;
} }

110
HeaderRenderer.cs Normal file
View File

@@ -0,0 +1,110 @@
using System.Reflection;
using Spectre.Console;
using AnchorCli.OpenRouter;
namespace AnchorCli;
/// <summary>
/// Renders the application header, including ASCII art logo and configuration info table.
/// </summary>
internal sealed class HeaderRenderer
{
private readonly string _model;
private readonly string _endpoint;
private readonly string _providerName;
private readonly ModelInfo? _modelInfo;
private readonly TokenTracker? _tokenTracker;
public HeaderRenderer(
string model,
string endpoint,
string providerName,
ModelInfo? modelInfo = null,
TokenTracker? tokenTracker = null)
{
_model = model;
_endpoint = endpoint;
_providerName = providerName;
_modelInfo = modelInfo;
_tokenTracker = tokenTracker;
}
/// <summary>
/// Renders the full header including logo, subtitle, and info table.
/// </summary>
public void Render()
{
RenderLogo();
RenderSubtitle();
RenderInfoTable();
}
/// <summary>
/// Renders the ASCII art logo.
/// </summary>
public void RenderLogo()
{
var fontStream = Assembly.GetExecutingAssembly().GetManifestResourceStream("AnchorCli.Assets.3d.flf");
if (fontStream != null)
{
var font = FigletFont.Load(fontStream);
AnsiConsole.Write(
new FigletText(font, "anchor")
.Color(Color.CornflowerBlue));
}
else
{
AnsiConsole.Write(
new FigletText("anchor")
.Color(Color.CornflowerBlue));
}
}
/// <summary>
/// Renders the subtitle rule.
/// </summary>
public void RenderSubtitle()
{
AnsiConsole.Write(
new Rule("[dim]AI-powered coding assistant[/]")
.RuleStyle(Style.Parse("cornflowerblue dim"))
.LeftJustified());
AnsiConsole.WriteLine();
}
/// <summary>
/// Renders the configuration info table.
/// </summary>
public void RenderInfoTable()
{
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
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(_modelInfo?.Name ?? _model)}[/]");
table.AddRow("[grey]Provider[/]", $"[blue]{_providerName}[/]");
table.AddRow("[grey]Endpoint[/]", $"[dim]{_endpoint}[/]");
table.AddRow("[grey]Version[/]", $"[magenta]{version}[/]");
if (_modelInfo?.Pricing != null && _tokenTracker != null)
{
var inM = _tokenTracker.InputPrice * 1_000_000m;
var outM = _tokenTracker.OutputPrice * 1_000_000m;
table.AddRow("[grey]Pricing[/]",
$"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]");
}
if (_modelInfo != null)
{
table.AddRow("[grey]Context[/]",
$"[dim]{_modelInfo.ContextLength:N0} tokens[/]");
}
AnsiConsole.Write(table);
AnsiConsole.WriteLine();
}
}

View File

@@ -1,81 +0,0 @@
# Command Ideas for AnchorCli
## Session & Help
### `/help`
Show available commands, version info, and tool capabilities. Combines `/help`, `/version`, `/about`, and `/tools`.
### `/clear`
Clear the terminal screen and optionally reset conversation with `/clear --reset`.
### `/history`
Show the current chat history. Use `/history <n>` to show last N messages.
## Navigation
### `/cd [path]`
Change directory. With no argument, shows current working directory (combines `/cwd`, `/pwd`, `/cd`).
### `/ls`
List files in current directory (alias for ListDir tool).
## Configuration
### `/config`
Show or modify settings. Subcommands:
- `/config model <name>` - Change AI model
- `/config endpoint <url>` - Change API endpoint
- `/config debug <on|off>` - Toggle debug mode
## Conversation Management
### `/save [filename]`
Save current conversation to a file (JSON or markdown format).
### `/load <filename>`
Load a previous conversation from a file.
### `/export <filename>`
Export chat history in a specific format (JSON, markdown, plain text).
## Advanced Features
### `/undo`
Undo the last file edit (requires edit history tracking).
### `/diff [file]`
Show differences between current and original file state. With no argument, shows all pending changes.
### `/search <pattern>`
Quick file/content search across the project.
### `/stats`
Show session statistics (files edited, tokens used, commands run, estimated costs).
### `/macro <name> [commands...]`
Create and execute multi-step command sequences.
### `/alias <name> <command>`
Create custom command shortcuts.
## Safety & Integration
### `--dry-run` / Read-only Mode
Run Anchor without mutating any files. Shows what *would* happen (edits, deletes, renames) without applying changes. Perfect for reviewing AI suggestions before committing.
### Git Integration
Seamless version control integration:
- Auto-create a branch per session (`anchor session --git-branch`)
- Auto-commit after successful edits with descriptive messages
- Show git diff before/after operations
- Revert to pre-session state if something goes wrong
### Mutation Rate Limits
Prevent runaway AI from trashing a project:
- Configurable max file edits per conversation turn
- Hard cap on delete/rename operations without confirmation
- Cooldown period after N rapid mutations
- Warning when approaching limits
### File Type Restrictions
Config to block edits on sensitive patterns (`*.config`, `*.sql`, `*.production.*`, etc.). Requires explicit override flag.

View File

@@ -1,90 +0,0 @@
# Improvements for AnchorCli
This document contains criticisms and suggestions for improving the AnchorCli project.
## Architecture
1. **Program.cs is too large (433 lines)** - Split into smaller classes: ChatSession, ReplLoop, ResponseStreamer
2. **No dependency injection** - Use Microsoft.Extensions.DependencyInjection for testability
3. **Static tool classes with global Log delegates** - Convert to instance classes with injected ILogger
## Testing
4. **No unit tests** - Add xUnit project, test HashlineEncoder/Validator, tools, and ContextCompactor
5. **No integration tests** - Use Spectre.Console.Testing for TUI workflows
6. **No CI/CD** - Add GitHub Actions for test runs on push/PR
## Documentation
7. **Missing XML docs** - Add summary docs to public APIs
8. **Incomplete README** - Add contributing, development, troubleshooting sections
9. **No CHANGELOG.md** - Track releases and changes
## Security & Safety
10. **Command execution unsandboxed** - Add allowlist/denylist, time limits, output size limits
11. **No mutation rate limiting** - Track edits per turn, add configurable limits
12. **API key in plain text** - Use OS keychain or env var, set restrictive file permissions
## Performance
13. **No file read caching** - Cache file content per-turn with invalidation on write
14. **Regex not static** - Make compiled regexes static readonly
## User Experience
15. **No undo** - Store edit history, add /undo command
16. **No session persistence** - Add /save and /load commands
17. **Limited error recovery** - Better error messages, /debug mode
## Developer Experience
18. **No .editorconfig** - Add code style enforcement
19. **No solution file** - Create AnchorCli.sln
20. **Hardcoded model list** - Fetch from OpenRouter API dynamically
21. **No version info** - Add <Version> to .csproj, display in /help
## Code Quality
22. **Inconsistent error handling** - Standardize on error strings, avoid empty catch blocks
23. **Magic numbers** - Extract to named constants (150_000, 300, KeepRecentTurns=2)
24. **Commented-out debug code** - Remove or use #if DEBUG
25. **Weak hash algorithm** - Adler-8 XOR only has 256 values; consider 4-char hex
## Build & Dependencies
26. **No LICENSE file** - Add MIT LICENSE file
## Priority
### High
- [ ] Add unit tests
- [ ] Implement undo functionality
- [ ] Add mutation rate limiting
- [x] Refactor Program.cs
- [x] Add LICENSE file
### Medium
- [ ] Session persistence
- [ ] XML documentation
- [ ] Error handling consistency
- [x] .editorconfig
- [ ] Dynamic model list
### Low
- [ ] CHANGELOG.md
- [ ] CI/CD pipeline
- [ ] Stronger hash algorithm
- [ ] Code coverage reporting
## Quick Wins (<1 hour each)
- [x] Add <Version> to .csproj
- [x] Create LICENSE file
- [x] Add .editorconfig
- [x] Remove commented code
- [x] Extract magic numbers to constants
- [x] Add XML docs to Hashline classes
- [x] Make regexes static readonly
*Prioritize based on goals: safety, testability, or user experience.*

125
InputProcessor.cs Normal file
View File

@@ -0,0 +1,125 @@
using Spectre.Console;
namespace AnchorCli
{
internal class InputProcessor
{
private static void DisplayText(int left, string buffer, int index = -1, string placeholder = "", int viewportOffset = 0)
{
Console.CursorLeft = left;
if (buffer.Length == 0 && index == 0)
{
AnsiConsole.Markup($"[grey dim]{placeholder}{new string(' ', Console.WindowWidth - 1 - left - placeholder.Length)}[/]");
return;
}
var visibleWidth = Console.WindowWidth - left - 1;
var displayStart = Math.Min(viewportOffset, Math.Max(0, buffer.Length - 1));
var displayEnd = Math.Min(displayStart + visibleWidth, buffer.Length);
var displayBuffer = string.Concat(buffer.AsSpan(displayStart, displayEnd - displayStart), " ");
for (var i = 0; i < displayBuffer.Length; i++)
{
var actualIndex = displayStart + i;
if (index != -1 && actualIndex == index)
{
Console.ForegroundColor = ConsoleColor.Black;
Console.BackgroundColor = ConsoleColor.White;
}
Console.Write(displayBuffer[i]);
if (index != -1 && actualIndex == index)
Console.ResetColor();
}
// Fill remaining space with spaces
var remainingSpaces = visibleWidth - displayBuffer.Length;
if (remainingSpaces > 0)
Console.Write(new string(' ', remainingSpaces));
}
public static string ReadLine(string placeholder = "")
{
Console.CursorVisible = false;
var buffer = string.Empty;
var index = 0;
var viewportOffset = 0;
var left = Console.CursorLeft;
DisplayText(left, buffer, index, placeholder, viewportOffset);
while (true)
{
var inputKey = Console.ReadKey(intercept: true);
switch (inputKey)
{
case { Key: ConsoleKey.Enter } when buffer.Length > 0:
DisplayText(left, buffer);
Console.WriteLine();
return buffer;
case { Key: ConsoleKey.Backspace } when index > 0:
index--;
buffer = buffer.Remove(index, 1);
break;
case { Key: ConsoleKey.Delete } when index < buffer.Length:
buffer = buffer.Remove(index, 1);
break;
case { Key: ConsoleKey.LeftArrow, Modifiers: ConsoleModifiers.Control }:
while (index > 0 && buffer[index - 1] == ' ')
index--;
while (index > 0 && buffer[index - 1] != ' ')
index--;
break;
case { Key: ConsoleKey.RightArrow, Modifiers: ConsoleModifiers.Control }:
while (index < buffer.Length && buffer[index] == ' ')
index++;
while (index < buffer.Length && buffer[index] != ' ')
index++;
break;
case { Key: ConsoleKey.LeftArrow } when index > 0:
index--;
break;
case { Key: ConsoleKey.RightArrow } when index < buffer.Length:
index++;
break;
case { Key: ConsoleKey.W, Modifiers: ConsoleModifiers.Control } when index > 0:
var deleteStart = index;
while (deleteStart > 0 && buffer[deleteStart - 1] == ' ')
deleteStart--;
while (deleteStart > 0 && buffer[deleteStart - 1] != ' ')
deleteStart--;
var charsToDelete = index - deleteStart;
buffer = buffer.Remove(deleteStart, charsToDelete);
index = deleteStart;
break;
default:
var keyChar = inputKey.KeyChar;
if (!(char.IsLetterOrDigit(keyChar) || char.IsWhiteSpace(keyChar) || char.IsPunctuation(keyChar) || char.IsSymbol(keyChar)))
break;
buffer = buffer.Insert(index, inputKey.KeyChar.ToString());
index++;
break;
}
// Adjust viewport for scrolling
var visibleWidth = Console.WindowWidth - left - 1;
if (index < viewportOffset)
viewportOffset = index;
else if (index >= viewportOffset + visibleWidth)
viewportOffset = index - visibleWidth + 1;
DisplayText(left, buffer, index, placeholder, viewportOffset);
}
}
}
}

View File

@@ -0,0 +1,19 @@
using System.Net.Http;
namespace AnchorCli.OpenRouter;
/// <summary>
/// Provides extension methods for adding OpenRouter-specific HTTP headers.
/// </summary>
public static class OpenRouterHeaders
{
/// <summary>
/// Applies the required OpenRouter headers to the specified HttpClient.
/// </summary>
public static void ApplyTo(HttpClient httpClient)
{
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("HTTP-Referer", "https://git.technopunk.space/tomi/AnchorCli");
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("X-OpenRouter-Title", "Anchor CLI");
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("X-OpenRouter-Categories", "cli-agent");
}
}

View File

@@ -14,6 +14,11 @@ internal sealed class PricingProvider
private static readonly HttpClient Http = new(); private static readonly HttpClient Http = new();
private Dictionary<string, ModelInfo>? _models; private Dictionary<string, ModelInfo>? _models;
static PricingProvider()
{
OpenRouterHeaders.ApplyTo(Http);
}
/// <summary> /// <summary>
/// Fetches the full model list from OpenRouter (cached after first call). /// Fetches the full model list from OpenRouter (cached after first call).
/// </summary> /// </summary>

View File

@@ -5,9 +5,27 @@ namespace AnchorCli.OpenRouter;
/// </summary> /// </summary>
internal sealed class TokenTracker internal sealed class TokenTracker
{ {
public long SessionInputTokens { get; private set; } private ChatSession _session;
public long SessionOutputTokens { get; private set; }
public int RequestCount { get; private set; } public TokenTracker(ChatSession session)
{
_session = session;
}
/// <summary>
/// Gets or sets the session. Allows setting the session after construction
/// to support dependency injection patterns.
/// </summary>
public ChatSession Session
{
get => _session;
set => _session = value;
}
public string Provider { get; set; } = "Unknown";
public long SessionInputTokens => _session.SessionInputTokens;
public long SessionOutputTokens => _session.SessionOutputTokens;
public int RequestCount => _session.RequestCount;
/// <summary>Maximum context window for the model (tokens). 0 = unknown.</summary> /// <summary>Maximum context window for the model (tokens). 0 = unknown.</summary>
public int ContextLength { get; set; } public int ContextLength { get; set; }
@@ -23,22 +41,21 @@ internal sealed class TokenTracker
/// <summary>Fixed USD per API request.</summary> /// <summary>Fixed USD per API request.</summary>
public decimal RequestPrice { get; set; } public decimal RequestPrice { get; set; }
/// <summary> /// <summary>
/// Record usage from one response (may span multiple LLM rounds). /// Record usage from one response (may span multiple LLM rounds).
/// </summary> /// </summary>
public void AddUsage(int inputTokens, int outputTokens) public void AddUsage(int inputTokens, int outputTokens)
{ {
SessionInputTokens += inputTokens; _session.SessionInputTokens += inputTokens;
SessionOutputTokens += outputTokens; _session.SessionOutputTokens += outputTokens;
LastInputTokens = inputTokens; LastInputTokens = inputTokens;
RequestCount++; _session.RequestCount++;
} }
public void Reset() public void Reset()
{ {
SessionInputTokens = 0; _session.SessionInputTokens = 0;
SessionOutputTokens = 0; _session.SessionOutputTokens = 0;
RequestCount = 0; _session.RequestCount = 0;
LastInputTokens = 0; LastInputTokens = 0;
} }

View File

@@ -1,139 +1,45 @@
using System.ClientModel;
using Microsoft.Extensions.AI;
using OpenAI;
using AnchorCli; using AnchorCli;
using AnchorCli.Tools;
using AnchorCli.Commands; using AnchorCli.Commands;
using AnchorCli.OpenRouter;
using Spectre.Console; using Spectre.Console;
// ── Setup subcommand ───────────────────────────────────────────────────── Console.InputEncoding = System.Text.Encoding.UTF8;
if (args.Length > 0 && args[0].Equals("setup", StringComparison.OrdinalIgnoreCase)) Console.OutputEncoding = System.Text.Encoding.UTF8;
// ── Application entry point ───────────────────────────────────────────────
var startup = new ApplicationStartup(args);
// Handle setup subcommand
if (startup.HandleSetupSubcommand())
{ {
SetupTui.Run();
return; return;
} }
// ── Config ────────────────────────────────────────────────────────────── // Initialize application (load config, create clients, fetch pricing)
const string endpoint = "https://openrouter.ai/api/v1"; await startup.InitializeAsync();
var cfg = AnchorConfig.Load();
string apiKey = cfg.ApiKey;
string model = cfg.Model;
if (string.IsNullOrWhiteSpace(apiKey)) // Render header
{ var headerRenderer = startup.CreateHeaderRenderer();
AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]"); headerRenderer.Render();
return;
}
// ── Fetch model pricing from OpenRouter ───────────────────────────────── // Configure tool logging
var pricingProvider = new PricingProvider(); startup.ConfigureToolLogging();
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 ───────────────────────────────────────
object consoleLock = new object();
void ToolLog(string message)
{
lock (consoleLock)
{
Console.Write("\r" + new string(' ', 40) + "\r");
AnsiConsole.MarkupLine($"[dim grey] ● {Markup.Escape(message)}[/]");
}
}
CommandTool.Log =
DirTools.Log =
FileTools.Log =
EditTools.Log = ToolLog;
// ── Instantiate Core Components ──────────────────────────────────────────
var session = new ChatSession(innerClient);
if (modelInfo != null)
{
tokenTracker.ContextLength = modelInfo.ContextLength;
}
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(session.Compactor, session.History));
commandRegistry.Register(new SetupCommand());
commandRegistry.Register(new ResetCommand(session, tokenTracker));
// Create core components
var session = startup.CreateSession();
startup.TokenTracker.Session = session;
var commandRegistry = startup.CreateCommandRegistry(session);
var commandDispatcher = new CommandDispatcher(commandRegistry); var commandDispatcher = new CommandDispatcher(commandRegistry);
// ── Run Repl ──────────────────────────────────────────────────────────── // Create session manager
var sessionManager = new SessionManager(session);
var repl = new ReplLoop(session, tokenTracker, commandDispatcher); // Auto-load session if it exists
await sessionManager.TryLoadAsync();
// Run REPL loop
var repl = new ReplLoop(session, startup.TokenTracker, commandDispatcher, sessionManager);
await repl.RunAsync(); await repl.RunAsync();
// Auto-save session on clean exit
await sessionManager.TrySaveAsync();

View File

@@ -0,0 +1,89 @@
using System.Net.Http.Headers;
using System.Text.Json;
namespace AnchorCli.Providers;
/// <summary>
/// Generic token extractor for any OpenAI-compatible endpoint.
/// Tries common header names and JSON body parsing.
/// </summary>
internal sealed class GenericTokenExtractor : ITokenExtractor
{
public string ProviderName => "Generic";
public (int inputTokens, int outputTokens)? ExtractTokens(HttpResponseHeaders headers, string? responseBody)
{
// Try various common header names
var headerNames = new[] {
"x-total-tokens",
"x-ai-response-tokens",
"x-tokens",
"x-prompt-tokens",
"x-completion-tokens"
};
foreach (var headerName in headerNames)
{
if (headers.TryGetValues(headerName, out var values))
{
if (int.TryParse(values.FirstOrDefault(), out var tokens))
{
// Assume all tokens are output if we can't determine split
return (0, tokens);
}
}
}
// Fallback: try parsing from response body JSON
if (!string.IsNullOrEmpty(responseBody))
{
try
{
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
// Try standard OpenAI format: usage.prompt_tokens, usage.completion_tokens
if (root.TryGetProperty("usage", out var usage))
{
var prompt = usage.TryGetProperty("prompt_tokens", out var p) ? p.GetInt32() : 0;
var completion = usage.TryGetProperty("completion_tokens", out var c) ? c.GetInt32() : 0;
if (prompt > 0 || completion > 0)
{
return (prompt, completion);
}
}
}
catch
{
// Ignore parsing errors
}
}
return null;
}
public int? ExtractLatency(HttpResponseHeaders headers)
{
// Try various common latency headers
var headerNames = new[] {
"x-response-time",
"x-response-timing",
"x-latency-ms",
"x-duration-ms"
};
foreach (var headerName in headerNames)
{
if (headers.TryGetValues(headerName, out var values))
{
if (int.TryParse(values.FirstOrDefault(), out var latency))
{
return latency;
}
}
}
return null;
}
}

61
Providers/GroqProvider.cs Normal file
View File

@@ -0,0 +1,61 @@
using System.Net.Http.Headers;
namespace AnchorCli.Providers;
/// <summary>
/// Token extractor for Groq responses.
/// </summary>
internal sealed class GroqTokenExtractor : ITokenExtractor
{
public string ProviderName => "Groq";
public (int inputTokens, int outputTokens)? ExtractTokens(HttpResponseHeaders headers, string? responseBody)
{
// Groq provides x-groq-tokens header (format: "n;<prompt_tokens>,n;<completion_tokens>")
if (headers.TryGetValues("x-groq-tokens", out var values))
{
var tokenStr = values.FirstOrDefault();
if (!string.IsNullOrEmpty(tokenStr))
{
// Parse format: "n;123,n;45" where first is prompt, second is completion
var parts = tokenStr.Split(',');
if (parts.Length >= 2)
{
var inputPart = parts[0].Trim();
var outputPart = parts[1].Trim();
// Extract numbers after "n;"
if (inputPart.StartsWith("n;") && outputPart.StartsWith("n;"))
{
if (int.TryParse(inputPart[2..], out var input) &&
int.TryParse(outputPart[2..], out var output))
{
return (input, output);
}
}
}
}
}
// Fallback: try parsing from response body
if (!string.IsNullOrEmpty(responseBody))
{
// TODO: Parse usage from JSON body if headers aren't available
}
return null;
}
public int? ExtractLatency(HttpResponseHeaders headers)
{
if (headers.TryGetValues("x-groq-response-time", out var values))
{
if (int.TryParse(values.FirstOrDefault(), out var latency))
{
return latency;
}
}
return null;
}
}

View File

@@ -0,0 +1,18 @@
using AnchorCli.OpenRouter;
namespace AnchorCli.Providers;
/// <summary>
/// Interface for fetching model pricing information.
/// </summary>
internal interface IPricingProvider
{
/// <summary>
/// Fetches pricing info for a specific model.
/// </summary>
Task<ModelInfo?> GetModelInfoAsync(string modelId, CancellationToken ct = default);
/// <summary>
/// Fetches all available models with pricing.
/// </summary>
Task<Dictionary<string, ModelInfo>> GetAllModelsAsync(CancellationToken ct = default);
}

View File

@@ -0,0 +1,25 @@
using System.Net.Http.Headers;
namespace AnchorCli.Providers;
/// <summary>
/// Interface for extracting token usage from provider responses.
/// </summary>
internal interface ITokenExtractor
{
/// <summary>
/// Extracts token usage from response headers and/or body.
/// Returns (inputTokens, outputTokens) or null if unavailable.
/// </summary>
(int inputTokens, int outputTokens)? ExtractTokens(HttpResponseHeaders headers, string? responseBody);
/// <summary>
/// Gets the latency from response headers (in ms).
/// </summary>
int? ExtractLatency(HttpResponseHeaders headers);
/// <summary>
/// Gets the provider name for display purposes.
/// </summary>
string ProviderName { get; }
}

View File

@@ -0,0 +1,39 @@
using System.Net.Http.Headers;
namespace AnchorCli.Providers;
/// <summary>
/// Token extractor for Ollama responses.
/// Ollama doesn't provide official token counts, so we estimate.
/// </summary>
internal sealed class OllamaTokenExtractor : ITokenExtractor
{
public string ProviderName => "Ollama";
public (int inputTokens, int outputTokens)? ExtractTokens(HttpResponseHeaders headers, string? responseBody)
{
// Ollama doesn't provide token headers
return null;
}
public int? ExtractLatency(HttpResponseHeaders headers)
{
// Ollama doesn't provide latency headers
return null;
}
/// <summary>
/// Estimates token count from text length (rough approximation).
/// Assumes ~4 characters per token on average.
/// </summary>
public static int EstimateTokens(string text)
{
if (string.IsNullOrEmpty(text))
{
return 0;
}
// Rough estimate: 4 characters per token
return text.Length / 4;
}
}

View File

@@ -0,0 +1,40 @@
using System.Net.Http.Json;
using System.Text.Json;
using AnchorCli.OpenRouter;
namespace AnchorCli.Providers;
/// <summary>
/// Pricing provider for OpenRouter API.
/// </summary>
internal sealed class OpenRouterProvider : IPricingProvider
{
private const string ModelsUrl = "https://openrouter.ai/api/v1/models";
private static readonly HttpClient Http = new();
private Dictionary<string, ModelInfo>? _models;
static OpenRouterProvider()
{
OpenRouterHeaders.ApplyTo(Http);
}
public async Task<Dictionary<string, ModelInfo>> 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;
}
public async Task<ModelInfo?> GetModelInfoAsync(string modelId, CancellationToken ct = default)
{
var models = await GetAllModelsAsync(ct);
return models.GetValueOrDefault(modelId);
}
}

View File

@@ -0,0 +1,42 @@
using System.Net.Http.Headers;
namespace AnchorCli.Providers;
/// <summary>
/// Token extractor for OpenRouter responses.
/// </summary>
internal sealed class OpenRouterTokenExtractor : ITokenExtractor
{
public string ProviderName => "OpenRouter";
public (int inputTokens, int outputTokens)? ExtractTokens(HttpResponseHeaders headers, string? responseBody)
{
// OpenRouter provides x-total-tokens header
if (headers.TryGetValues("x-total-tokens", out var values))
{
// Note: OpenRouter only provides total tokens, not split
// We'll estimate split based on typical ratios if needed
if (long.TryParse(values.FirstOrDefault(), out var total))
{
// For now, return total as output (placeholder until we have better splitting)
// In practice, you'd need to track input separately from the request
return (0, (int)total);
}
}
return null;
}
public int? ExtractLatency(HttpResponseHeaders headers)
{
if (headers.TryGetValues("x-response-timing", out var values))
{
if (int.TryParse(values.FirstOrDefault(), out var latency))
{
return latency;
}
}
return null;
}
}

View File

@@ -0,0 +1,70 @@
namespace AnchorCli.Providers;
/// <summary>
/// Factory for creating provider instances based on endpoint or provider name.
/// </summary>
internal static class ProviderFactory
{
/// <summary>
/// Creates a token extractor based on the provider name.
/// </summary>
public static ITokenExtractor CreateTokenExtractor(string providerName)
{
return providerName.ToLowerInvariant() switch
{
"openrouter" => new OpenRouterTokenExtractor(),
"groq" => new GroqTokenExtractor(),
"ollama" => new OllamaTokenExtractor(),
_ => new GenericTokenExtractor()
};
}
/// <summary>
/// Creates a token extractor by auto-detecting from the endpoint URL.
/// </summary>
public static ITokenExtractor CreateTokenExtractorForEndpoint(string endpoint)
{
if (string.IsNullOrEmpty(endpoint))
{
return new GenericTokenExtractor();
}
var url = endpoint.ToLowerInvariant();
if (url.Contains("openrouter"))
{
return new OpenRouterTokenExtractor();
}
if (url.Contains("groq"))
{
return new GroqTokenExtractor();
}
if (url.Contains("ollama") || url.Contains("localhost") || url.Contains("127.0.0.1"))
{
return new OllamaTokenExtractor();
}
return new GenericTokenExtractor();
}
/// <summary>
/// Creates a pricing provider based on the provider name.
/// Only OpenRouter has a pricing API currently.
/// </summary>
public static IPricingProvider? CreatePricingProvider(string providerName)
{
return providerName.ToLowerInvariant() switch
{
"openrouter" => new OpenRouterProvider(),
_ => null // Other providers don't have pricing APIs yet
};
}
/// <summary>
/// Determines if an endpoint is OpenRouter.
/// </summary>
public static bool IsOpenRouter(string endpoint) =>
!string.IsNullOrEmpty(endpoint) && endpoint.Contains("openrouter", StringComparison.OrdinalIgnoreCase);
}

135
README.md
View File

@@ -2,30 +2,14 @@
An AI-powered coding assistant built as a .NET 10.0 console application, featuring the **Hashline** technique for safe, precise file editing. 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 ## Features
- **Batch Editing**: Apply multiple replace/delete operations atomically in a single pass without line drift
- **Interactive REPL**: Chat with an AI model to edit files, manage directories, and execute commands - **Interactive REPL**: Chat with an AI model to edit files, manage directories, and execute commands
- **Slash Commands**: `/setup`, `/help`, `/exit`, `/clear`, `/status`, `/compact`, `/reset` - **Slash Commands**: `/setup`, `/help`, `/exit`, `/clear`, `/status`, `/compact`, `/reset`, `/load`, `/save`
- **Token Tracking**: Real-time token usage and cost per response, plus session totals - **Token Tracking**: Real-time token usage and cost per response, plus session totals
- **Model Pricing Display**: Shows current model pricing from OpenRouter in the header - **Model Pricing Display**: Shows current model pricing from OpenRouter in the header
- **Context Compaction**: Automatic conversation history compression when approaching context limits, including stale tool result compaction - **Context Compaction**: Automatic conversation history compression when approaching context limits
- **Comprehensive Toolset**: 15 tools for file operations, editing, directory management, and command execution - **Comprehensive Toolset**: 15 tools for file operations, editing, directory management, and command execution
- **AOT-Ready**: Native AOT compilation for ~12 MB binaries with no .NET runtime dependency - **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 - **Rich CLI**: Beautiful terminal output using Spectre.Console with tables, rules, and colored text
@@ -33,12 +17,14 @@ This eliminates:
- **OpenAI-Compatible**: Works with any OpenAI-compatible API (OpenAI, Ollama, Cerebras, Groq, OpenRouter, etc.) - **OpenAI-Compatible**: Works with any OpenAI-compatible API (OpenAI, Ollama, Cerebras, Groq, OpenRouter, etc.)
- **Ctrl+C Support**: Cancel in-progress responses without exiting - **Ctrl+C Support**: Cancel in-progress responses without exiting
## Requirements ## Installation
### Requirements
- .NET 10 SDK - .NET 10 SDK
- `clang` (for native AOT publish on Linux) - `clang` (for native AOT publish on Linux)
## Quick Start ### Quick Start
```bash ```bash
# Run the application # Run the application
@@ -46,11 +32,10 @@ dotnet run --project AnchorCli
# First time? The app will prompt you to run /setup # First time? The app will prompt you to run /setup
# Or run it explicitly: # Or run it explicitly:
dotnet run --project AnchorCli
/setup /setup
``` ```
## Native AOT Build ### Native AOT Build
```bash ```bash
dotnet publish AnchorCli -r linux-x64 -c Release dotnet publish AnchorCli -r linux-x64 -c Release
@@ -59,91 +44,17 @@ dotnet publish AnchorCli -r linux-x64 -c Release
The resulting binary is ~12 MB, has no .NET runtime dependency, and starts instantly. The resulting binary is ~12 MB, has no .NET runtime dependency, and starts instantly.
## Slash Commands ## Usage
| Command | Description |
|---|---|
| `/setup` | Run interactive TUI to configure API key and model (also accessible via `anchor setup` subcommand) |
| `/help` | Show available tools and commands |
| `/exit` | Exit the application |
| `/clear` | Clear the conversation history |
| `/status` | Show session token usage and cost |
| `/compact` | Manually trigger context compaction |
| `/reset` | Clear session and reset token tracker |
## 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
- `grep_recursive` - Search for a regex pattern across all files in a directory tree
- `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)
```
AnchorCli/
├── Program.cs # Entry point + CLI parsing
├── ReplLoop.cs # Main REPL loop with streaming, spinners, and cancellation
├── ChatSession.cs # AI chat client wrapper with message history
├── ToolRegistry.cs # Centralized tool registration and dispatch
├── AnchorConfig.cs # JSON file-based configuration (~APPDATA~/anchor/config.json)
├── ContextCompactor.cs # Conversation history compression
├── AppJsonContext.cs # Source-generated JSON context (AOT)
├── SetupTui.cs # Interactive setup TUI
├── Hashline/
│ ├── HashlineEncoder.cs # Adler-8 + position-seed hashing
│ └── HashlineValidator.cs # Anchor resolution + validation
├── Tools/
│ ├── FileTools.cs # read_file, grep_file, grep_recursive, 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
├── Commands/
│ ├── ICommand.cs # Command interface
│ ├── CommandRegistry.cs # Command registration
│ ├── CommandDispatcher.cs # Command dispatch logic
│ ├── ExitCommand.cs # /exit command
│ ├── HelpCommand.cs # /help command
│ ├── ClearCommand.cs # /clear command
│ ├── StatusCommand.cs # /status command
│ ├── CompactCommand.cs # /compact command
│ ├── ResetCommand.cs # /reset command
│ └── SetupCommand.cs # /setup command
└── OpenRouter/
└── PricingProvider.cs # Fetch model pricing from OpenRouter
```
## How It Works
1. **Setup**: Configure API credentials via the `/setup` command (or `anchor setup` subcommand) 1. **Setup**: Configure API credentials via the `/setup` command (or `anchor setup` subcommand)
2. **REPL Loop**: You interact with the AI through a conversational interface 2. **REPL Loop**: 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 3. **Tool Calling**: The AI can call 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 4. **Safe Execution**: Commands require explicit user approval before running
5. **Token Tracking**: Responses show token usage and cost; session totals are maintained
6. **Context Compaction**: When approaching context limits, conversation history is automatically compressed
7. **Safe Execution**: Commands require explicit user approval before running
## Supported Models ### Supported Models
AnchorCli works with any OpenAI-compatible API endpoint:
AnchorCli works with any OpenAI-compatible API endpoint, including:
- OpenAI (gpt-4o, gpt-4.1, etc.) - OpenAI (gpt-4o, gpt-4.1, etc.)
- Ollama (local models) - Ollama (local models)
- Cerebras - Cerebras
@@ -151,6 +62,22 @@ AnchorCli works with any OpenAI-compatible API endpoint, including:
- OpenRouter (qwen3.5-27b, etc.) - OpenRouter (qwen3.5-27b, etc.)
- Any custom OpenAI-compatible server - Any custom OpenAI-compatible server
## Configuration
Configuration is stored in `~APPDATA~/anchor/config.json` on Windows or `~/.anchor/config.json` on Linux/macOS.
Use the `/setup` command or run `anchor setup` from the command line to configure:
- API Key
- API Endpoint (default: OpenRouter)
- Model selection
## Documentation
- [**Available Tools**](docs/TOOLS.md) - Complete list of all tools organized by category
- [**Hashline Technique**](docs/HASHLINE.md) - Detailed explanation of Hashline editing with examples
- [**Slash Commands**](docs/COMMANDS.md) - All available slash commands with descriptions
## License ## License
MIT License - See LICENSE file for details. MIT License - See LICENSE file for details.

View File

@@ -1,28 +1,42 @@
using Microsoft.Extensions.AI; using System.Text;
using OpenAI;
using Spectre.Console; using Spectre.Console;
using AnchorCli.OpenRouter;
using AnchorCli.Commands; using AnchorCli.Commands;
using AnchorCli.Tools; using AnchorCli.Tools;
using AnchorCli.OpenRouter;
namespace AnchorCli; namespace AnchorCli;
/// <summary>
/// Manages the interactive REPL (Read-Eval-Print Loop) for user interaction.
/// Orchestrates input handling, command dispatching, and response display.
/// </summary>
internal sealed class ReplLoop internal sealed class ReplLoop
{ {
private readonly ChatSession _session; private readonly ChatSession _session;
private readonly TokenTracker _tokenTracker; private readonly TokenTracker _tokenTracker;
private readonly CommandDispatcher _commandDispatcher; private readonly CommandDispatcher _commandDispatcher;
private readonly SessionManager _sessionManager;
private readonly ResponseStreamer _streamer;
private readonly UsageDisplayer _usageDisplayer;
private readonly ContextCompactionService _compactionService;
public ReplLoop(ChatSession session, TokenTracker tokenTracker, CommandDispatcher commandDispatcher) public ReplLoop(
ChatSession session,
TokenTracker tokenTracker,
CommandDispatcher commandDispatcher,
SessionManager sessionManager)
{ {
_session = session; _session = session;
_tokenTracker = tokenTracker; _tokenTracker = tokenTracker;
_commandDispatcher = commandDispatcher; _commandDispatcher = commandDispatcher;
_sessionManager = sessionManager;
_streamer = new ResponseStreamer(session);
_usageDisplayer = new UsageDisplayer(tokenTracker);
_compactionService = new ContextCompactionService(session.Compactor, session.History, tokenTracker);
} }
public async Task RunAsync() public async Task RunAsync()
{ {
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.MarkupLine("[dim]Press [bold]Ctrl+C[/] to cancel the current response.[/]");
AnsiConsole.WriteLine(); AnsiConsole.WriteLine();
@@ -30,217 +44,36 @@ internal sealed class ReplLoop
Console.CancelKeyPress += (_, e) => Console.CancelKeyPress += (_, e) =>
{ {
e.Cancel = true; // Prevent process termination e.Cancel = true;
responseCts?.Cancel(); responseCts?.Cancel();
}; };
while (true) while (true)
{ {
string input = ReadLine.Read(" "); AnsiConsole.Markup("[grey] [/]");
string input = InputProcessor.ReadLine("Type your message, or use [bold]/help[/] to see commands.");
if (string.IsNullOrWhiteSpace(input)) continue; if (string.IsNullOrWhiteSpace(input)) continue;
if (await _commandDispatcher.TryExecuteAsync(input, default)) continue; if (await _commandDispatcher.TryExecuteAsync(input, default)) continue;
_session.History.Add(new ChatMessage(ChatRole.User, input)); _session.History.Add(new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.User, input));
int turnStartIndex = _session.History.Count;
AnsiConsole.WriteLine(); AnsiConsole.WriteLine();
responseCts?.Dispose();
responseCts = new CancellationTokenSource(); responseCts = new CancellationTokenSource();
string fullResponse = "";
try try
{ {
await using var stream = _session await ProcessTurnAsync(responseCts.Token);
.GetStreamingResponseAsync(responseCts.Token)
.GetAsyncEnumerator(responseCts.Token);
string? firstChunk = null;
int respIn = 0, respOut = 0;
void CaptureUsage(ChatResponseUpdate update)
{
if (update.RawRepresentation is OpenAI.Chat.StreamingChatCompletionUpdate raw
&& raw.Usage != null)
{
respIn = raw.Usage.InputTokenCount; // last call = actual context size
respOut += raw.Usage.OutputTokenCount; // additive — each round generates new output
}
}
object consoleLock = new();
using var spinnerCts = CancellationTokenSource.CreateLinkedTokenSource(responseCts.Token);
bool showSpinner = true;
CommandTool.PauseSpinner = () =>
{
lock (consoleLock)
{
showSpinner = false;
Console.Write("\r" + new string(' ', 40) + "\r");
}
};
CommandTool.ResumeSpinner = () =>
{
lock (consoleLock)
{
showSpinner = true;
}
};
FileTools.OnFileRead = _ =>
{
int n = ContextCompactor.CompactStaleToolResults(_session.History);
if (n > 0)
AnsiConsole.MarkupLine(
$"[dim grey] ♻ Compacted {n} stale tool result(s)[/]");
};
var spinnerTask = Task.Run(async () =>
{
var frames = Spinner.Known.BouncingBar.Frames;
var interval = Spinner.Known.BouncingBar.Interval;
int i = 0;
Console.Write("\x1b[?25l");
try
{
while (!spinnerCts.Token.IsCancellationRequested)
{
lock (consoleLock)
{
if (showSpinner && !spinnerCts.Token.IsCancellationRequested)
{
var frame = frames[i % frames.Count];
Console.Write($"\r\x1b[38;5;69m{frame}\x1b[0m Thinking...");
i++;
}
}
try { await Task.Delay(interval, spinnerCts.Token); } catch { }
}
}
finally
{
lock (consoleLock)
{
if (showSpinner)
Console.Write("\r" + new string(' ', 40) + "\r");
Console.Write("\x1b[?25h");
}
}
});
try
{
while (await stream.MoveNextAsync())
{
responseCts.Token.ThrowIfCancellationRequested();
CaptureUsage(stream.Current);
if (!string.IsNullOrEmpty(stream.Current.Text))
{
firstChunk = stream.Current.Text;
fullResponse = firstChunk;
break;
}
}
}
finally
{
spinnerCts.Cancel();
await Task.WhenAny(spinnerTask);
CommandTool.PauseSpinner = null;
CommandTool.ResumeSpinner = null;
FileTools.OnFileRead = null;
}
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;
}
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();
_session.History.Add(new ChatMessage(ChatRole.Assistant, fullResponse));
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 _session.Compactor.TryCompactAsync(_session.History, default));
if (compacted)
{
AnsiConsole.MarkupLine(
$"[green]✓ Context compacted ({_session.History.Count} messages remaining)[/]");
}
else
{
AnsiConsole.MarkupLine(
"[dim grey] (compaction skipped — not enough history to compress)[/]");
}
AnsiConsole.WriteLine();
}
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
AnsiConsole.WriteLine(); HandleCancellation();
AnsiConsole.MarkupLine("[dim grey] ✗ Cancelled[/]");
AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
AnsiConsole.WriteLine();
if (!string.IsNullOrEmpty(fullResponse))
{
_session.History.Add(new ChatMessage(ChatRole.Assistant, fullResponse));
}
_session.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) catch (Exception ex)
{ {
AnsiConsole.WriteLine(); DisplayError(ex);
AnsiConsole.Write(
new Panel($"[red]{Markup.Escape(ex.Message)}[/]")
.Header("[bold red] Error [/]")
.BorderColor(Color.Red)
.RoundedBorder()
.Padding(1, 0));
AnsiConsole.WriteLine();
} }
finally finally
{ {
@@ -249,4 +82,91 @@ internal sealed class ReplLoop
} }
} }
} }
private async Task ProcessTurnAsync(CancellationToken cancellationToken)
{
using var spinner = new SpinnerService();
spinner.Start(cancellationToken);
// Configure tool callbacks for spinner control and stale result compaction
var originalPause = CommandTool.PauseSpinner;
var originalResume = CommandTool.ResumeSpinner;
var originalOnFileRead = FileTools.OnFileRead;
CommandTool.PauseSpinner = spinner.Pause;
CommandTool.ResumeSpinner = spinner.Resume;
FileTools.OnFileRead = _ =>
{
int n = ContextCompactor.CompactStaleToolResults(_session.History);
if (n > 0)
AnsiConsole.MarkupLine($"[dim grey] ♻ Compacted {n} stale tool result(s)[/]");
};
var responseBuilder = new StringBuilder();
bool firstChunkDisplayed = false;
try
{
await foreach (var chunk in _streamer.StreamAsync(cancellationToken))
{
// Stop spinner before displaying first chunk
if (!firstChunkDisplayed)
{
await spinner.StopAsync();
firstChunkDisplayed = true;
}
AnsiConsole.Markup(Markup.Escape(chunk));
responseBuilder.Append(chunk);
}
}
finally
{
if (!firstChunkDisplayed)
{
await spinner.StopAsync();
}
CommandTool.PauseSpinner = originalPause;
CommandTool.ResumeSpinner = originalResume;
FileTools.OnFileRead = originalOnFileRead;
}
var fullResponse = responseBuilder.ToString();
// Display usage statistics
_usageDisplayer.Display(_streamer.LastInputTokens, _streamer.LastOutputTokens);
_usageDisplayer.DisplaySeparator();
// Add response to history
_session.History.Add(new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.Assistant, fullResponse));
// Check for context compaction
await _compactionService.TryCompactAsync();
// Save session after turn completes
await _sessionManager.SaveAfterTurnAsync();
}
private void HandleCancellation()
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[dim grey] ✗ Cancelled[/]");
AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
AnsiConsole.WriteLine();
_session.History.Add(new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.User,
"[Response cancelled by user. Acknowledge briefly and wait for the next instruction. Do not repeat what was already said.]"));
}
private void DisplayError(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();
}
} }

60
ResponseStreamer.cs Normal file
View File

@@ -0,0 +1,60 @@
using Microsoft.Extensions.AI;
using OpenAI;
namespace AnchorCli;
/// <summary>
/// Handles streaming responses from the chat client, including token usage capture.
/// </summary>
internal sealed class ResponseStreamer
{
private readonly ChatSession _session;
public ResponseStreamer(ChatSession session)
{
_session = session;
}
/// <summary>
/// Streams a response from the session and captures token usage.
/// Returns an async enumerable that yields text chunks as they arrive.
/// </summary>
public async IAsyncEnumerable<string> StreamAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await using var stream = _session
.GetStreamingResponseAsync(cancellationToken)
.GetAsyncEnumerator(cancellationToken);
int respIn = 0, respOut = 0;
void CaptureUsage(ChatResponseUpdate update)
{
if (update.RawRepresentation is OpenAI.Chat.StreamingChatCompletionUpdate raw
&& raw.Usage != null)
{
respIn = raw.Usage.InputTokenCount;
respOut += raw.Usage.OutputTokenCount;
}
}
// Stream all chunks
while (await stream.MoveNextAsync())
{
cancellationToken.ThrowIfCancellationRequested();
CaptureUsage(stream.Current);
var text = stream.Current.Text;
if (!string.IsNullOrEmpty(text))
{
yield return text;
}
}
// Store final usage stats
LastInputTokens = respIn;
LastOutputTokens = respOut;
}
public int LastInputTokens { get; private set; }
public int LastOutputTokens { get; private set; }
}

View File

@@ -1,334 +0,0 @@
# Sandbox Implementation Plan for AnchorCli
## Overview
By default, all file and directory operations are restricted to the current working directory (CWD).
Users can bypass this restriction with the `--no-sandbox` flag.
## Usage
```bash
# Default: sandbox enabled (operations limited to CWD)
anchor
# Disable sandbox (allow operations anywhere)
anchor --no-sandbox
```
## Architecture
The implementation leverages the existing `ResolvePath()` methods in `FileTools` and `DirTools`.
Since tools are static classes without dependency injection, we use a static `SandboxContext` class.
---
## Implementation Steps
### Step 1: Create `SandboxContext.cs`
Create a new file `Core/SandboxContext.cs`:
```csharp
using System;
namespace AnchorCli;
/// <summary>
/// Static context holding sandbox configuration.
/// Checked by ResolvePath() to validate paths are within working directory.
/// </summary>
internal static class SandboxContext
{
private static string? _workingDirectory;
private static bool _enabled = true;
public static bool Enabled
{
get => _enabled;
set => _enabled = value;
}
public static string WorkingDirectory
{
get => _workingDirectory ?? Environment.CurrentDirectory;
set => _workingDirectory = value;
}
/// <summary>
/// Validates that a resolved path is within the working directory (if sandbox is enabled).
/// Returns the resolved path if valid, or null if outside sandbox (no exception thrown).
/// When null is returned, the calling tool should return an error message to the agent.
/// </summary>
public static string? ValidatePath(string resolvedPath)
{
if (!_enabled)
return resolvedPath;
var workDir = WorkingDirectory;
// Normalize paths for comparison
var normalizedPath = Path.GetFullPath(resolvedPath).TrimEnd(Path.DirectorySeparatorChar);
var normalizedWorkDir = Path.GetFullPath(workDir).TrimEnd(Path.DirectorySeparatorChar);
// Check if path starts with working directory
if (!normalizedPath.StartsWith(normalizedWorkDir, StringComparison.OrdinalIgnoreCase))
{
// Return null to signal violation - caller handles error messaging
return null;
}
return resolvedPath;
}
public static void Initialize(bool sandboxEnabled)
{
_enabled = sandboxEnabled;
_workingDirectory = Environment.CurrentDirectory;
}
}
```
---
### Step 2: Modify `Program.cs`
Add argument parsing and initialize the sandbox context:
**After line 15** (after the `setup` subcommand check), add:
```csharp
// ── Parse sandbox flag ──────────────────────────────────────────────────
bool sandboxEnabled = !args.Contains("--no-sandbox");
SandboxContext.Initialize(sandboxEnabled);
if (!sandboxEnabled)
{
AnsiConsole.MarkupLine("[dim grey]Sandbox disabled (--no-sandbox)[/]");
}
```
---
### Step 3: Update `FileTools.ResolvePath()`
**Replace lines 322-323** with:
internal static string? ResolvePath(string path, out string? errorMessage)
{
errorMessage = null;
var resolved = Path.IsPathRooted(path)
? path
: Path.GetFullPath(path, Environment.CurrentDirectory);
var validated = SandboxContext.ValidatePath(resolved);
if (validated == null)
{
errorMessage = $"Sandbox violation: Path '{path}' is outside working directory '{SandboxContext.WorkingDirectory}'. Use --no-sandbox to disable restrictions.";
return null;
}
return validated;
}
---
### Step 4: Update `DirTools.ResolvePath()`
**Replace lines 84-85** with:
```csharp
internal static string? ResolvePath(string path, out string? errorMessage)
{
errorMessage = null;
var resolved = Path.IsPathRooted(path)
? path
: Path.GetFullPath(path, Environment.CurrentDirectory);
var validated = SandboxContext.ValidatePath(resolved);
if (validated == null)
{
errorMessage = $"Sandbox violation: Path '{path}' is outside working directory '{SandboxContext.WorkingDirectory}'. Use --no-sandbox to disable restrictions.";
return null;
}
return validated;
}
---
### Step 5: Update Tool Descriptions (Optional but Recommended)
Update the `[Description]` attributes to mention sandbox behavior:
**FileTools.cs - ReadFile** (line 23):
```csharp
[Description("Read a file. Max 200 lines per call. Returns lines with line:hash| anchors. Sandbox: restricted to working directory unless --no-sandbox is used. IMPORTANT: Call GrepFile first...")]
```
**DirTools.cs - CreateDir** (line 63):
```csharp
[Description("Create a new directory. Creates parent directories if they don't exist. Sandbox: restricted to working directory unless --no-sandbox is used. Returns OK on success...")]
```
Repeat for other tools as needed.
---
## How Tools Handle Sandbox Violations
Each tool that uses `ResolvePath()` must check for `null` return and handle it gracefully:
### FileTools Pattern
```csharp
// Before (old code):
var resolvedPath = ResolvePath(path);
var content = File.ReadAllText(resolvedPath);
// After (new code):
var resolvedPath = ResolvePath(path, out var errorMessage);
if (resolvedPath == null)
return $"ERROR: {errorMessage}";
var content = File.ReadAllText(resolvedPath);
```
### DirTools Pattern
```csharp
// Before (old code):
var resolvedPath = ResolvePath(path);
Directory.CreateDirectory(resolvedPath);
// After (new code):
var resolvedPath = ResolvePath(path, out var errorMessage);
if (resolvedPath == null)
return $"ERROR: {errorMessage}";
Directory.CreateDirectory(resolvedPath);
return "OK";
```
### EditTools
No changes needed - it already calls `FileTools.ResolvePath()`, so the sandbox check happens there.
### Tools That Don't Use ResolvePath
- `ListDir` with no path argument (uses current directory)
- `GetFileInfo` - needs to be updated to use `ResolvePath()`
- `FindFiles` - needs to be updated to validate the search path
---
---
## Error Handling - No Crashes
When a sandbox violation occurs, the program **does not crash**. Instead:
1. `ResolvePath()` returns `null` and sets `errorMessage`
2. The tool returns the error message to the agent
3. The agent sees the error and can continue the conversation
4. The user sees a clear error message in the chat
**Example tool implementation pattern:**
```csharp
public static async Task<string> ReadFile(string path, int startLine, int endLine)
{
var resolvedPath = ResolvePath(path, out var errorMessage);
if (resolvedPath == null)
return $"ERROR: {errorMessage}"; // Return error, don't throw
// ... rest of the tool logic
}
```
**What the agent sees:**
```
Tool result: ERROR: Sandbox violation: Path '/home/tomi/.ssh' is outside working directory '/home/tomi/dev/anchor'. Use --no-sandbox to disable restrictions.
```
**What the user sees in chat:**
> The agent tried to read `/home/tomi/.ssh` but was blocked by the sandbox. The agent can now adjust its approach or ask you to run with `--no-sandbox`.
---
## Edge Cases Handled
| Case | Behavior |
|------|----------|
| **Symlinks inside CWD pointing outside** | Follows symlink (user-created link = intentional) |
| **Path traversal (`../..`)** | Blocked if result is outside CWD |
| **Absolute paths** | Validated against CWD |
| **Network paths** | Blocked (not under CWD) |
| **Case sensitivity** | Uses `OrdinalIgnoreCase` for cross-platform compatibility |
---
## Security Notes
⚠️ **The sandbox is a safety feature, not a security boundary.**
- It prevents **accidental** modifications to system files
- It does **not** protect against malicious intent
- `CommandTool.ExecuteCommand()` can still run arbitrary shell commands
- A determined user can always use `--no-sandbox`
For true isolation, run anchor in a container or VM.
---
## Testing Checklist
- [ ] `ReadFile` on file inside CWD → **Success**
- [ ] `ReadFile` on file outside CWD → **Sandbox violation error**
- [ ] `ReadFile` with `../` traversal outside CWD → **Sandbox violation error**
- [ ] `CreateDir` outside CWD → **Sandbox violation error**
- [ ] `anchor --no-sandbox` then read `/etc/passwd`**Success**
- [ ] Symlink inside CWD pointing to `/etc/passwd`**Success** (user-created link)
- [ ] Case variations on Windows (`C:\Users` vs `c:\users`) → **Success**
---
## Migration Guide
### Existing Workflows
If you have scripts or workflows that rely on accessing files outside the project:
```bash
# Update your scripts to use --no-sandbox
anchor --no-sandbox
```
### CI/CD Integration
For CI environments where sandbox may not be needed:
```yaml
# GitHub Actions example
- name: Run anchor
run: anchor --no-sandbox
```
---
## Files Modified
| File | Changes |
|------|---------|
| `Core/SandboxContext.cs` | **New file** - Static sandbox state and validation |
| `Program.cs` | Add `--no-sandbox` parsing, call `SandboxContext.Initialize()` |
| `Tools/FileTools.cs` | Update `ResolvePath()` signature to return `string?` with `out errorMessage`; update all tool methods to check for null |
| `Tools/DirTools.cs` | Update `ResolvePath()` signature to return `string?` with `out errorMessage`; update all tool methods to check for null |
| `Tools/EditTools.cs` | No changes (uses `FileTools.ResolvePath()`, sandbox check happens there) |
| `Tools/CommandTool.cs` | **Not sandboxed** - shell commands can access any path (documented limitation) |
---
## Future Enhancements
- **Allowlist**: Let users specify additional safe directories via config
- **Per-tool sandbox**: Some tools (e.g., `GrepRecursive`) could have different rules
- **Audit mode**: Log all file operations for review
- **Interactive prompt**: Ask for confirmation before violating sandbox instead of hard fail

83
SessionManager.cs Normal file
View File

@@ -0,0 +1,83 @@
using Spectre.Console;
namespace AnchorCli;
/// <summary>
/// Manages session persistence, including auto-load on startup and auto-save on exit.
/// </summary>
internal sealed class SessionManager
{
private readonly ChatSession _session;
private readonly string _sessionPath;
public SessionManager(ChatSession session, string sessionPath = ".anchor/session.json")
{
_session = session;
_sessionPath = sessionPath;
}
/// <summary>
/// Attempts to load a session from disk. Returns true if successful.
/// </summary>
public async Task<bool> TryLoadAsync(CancellationToken cancellationToken = default)
{
if (!File.Exists(_sessionPath))
{
return false;
}
try
{
await _session.LoadAsync(_sessionPath, cancellationToken);
AnsiConsole.MarkupLine($"[dim grey]Auto-loaded previous session.[/]");
// Print the last message if there is one
if (_session.History.Count > 1)
{
var lastMessage = _session.History[^1];
var preview = lastMessage.Text.Length > 280
? lastMessage.Text[..277] + "..."
: lastMessage.Text;
AnsiConsole.MarkupLine($"[dim grey] Last message: {Markup.Escape(preview)}[/]");
}
return true;
}
catch
{
// Ignore load errors
return false;
}
}
/// <summary>
/// Attempts to save the session to disk. Returns true if successful.
/// </summary>
public async Task<bool> TrySaveAsync(CancellationToken cancellationToken = default)
{
try
{
var directory = Path.GetDirectoryName(_sessionPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
await _session.SaveAsync(_sessionPath, cancellationToken);
return true;
}
catch
{
// Ignore save errors
return false;
}
}
/// <summary>
/// Saves the session after an LLM turn completes.
/// </summary>
public async Task SaveAfterTurnAsync(CancellationToken cancellationToken = default)
{
await TrySaveAsync(cancellationToken);
}
}

View File

@@ -27,16 +27,93 @@ internal static class SetupTui
AnsiConsole.WriteLine(); AnsiConsole.WriteLine();
// ── Provider ────────────────────────────────────────────────────
var providers = new List<(string Value, string Description)>
{
("openrouter", "default, pricing support"),
("groq", "high-speed inference"),
("ollama", "local, no auth required"),
("openai", "official OpenAI API"),
("custom", "generic OpenAI-compatible endpoint")
};
string currentProvider = config.Provider ?? "openrouter";
AnsiConsole.MarkupLine($" Current provider: [cyan]{Markup.Escape(currentProvider)}[/]");
var selectedProviderChoice = AnsiConsole.Prompt(
new SelectionPrompt<(string Value, string Description)>()
.Title(" Select a provider:")
.UseConverter(p => p.Value + (string.IsNullOrEmpty(p.Description) ? "" : $" [dim]({p.Description})[/]"))
.AddChoices(providers));
config.Provider = selectedProviderChoice.Value;
if (config.Provider == "custom")
{
string customEndpoint = AnsiConsole.Prompt(
new TextPrompt<string>(" Enter endpoint URL:")
.DefaultValue(config.Endpoint)
.AllowEmpty());
if (!string.IsNullOrWhiteSpace(customEndpoint))
{
config.Endpoint = customEndpoint.Trim();
}
}
else
{
config.Endpoint = config.Provider.ToLowerInvariant() switch
{
"openrouter" => "https://openrouter.ai/api/v1",
"groq" => "https://api.groq.com/openai/v1",
"ollama" => "http://localhost:11434/v1",
"openai" => "https://api.openai.com/v1",
_ => config.Endpoint
};
}
AnsiConsole.WriteLine();
// ── Model ───────────────────────────────────────────────────── // ── Model ─────────────────────────────────────────────────────
AnsiConsole.MarkupLine($" Current model: [cyan]{Markup.Escape(config.Model)}[/]"); AnsiConsole.MarkupLine($" Current model: [cyan]{Markup.Escape(config.Model)}[/]");
var models = new List<(string Value, string Description)> var models = config.Provider.ToLowerInvariant() switch
{ {
("qwen/qwen3.5-397b-a17b", "smart, expensive"), "groq" => new List<(string Value, string Description)>
("qwen/qwen3.5-35b-a3b", "cheapest"), {
("qwen/qwen3.5-27b", "fast"), ("llama-3.3-70b-versatile", "fast, powerful"),
("qwen/qwen3.5-122b-a10b", "smart"), ("llama-3.1-8b-instant", "very fast"),
("Custom...", "") ("mixtral-8x7b-32768", "sparse MoE"),
("gemma2-9b-it", "Google's Gemma"),
("Custom...", "")
},
"ollama" => new List<(string Value, string Description)>
{
("llama3.2", "Meta's Llama 3.2"),
("qwen2.5", "Alibaba Qwen"),
("mistral", "Mistral AI"),
("codellama", "code-focused"),
("Custom...", "")
},
"openai" => new List<(string Value, string Description)>
{
("gpt-4o", "most capable"),
("gpt-4o-mini", "fast, affordable"),
("o1-preview", "reasoning model"),
("Custom...", "")
},
_ => new List<(string Value, string Description)>
{
("qwen/qwen3.5-397b-a17b", "smart, expensive"),
("qwen/qwen3.5-122b-a10b", "faster"),
("qwen/qwen3.5-27b", "fast"),
("qwen/qwen3.5-flash-02-23", "cloud, fast"),
("qwen/qwen3.5-plus-02-15", "cloud, smart"),
("Custom...", "")
}
}; };
string selectedModel = AnsiConsole.Prompt( string selectedModel = AnsiConsole.Prompt(

100
SpinnerService.cs Normal file
View File

@@ -0,0 +1,100 @@
using Spectre.Console;
namespace AnchorCli;
/// <summary>
/// Manages the "thinking" spinner animation during AI response generation.
/// </summary>
internal sealed class SpinnerService : IDisposable
{
private readonly object _consoleLock = new();
private CancellationTokenSource? _spinnerCts;
private Task? _spinnerTask;
private bool _showSpinner = true;
private bool _disposed;
/// <summary>
/// Starts the spinner animation.
/// </summary>
public void Start(CancellationToken cancellationToken)
{
_spinnerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_showSpinner = true;
_spinnerTask = Task.Run(async () =>
{
var frames = Spinner.Known.BouncingBar.Frames;
var interval = Spinner.Known.BouncingBar.Interval;
int i = 0;
Console.Write("\x1b[?25l");
try
{
while (!_spinnerCts.Token.IsCancellationRequested)
{
lock (_consoleLock)
{
if (_showSpinner && !_spinnerCts.Token.IsCancellationRequested)
{
var frame = frames[i % frames.Count];
Console.Write($"\r\x1b[38;5;69m{frame}\x1b[0m Thinking...");
i++;
}
}
try { await Task.Delay(interval, _spinnerCts.Token); } catch { }
}
}
finally
{
lock (_consoleLock)
{
if (_showSpinner)
Console.Write("\r" + new string(' ', 40) + "\r");
Console.Write("\x1b[?25h");
}
}
});
}
/// <summary>
/// Stops the spinner animation and waits for it to complete.
/// </summary>
public async Task StopAsync()
{
_spinnerCts?.Cancel();
if (_spinnerTask != null)
{
await Task.WhenAny(_spinnerTask);
}
}
/// <summary>
/// Pauses the spinner (e.g., during tool execution).
/// </summary>
public void Pause()
{
lock (_consoleLock)
{
_showSpinner = false;
Console.Write("\r" + new string(' ', 40) + "\r");
}
}
/// <summary>
/// Resumes the spinner after being paused.
/// </summary>
public void Resume()
{
lock (_consoleLock)
{
_showSpinner = true;
}
}
public void Dispose()
{
if (_disposed) return;
_spinnerCts?.Dispose();
_disposed = true;
}
}

View File

@@ -9,26 +9,22 @@ internal static class ToolRegistry
{ {
var jsonOptions = AppJsonContext.Default.Options; var jsonOptions = AppJsonContext.Default.Options;
return new List<AITool> return
{ [
AIFunctionFactory.Create(FileTools.ReadFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.ReadFile, name: "read_file", serializerOptions: jsonOptions),
AIFunctionFactory.Create(FileTools.GrepFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.Grep, name: "grep", serializerOptions: jsonOptions),
AIFunctionFactory.Create(FileTools.ListDir, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.ListDir, name: "list_dir", serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.ReplaceLines, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.ReplaceLines, name: "replace_lines", serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.InsertAfter, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.DeleteRange, name: "delete_range", serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.DeleteRange, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.BatchEdit, name: "batch_edit", serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.CreateFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.Delete, name: "delete_file", serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.DeleteFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.FindFiles, name: "find_files", serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.RenameFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.GetFileInfo, name: "get_file_info", serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.CopyFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.WriteToFile, name: "write_to_file", serializerOptions: jsonOptions),
AIFunctionFactory.Create(DirTools.CreateDir, serializerOptions: jsonOptions), AIFunctionFactory.Create(CommandTool.ExecuteCommand, name: "execute_command", serializerOptions: jsonOptions),
AIFunctionFactory.Create(DirTools.RenameDir, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.MoveFile, name: "rename_file", serializerOptions: jsonOptions),
AIFunctionFactory.Create(DirTools.DeleteDir, serializerOptions: jsonOptions), AIFunctionFactory.Create(DirTools.RenameDir, name: "rename_dir", serializerOptions: jsonOptions),
AIFunctionFactory.Create(FileTools.FindFiles, serializerOptions: jsonOptions), AIFunctionFactory.Create(DirTools.CreateDir, name: "create_dir", 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),
};
} }
} }

View File

@@ -21,7 +21,7 @@ internal static class CommandTool
public static string ExecuteCommand( public static string ExecuteCommand(
[Description("The shell command to execute.")] string command) [Description("The shell command to execute.")] string command)
{ {
Log($"Command request: {command}"); Log($" ● execute_command: {command}");
// Prompt for user approval // Prompt for user approval
PauseSpinner?.Invoke(); PauseSpinner?.Invoke();

View File

@@ -17,7 +17,7 @@ internal static class DirTools
{ {
sourcePath = ResolvePath(sourcePath); sourcePath = ResolvePath(sourcePath);
destinationPath = ResolvePath(destinationPath); destinationPath = ResolvePath(destinationPath);
Log($"Renaming/moving directory: {sourcePath} -> {destinationPath}"); Log($" ● rename_dir: {sourcePath} -> {destinationPath}");
if (!Directory.Exists(sourcePath)) if (!Directory.Exists(sourcePath))
return $"ERROR: Directory not found: {sourcePath}"; return $"ERROR: Directory not found: {sourcePath}";
@@ -39,33 +39,12 @@ internal static class DirTools
} }
} }
[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.")] [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( public static string CreateDir(
[Description("Path to the directory to create.")] string path) [Description("Path to the directory to create.")] string path)
{ {
path = ResolvePath(path); path = ResolvePath(path);
Log($"Creating directory: {path}"); Log($" ● create_dir: {path}");
if (Directory.Exists(path)) if (Directory.Exists(path))
return $"ERROR: Directory already exists: {path}"; return $"ERROR: Directory already exists: {path}";

View File

@@ -4,6 +4,15 @@ using AnchorCli.Hashline;
namespace AnchorCli.Tools; namespace AnchorCli.Tools;
/// <summary>
/// Represents a single operation within a batch edit.
public record BatchOperation(
[property: Description("Operation type: 'replace' or 'delete'")] string Type,
[property: Description("First line's line:hash anchor (e.g. '5:a3')")] string? StartAnchor,
[property: Description("Last line's line:hash anchor (e.g. '8:d4')")] string? EndAnchor,
[property: Description("Text content to insert. Required for 'replace' operations.")] string[]? Content);
/// <summary> /// <summary>
/// Mutating file tools exposed to the LLM as AIFunctions. /// Mutating file tools exposed to the LLM as AIFunctions.
/// Every operation validates Hashline anchors (line:hash format) before touching the file. /// Every operation validates Hashline anchors (line:hash format) before touching the file.
@@ -61,12 +70,11 @@ internal static partial class EditTools
{ {
newLines = SanitizeNewLines(newLines); newLines = SanitizeNewLines(newLines);
path = FileTools.ResolvePath(path); path = FileTools.ResolvePath(path);
Log($"REPLACE_LINES: {path}"); Log($" ● replace_lines: {path}");
Log($" Range: {startAnchor} -> {endAnchor}"); Log($" {startAnchor.Split(':')[0]}-{endAnchor.Split(':')[0]} lines -> {newLines.Length} new lines");
Log($" Replacing {endAnchor.Split(':')[0]}-{startAnchor.Split(':')[0]} lines with {newLines.Length} new lines");
if (!File.Exists(path)) if (!File.Exists(path))
return $"ERROR: File not found: {path}"; return $"ERROR: File not found: {path}\n Check the correct path and try again.";
try try
{ {
@@ -74,7 +82,7 @@ internal static partial class EditTools
if (!HashlineValidator.TryResolveRange(startAnchor, endAnchor, lines, if (!HashlineValidator.TryResolveRange(startAnchor, endAnchor, lines,
out int startIdx, out int endIdx, out string error)) out int startIdx, out int endIdx, out string error))
return $"ERROR: {error}"; return $"ERROR: Anchor validation failed\n{error}";
var result = new List<string>(lines.Length - (endIdx - startIdx + 1) + newLines.Length); var result = new List<string>(lines.Length - (endIdx - startIdx + 1) + newLines.Length);
result.AddRange(lines[..startIdx]); result.AddRange(lines[..startIdx]);
@@ -86,48 +94,10 @@ internal static partial class EditTools
} }
catch (Exception ex) catch (Exception ex)
{ {
return $"ERROR modifying '{path}': {ex.Message}"; return $"ERROR modifying '{path}': {ex.Message}.\nThis is a bug. Tell the user about it.";
} }
} }
[Description("Insert lines after the specified line:hash anchor.")]
public static string InsertAfter(
[Description("Path to the file.")] string path,
[Description("line:hash anchor to insert after (e.g. '3:0e').")] string anchor,
[Description("Raw source code to insert. Do NOT include 'lineNumber:hash|' prefixes.")] 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]}");
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}";
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.")] [Description("Delete a range of lines.")]
public static string DeleteRange( public static string DeleteRange(
@@ -136,7 +106,8 @@ internal static partial class EditTools
[Description("Last line's line:hash anchor (e.g. '6:19').")] string endAnchor) [Description("Last line's line:hash anchor (e.g. '6:19').")] string endAnchor)
{ {
path = FileTools.ResolvePath(path); path = FileTools.ResolvePath(path);
Log($"Deleting lines in file: {path}"); Log($" ● delete_range: {path}");
Log($" {startAnchor.Split(':')[0]}-{endAnchor.Split(':')[0]} lines");
if (!File.Exists(path)) if (!File.Exists(path))
return $"ERROR: File not found: {path}"; return $"ERROR: File not found: {path}";
@@ -158,157 +129,261 @@ internal static partial class EditTools
} }
catch (Exception ex) catch (Exception ex)
{ {
return $"ERROR modifying '{path}': {ex.Message}"; return $"ERROR modifying '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
} }
} }
[Description("Create a new file (parents auto-created). Max initial lines: 200. Alternatively, append lines later.")]
public static string CreateFile(
[Description("Path to the new file to create.")] string path, [Description("Delete a file or directory. Use mode='file' to delete a file, mode='dir' to delete a directory.")]
[Description("Optional initial raw source code. Do NOT include 'lineNumber:hash|' prefixes.")] string[]? initialLines = null) public static string Delete(
[Description("Path to the file or directory to delete.")] string path,
[Description("Type of deletion: 'file' or 'dir'. Defaults to 'file'.")] string mode = "file")
{ {
path = FileTools.ResolvePath(path); path = FileTools.ResolvePath(path);
Log($"Creating file: {path}"); string targetType = mode.Equals("dir", StringComparison.CurrentCultureIgnoreCase) ? "directory" : "file";
Log($" ● delete_{targetType}: {path}");
if (File.Exists(path)) if (mode.Equals("dir", StringComparison.CurrentCultureIgnoreCase))
return $"ERROR: File already exists: {path}"; {
if (!Directory.Exists(path))
return $"ERROR: Directory not found: {path}";
try
{
Directory.Delete(path, true);
return $"OK: Directory deleted: '{path}'";
}
catch (Exception ex)
{
return $"ERROR deleting directory '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
}
}
else
{
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}\nThis is a bug. Tell the user about it.";
}
}
}
[Description("Move or copy a file to a new location.")]
public static string MoveFile(
[Description("Current path to the file.")] string sourcePath,
[Description("New path for the file.")] string destinationPath,
[Description("If true, copy the file instead of moving it. Defaults to false.")] bool copy = false)
{
sourcePath = FileTools.ResolvePath(sourcePath);
destinationPath = FileTools.ResolvePath(destinationPath);
string action = copy ? "copy" : "move";
Log($" ● {action}_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);
if (copy)
File.Copy(sourcePath, destinationPath);
else
File.Move(sourcePath, destinationPath);
return copy ? $"OK (copied to {destinationPath})" : $"OK (moved to {destinationPath})";
}
catch (Exception ex)
{
return $"ERROR {action.ToLower()} file: {ex.Message}\nThis is a bug. Tell the user about it.";
}
}
[Description("Write to a file with different modes: create, append, or insert.")]
public static string WriteToFile(
[Description("Path to the file.")] string path,
[Description("Content to write.")] string[] content,
[Description("Write mode: 'create' (error if exists), 'append' (creates if missing), 'insert' (requires anchor)")] string mode = "create",
[Description("line:hash anchor to insert after (required for mode='insert', e.g. '3:0e').")] string? anchor = null)
{
content = SanitizeNewLines(content);
path = FileTools.ResolvePath(path);
Log($" ● write_to_file: {path}");
Log($" mode: {mode} with {content.Length} lines");
try try
{ {
if (initialLines is not null)
initialLines = SanitizeNewLines(initialLines);
string? dir = Path.GetDirectoryName(path); string? dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir)) if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
if (initialLines is not null && initialLines.Length > 0) switch (mode.ToLower())
File.WriteAllLines(path, initialLines); {
else case "create":
File.WriteAllText(path, ""); if (File.Exists(path))
return $"ERROR: File already exists: {path}";
return $"OK fp:{HashlineEncoder.FileFingerprint(initialLines ?? [])}"; if (content.Length > 0)
File.WriteAllLines(path, content);
else
File.WriteAllText(path, "");
return $"OK fp:{HashlineEncoder.FileFingerprint(content)}";
case "append":
if (!File.Exists(path))
{
File.WriteAllText(path, "");
Log($" (created new file)");
}
using (var writer = new System.IO.StreamWriter(path, true))
{
foreach (var line in content)
{
writer.WriteLine(line);
}
}
string[] appendedLines = File.ReadAllLines(path);
return $"OK fp:{HashlineEncoder.FileFingerprint([.. appendedLines])}";
case "insert":
if (!File.Exists(path))
return $"ERROR: File not found: {path}";
if (string.IsNullOrEmpty(anchor))
return "ERROR: mode='insert' requires an anchor parameter";
string[] lines = File.ReadAllLines(path);
if (!HashlineValidator.TryResolve(anchor, lines, out int idx, out string error))
return $"ERROR: {error}";
var result = new List<string>(lines.Length + content.Length);
result.AddRange(lines[..(idx + 1)]);
result.AddRange(content);
result.AddRange(lines[(idx + 1)..]);
File.WriteAllLines(path, result);
return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}";
default:
return $"ERROR: Unknown mode '{mode}'. Valid modes: create, append, insert";
}
} }
catch (Exception ex) catch (Exception ex)
{ {
return $"ERROR creating '{path}': {ex.Message}"; return $"ERROR writing to '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
} }
} }
[Description("Delete a file permanently.")] [Description("Atomically apply multiple replace/delete operations to a file. All anchors validated upfront against original content. Operations auto-sorted bottom-to-top to prevent line drift. Prefer over individual calls when making multiple edits.")]
public static string DeleteFile( public static string BatchEdit(
[Description("Path to the file to delete.")] string path) [Description("Path to the file.")] string path,
[Description("Array of operations to apply. Operations are applied in bottom-to-top order automatically.")] BatchOperation[] operations)
{ {
path = FileTools.ResolvePath(path); path = FileTools.ResolvePath(path);
Log($"Deleting file: {path}"); Log($" ● batch_edit: {path}");
Log($" operations: {operations.Length}");
if (!File.Exists(path)) if (!File.Exists(path))
return $"ERROR: File not found: {path}"; return $"ERROR: File not found: {path}";
try if (operations.Length == 0)
{ return "ERROR: No operations provided";
File.Delete(path);
return $"OK (deleted)";
}
catch (Exception ex)
{
return $"ERROR deleting '{path}': {ex.Message}";
}
}
[Description("Rename or move a file. Auto-creates target dirs.")]
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 try
{ {
string? dir = Path.GetDirectoryName(destinationPath); // Read file once
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir)) string[] lines = File.ReadAllLines(path);
Directory.CreateDirectory(dir);
File.Move(sourcePath, destinationPath); // Pre-validate all anchors against original content (fail-fast)
return $"OK (moved to {destinationPath})"; var resolvedOps = new List<(int StartIdx, int EndIdx, BatchOperation Op)>();
} for (int i = 0; i < operations.Length; i++)
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 EOF (auto-creating the file if missing).")]
public static string AppendToFile(
[Description("Path to the file to append to.")] string path,
[Description("Raw source code to append. Do NOT include 'lineNumber:hash|' prefixes.")] 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, ""); var op = operations[i];
Log($" (created new file)");
if (string.IsNullOrWhiteSpace(op.Type))
return $"ERROR: Operation {i}: Type is required (use 'replace' or 'delete')";
var opType = op.Type.ToLowerInvariant();
if (opType != "replace" && opType != "delete")
return $"ERROR: Operation {i}: Invalid type '{op.Type}'. Must be 'replace' or 'delete'";
if (opType == "replace" && op.Content == null)
return $"ERROR: Operation {i}: 'replace' requires Content";
if (string.IsNullOrEmpty(op.StartAnchor) || string.IsNullOrEmpty(op.EndAnchor))
return $"ERROR: Operation {i}: StartAnchor and EndAnchor are required";
if (!HashlineValidator.TryResolveRange(op.StartAnchor, op.EndAnchor, lines,
out int startIdx, out int endIdx, out string error))
return $"ERROR: Operation {i}: Anchor validation failed\n{error}";
resolvedOps.Add((startIdx, endIdx, op));
} }
using (var writer = new System.IO.StreamWriter(path, true)) // Check for overlapping ranges (conflicting operations)
for (int i = 0; i < resolvedOps.Count; i++)
{ {
foreach (var line in lines) for (int j = i + 1; j < resolvedOps.Count; j++)
{ {
writer.WriteLine(line); var (startA, endA, _) = resolvedOps[i];
var (startB, endB, _) = resolvedOps[j];
if (!(endA < startB || endB < startA))
return $"ERROR: Operations {i} and {j} have overlapping ranges. " +
$"Range [{startA}-{endA}] overlaps with [{startB}-{endB}].";
} }
} }
string[] allLines = File.ReadAllLines(path); // Sort operations top-to-bottom (by start index ascending) because we build a new list sequentially
return $"OK fp:{HashlineEncoder.FileFingerprint([.. allLines])}"; var sortedOps = resolvedOps.OrderBy(x => x.StartIdx).ToList();
// Apply all operations to a single buffer
var result = new List<string>(lines.Length);
int nextLineIdx = 0;
foreach (var (startIdx, endIdx, op) in sortedOps)
{
// Copy lines before this operation
if (startIdx > nextLineIdx)
result.AddRange(lines[nextLineIdx..startIdx]);
// Apply operation
var opType = op.Type.ToLowerInvariant();
if (opType == "replace")
result.AddRange(SanitizeNewLines(op.Content!));
// delete: don't add anything (skip the range)
nextLineIdx = endIdx + 1;
}
// Copy remaining lines after the last operation
if (nextLineIdx < lines.Length)
result.AddRange(lines[nextLineIdx..]);
// Write file once
File.WriteAllLines(path, result);
return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}";
} }
catch (Exception ex) catch (Exception ex)
{ {
return $"ERROR appending to '{path}': {ex.Message}"; return $"ERROR batch editing '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
} }
} }

View File

@@ -27,7 +27,7 @@ internal static class FileTools
[Description("Last line to return (inclusive). Use 0 for EOF. Defaults to 0.")] int endLine = 0) [Description("Last line to return (inclusive). Use 0 for EOF. Defaults to 0.")] int endLine = 0)
{ {
path = ResolvePath(path); path = ResolvePath(path);
Log($"Reading file: {path} {startLine}:{endLine}L"); Log($" ● read_file: {path} {startLine}:{endLine}L");
if (!File.Exists(path)) if (!File.Exists(path))
return $"ERROR: File not found: {path}"; return $"ERROR: File not found: {path}";
@@ -56,61 +56,13 @@ internal static class FileTools
} }
} }
[Description("Search a file for a regex pattern. Returns matches with line:hash| anchors.")]
public static string GrepFile(
[Description("Path to the file to search.")] string path,
[Description("Regex pattern.")] 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 files and subdirectories.")] [Description("List files and subdirectories.")]
public static string ListDir( public static string ListDir(
[Description("Path to the directory.")] string path = ".") [Description("Path to the directory.")] string path = ".")
{ {
path = ResolvePath(path); path = ResolvePath(path);
Log($"Listing directory: {path}"); Log($" ● list_dir: {path}");
if (!Directory.Exists(path)) if (!Directory.Exists(path))
return $"ERROR: Directory not found: {path}"; return $"ERROR: Directory not found: {path}";
@@ -143,7 +95,7 @@ internal static class FileTools
[Description("Glob pattern (supports * and **).")] string pattern) [Description("Glob pattern (supports * and **).")] string pattern)
{ {
path = ResolvePath(path); path = ResolvePath(path);
Log($"Finding files: {pattern} in {path}"); Log($" ● find_files: {pattern} in {path}");
if (!Directory.Exists(path)) if (!Directory.Exists(path))
return $"ERROR: Directory not found: {path}"; return $"ERROR: Directory not found: {path}";
@@ -174,75 +126,136 @@ internal static class FileTools
} }
} }
[Description("Recursive regex search across all files. Returns matches with file:line:hash| format.")]
public static string GrepRecursive( [Description("Consolidated grep operation for single file or recursive directory search.")]
[Description("Directory to search.")] string path, public static string Grep(
[Description("Directory to search (for recursive mode) or file path (for file mode).")] string path,
[Description("Regex pattern.")] string pattern, [Description("Regex pattern.")] string pattern,
[Description("Optional glob to filter files (e.g. '*.cs').")] string? filePattern = null) [Description("Mode: 'file' for single file, 'recursive' for directory search.")] string mode = "recursive",
[Description("Optional glob to filter files in recursive mode (e.g. '*.cs').")] string? filePattern = null)
{ {
path = ResolvePath(path); path = ResolvePath(path);
Log($"Recursive grep: {pattern} in {path}" + (filePattern != null ? $" (files: {filePattern})" : "")); mode = mode.ToLowerInvariant();
if (!Directory.Exists(path)) if (mode == "file")
return $"ERROR: Directory not found: {path}";
Regex regex;
try
{ {
regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase); Log($" ● grep_file: {path}");
}
catch (Exception ex)
{
return $"ERROR: Invalid regex pattern '{pattern}': {ex.Message}";
}
try if (!File.Exists(path))
{ if (Directory.Exists(path))
string globPattern = filePattern?.Replace("**/", "") ?? "*"; return $"ERROR: {path} is a directory, not a file.";
var sb = new System.Text.StringBuilder(); else
int totalMatches = 0; return $"ERROR: File not found: {path}";
foreach (var file in EnumerateFilesRecursive(path, globPattern)) Regex regex;
try
{ {
try regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
{ }
// Skip binary files: check first 512 bytes for null chars catch (Exception ex)
using var probe = new StreamReader(file); {
var buf = new char[512]; return $"ERROR: Invalid regex pattern '{pattern}': {ex.Message}";
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) try
return $"(no matches for '{pattern}' in {path})"; {
string[] lines = File.ReadAllLines(path);
var sb = new System.Text.StringBuilder();
int matchCount = 0;
return $"Found {totalMatches} match(es):\n" + sb.ToString(); 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}\nThis is a bug, tell the user about it.";
}
} }
catch (Exception ex) else if (mode == "recursive")
{ {
return $"ERROR in recursive grep: {ex.Message}"; Log($" ● grep_recursive: {pattern} in {path}" + (filePattern != null ? $" (files: {filePattern})" : ""));
if (!Directory.Exists(path))
if (File.Exists(path))
return $"ERROR: {path} is a file, not a directory.";
else
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}.\nThis is a bug, tell the user about it.";
}
}
else
{
return $"ERROR: Invalid mode '{mode}'. Use 'file' or 'recursive'.";
} }
} }
@@ -284,7 +297,7 @@ internal static class FileTools
[Description("Path to the file.")] string path) [Description("Path to the file.")] string path)
{ {
path = ResolvePath(path); path = ResolvePath(path);
Log($"Getting file info: {path}"); Log($" ● get_file_info: {path}");
if (!File.Exists(path)) if (!File.Exists(path))
return $"ERROR: File not found: {path}"; return $"ERROR: File not found: {path}";

48
UsageDisplayer.cs Normal file
View File

@@ -0,0 +1,48 @@
using Spectre.Console;
namespace AnchorCli.OpenRouter;
/// <summary>
/// Displays token usage and cost information to the console.
/// </summary>
internal sealed class UsageDisplayer
{
private readonly TokenTracker _tokenTracker;
public UsageDisplayer(TokenTracker tokenTracker)
{
_tokenTracker = tokenTracker;
}
/// <summary>
/// Displays the usage statistics for a single response.
/// </summary>
public void Display(int inputTokens, int outputTokens)
{
if (inputTokens > 0 || outputTokens > 0)
{
_tokenTracker.AddUsage(inputTokens, outputTokens);
var cost = _tokenTracker.CalculateCost(inputTokens, outputTokens);
var ctxPct = _tokenTracker.ContextUsagePercent;
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine(
$"[dim grey] {TokenTracker.FormatTokens(inputTokens)}↑ {TokenTracker.FormatTokens(outputTokens)}↓" +
$" {TokenTracker.FormatCost(cost)}" +
(ctxPct >= 0 ? $" ctx:{ctxPct:F0}%" : "") +
$" │ session: {TokenTracker.FormatCost(_tokenTracker.SessionCost)}[/]");
}
else
{
AnsiConsole.WriteLine();
}
}
/// <summary>
/// Displays a rule separator.
/// </summary>
public void DisplaySeparator()
{
AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
}
}

36
docs/COMMANDS.md Normal file
View File

@@ -0,0 +1,36 @@
# Slash Commands
AnchorCli provides several slash commands for managing your session and interacting with the AI.
## Available Commands
| Command | Description |
|---------|-------------|
| `/setup` | Run interactive TUI to configure API key and model (also accessible via `anchor setup` subcommand) |
| `/help` | Show available tools and commands |
| `/exit` | Exit the application |
| `/clear` | Clear the conversation history |
| `/status` | Show session token usage and cost |
| `/compact` | Manually trigger context compaction |
| `/reset` | Clear session and reset token tracker |
| `/load` | Load a previous session from disk |
| `/save` | Save current session to disk |
## Usage
Type any command starting with `/` in the REPL to execute it:
```
> /status
Session: 1,234 tokens used ($0.0015)
```
```
> /help
Available tools: read_file, grep_file, replace_lines, ...
Available commands: /setup, /help, /exit, /clear, ...
```
---
*Back to [README.md](../README.md)*

55
docs/HASHLINE.md Normal file
View File

@@ -0,0 +1,55 @@
# The Hashline Technique
Hashline is AnchorCli's unique approach to safe, precise file editing.
## How It Works
Every line returned by file tools is prefixed with a content-derived hash anchor:
```
function hello() {
return "world";
}
```
The format is `lineNumber:hash|content` where:
- `lineNumber` is the current line number in the file
- `hash` is an Adler-8 checksum derived from the line content and position
- `content` is the actual line text
## Editing with Anchors
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.
### Example
To replace lines 2-3 from the example above:
```json
{
"tool": "replace_lines",
"path": "example.js",
"startAnchor": "2:f1",
"endAnchor": "3:0e",
"newLines": [" return \"hello world\";"]
}
```
## Benefits
This eliminates:
- **Whitespace/indentation reproduction errors** — No need to match exact spacing
- **Silent edits to the wrong line** — Hash validation ensures you're editing the right content
- **Entire-file rewrites** — Change just one line without touching the rest
- **Line drift in batch operations** — BatchEdit validates all anchors upfront and applies changes bottom-to-top
## Key Principles
1. **Never include anchors in your content** — When using `replace_lines`, `insert_after`, or similar tools, the `newLines` parameter should contain raw source code only, WITHOUT the `lineNumber:hash|` prefix
2. **Always validate before editing** — Re-read files to get fresh anchors if your previous anchors fail validation
3. **Use BatchEdit for multiple changes** — This validates all anchors upfront and applies operations atomically
---
*Back to [README.md](../README.md)* | *See [TOOLS.md](TOOLS.md) for all available tools*

53
docs/TOOLS.md Normal file
View File

@@ -0,0 +1,53 @@
# Available Tools
AnchorCli provides a comprehensive set of tools for file operations, editing, directory management, and command execution.
## File Operations
| Tool | Description |
|------|-------------|
| `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 |
| `grep_recursive` | Search for a regex pattern across all files in a directory tree |
| `find_files` | Search for files matching glob patterns |
| `get_file_info` | Get detailed file information (size, permissions, etc.) |
## Edit Operations
| Tool | Description |
|------|-------------|
| `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 |
| `batch_edit` | Apply multiple replace/delete operations atomically in a single call |
## Directory Operations
| Tool | Description |
|------|-------------|
| `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
| Tool | Description |
|------|-------------|
| `execute_command` | Run shell commands (with user approval) |
## Tool Usage Guidelines
- All file editing tools use **Hashline anchors** (`line:hash`) for precise, safe edits
- Before any mutation, both the line number **and** hash are validated — stale anchors are rejected
- `batch_edit` is preferred for multiple operations to prevent line drift
- `grep_file` results are pre-tagged with anchors for immediate editing
---
*For more details on the Hashline technique, see [HASHLINE.md](HASHLINE.md)*