Compare commits
32 Commits
de6a21fb5a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f187348d7 | |||
| fe006a5256 | |||
| ef30a2254b | |||
| 1e943e6566 | |||
| ccfa7e1b9d | |||
| c9515a822d | |||
| 75bbdda37d | |||
| 46d32c43ba | |||
| 35c8840ed4 | |||
| e2ab10813c | |||
| acc04af4bc | |||
| 977d772229 | |||
| a776d978ea | |||
| 91a44bb2a4 | |||
| f687360c2b | |||
| 4fbbde32e3 | |||
| 8f2c72b3c5 | |||
| 829ba7a7f2 | |||
| 8b48b0f866 | |||
| 82ef63c731 | |||
| 119e623f5a | |||
| e98cd3b19c | |||
| 50414e8b8c | |||
| 003345edc0 | |||
| 7a6e9785d6 | |||
| 1af1665839 | |||
| 112f1f3202 | |||
| c7e7976d9d | |||
| 4476cc7f15 | |||
| f2c3e5032d | |||
| 941894761a | |||
| 5fb914dbc8 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,3 +2,7 @@ bin
|
|||||||
obj
|
obj
|
||||||
.vscode
|
.vscode
|
||||||
publish
|
publish
|
||||||
|
.anchor
|
||||||
|
.idea
|
||||||
|
.vs
|
||||||
|
.crush
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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 =>
|
||||||
|
|||||||
@@ -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
176
ApplicationStartup.cs
Normal 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
818
Assets/3d.flf
Normal file
@@ -0,0 +1,818 @@
|
|||||||
|
flf2a$ 8 8 20 -1 1
|
||||||
|
3d font created by xero <x@xero.nu>
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@@
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░░ @
|
||||||
|
██@
|
||||||
|
░░ @@
|
||||||
|
█ █@
|
||||||
|
░█ ░█@
|
||||||
|
░ ░ @
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
██ ██ @
|
||||||
|
████████████@
|
||||||
|
░░░██░░░░██░ @
|
||||||
|
░██ ░██ @
|
||||||
|
████████████@
|
||||||
|
░░░██░░░░██░ @
|
||||||
|
░░ ░░ @@
|
||||||
|
█ @
|
||||||
|
█████@
|
||||||
|
░█░█░ @
|
||||||
|
░█████@
|
||||||
|
░░░█░█@
|
||||||
|
█████@
|
||||||
|
░░░█░ @
|
||||||
|
░ @@
|
||||||
|
@
|
||||||
|
██ ██ @
|
||||||
|
░░ ██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ ██ @
|
||||||
|
░░ ░░ @@
|
||||||
|
██ @
|
||||||
|
█░ █ @
|
||||||
|
░ ██ @
|
||||||
|
█░ █ █@
|
||||||
|
█ ░ █ @
|
||||||
|
░█ ░█ @
|
||||||
|
░ ████ █@
|
||||||
|
░░░░ ░ @@
|
||||||
|
██@
|
||||||
|
░░█@
|
||||||
|
░ @
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
██@
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░██ @
|
||||||
|
░░██@
|
||||||
|
░░ @@
|
||||||
|
██ @
|
||||||
|
░░██ @
|
||||||
|
░░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
░░ @@
|
||||||
|
██ @
|
||||||
|
██ ░██ ██ @
|
||||||
|
░░██ ░██ ██ @
|
||||||
|
██████████████@
|
||||||
|
░░░██░░██░░██░ @
|
||||||
|
██ ░██ ░░██ @
|
||||||
|
░░ ░██ ░░ @
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
█ @
|
||||||
|
░█ @
|
||||||
|
█████████@
|
||||||
|
░░░░░█░░░ @
|
||||||
|
░█ @
|
||||||
|
░ @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██@
|
||||||
|
░░█@
|
||||||
|
░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
█████@
|
||||||
|
░░░░░ @
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░░ @@
|
||||||
|
██@
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
░░ @@
|
||||||
|
████ @
|
||||||
|
█░░░██@
|
||||||
|
░█ █░█@
|
||||||
|
░█ █ ░█@
|
||||||
|
░██ ░█@
|
||||||
|
░█ ░█@
|
||||||
|
░ ████ @
|
||||||
|
░░░░ @@
|
||||||
|
██ @
|
||||||
|
███ @
|
||||||
|
░░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
████@
|
||||||
|
░░░░ @@
|
||||||
|
████ @
|
||||||
|
█░░░ █@
|
||||||
|
░ ░█@
|
||||||
|
███ @
|
||||||
|
█░░ @
|
||||||
|
█ @
|
||||||
|
░██████@
|
||||||
|
░░░░░░ @@
|
||||||
|
████ @
|
||||||
|
█░░░ █@
|
||||||
|
░ ░█@
|
||||||
|
███ @
|
||||||
|
░░░ █@
|
||||||
|
█ ░█@
|
||||||
|
░ ████ @
|
||||||
|
░░░░ @@
|
||||||
|
██ @
|
||||||
|
█░█ @
|
||||||
|
█ ░█ @
|
||||||
|
██████@
|
||||||
|
░░░░░█ @
|
||||||
|
░█ @
|
||||||
|
░█ @
|
||||||
|
░ @@
|
||||||
|
██████@
|
||||||
|
░█░░░░ @
|
||||||
|
░█████ @
|
||||||
|
░░░░░ █@
|
||||||
|
░█@
|
||||||
|
█ ░█@
|
||||||
|
░ ████ @
|
||||||
|
░░░░ @@
|
||||||
|
████ @
|
||||||
|
█░░░ █@
|
||||||
|
░█ ░ @
|
||||||
|
░█████ @
|
||||||
|
░█░░░ █@
|
||||||
|
░█ ░█@
|
||||||
|
░ ████ @
|
||||||
|
░░░░ @@
|
||||||
|
██████@
|
||||||
|
░░░░░░█@
|
||||||
|
░█@
|
||||||
|
█ @
|
||||||
|
█ @
|
||||||
|
█ @
|
||||||
|
█ @
|
||||||
|
░ @@
|
||||||
|
████ @
|
||||||
|
█░░░ █@
|
||||||
|
░█ ░█@
|
||||||
|
░ ████ @
|
||||||
|
█░░░ █@
|
||||||
|
░█ ░█@
|
||||||
|
░ ████ @
|
||||||
|
░░░░ @@
|
||||||
|
████ @
|
||||||
|
█░░░ █@
|
||||||
|
░█ ░█@
|
||||||
|
░ ████ @
|
||||||
|
░░░█ @
|
||||||
|
█ @
|
||||||
|
█ @
|
||||||
|
░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██@
|
||||||
|
░░ @
|
||||||
|
██@
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██@
|
||||||
|
░░ @
|
||||||
|
██@
|
||||||
|
░░█@
|
||||||
|
░ @@
|
||||||
|
██@
|
||||||
|
██░ @
|
||||||
|
██░ @
|
||||||
|
██░ @
|
||||||
|
░░ ██ @
|
||||||
|
░░ ██ @
|
||||||
|
░░ ██@
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██████@
|
||||||
|
░░░░░░ @
|
||||||
|
██████@
|
||||||
|
░░░░░░ @
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
██ @
|
||||||
|
░░ ██ @
|
||||||
|
░░ ██ @
|
||||||
|
░░ ██@
|
||||||
|
██░ @
|
||||||
|
██░ @
|
||||||
|
██░ @
|
||||||
|
░░ @@
|
||||||
|
████ @
|
||||||
|
██░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░ ██ @
|
||||||
|
██ @
|
||||||
|
░░ @
|
||||||
|
██ @
|
||||||
|
░░ @@
|
||||||
|
████ @
|
||||||
|
█░░░ █@
|
||||||
|
░█ ██░█@
|
||||||
|
░█░█ ░█@
|
||||||
|
░█░ ██ @
|
||||||
|
░█ ░░ @
|
||||||
|
░ █████@
|
||||||
|
░░░░░ @@
|
||||||
|
██ @
|
||||||
|
████ @
|
||||||
|
██░░██ @
|
||||||
|
██ ░░██ @
|
||||||
|
██████████@
|
||||||
|
░██░░░░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
██████ @
|
||||||
|
░█░░░░██ @
|
||||||
|
░█ ░██ @
|
||||||
|
░██████ @
|
||||||
|
░█░░░░ ██@
|
||||||
|
░█ ░██@
|
||||||
|
░███████ @
|
||||||
|
░░░░░░░ @@
|
||||||
|
██████ @
|
||||||
|
██░░░░██@
|
||||||
|
██ ░░ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░██ ██@
|
||||||
|
░░██████ @
|
||||||
|
░░░░░░ @@
|
||||||
|
███████ @
|
||||||
|
░██░░░░██ @
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ██ @
|
||||||
|
░███████ @
|
||||||
|
░░░░░░░ @@
|
||||||
|
████████@
|
||||||
|
░██░░░░░ @
|
||||||
|
░██ @
|
||||||
|
░███████ @
|
||||||
|
░██░░░░ @
|
||||||
|
░██ @
|
||||||
|
░████████@
|
||||||
|
░░░░░░░░ @@
|
||||||
|
████████@
|
||||||
|
░██░░░░░ @
|
||||||
|
░██ @
|
||||||
|
░███████ @
|
||||||
|
░██░░░░ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░ @@
|
||||||
|
████████ @
|
||||||
|
██░░░░░░██@
|
||||||
|
██ ░░ @
|
||||||
|
░██ @
|
||||||
|
░██ █████@
|
||||||
|
░░██ ░░░░██@
|
||||||
|
░░████████ @
|
||||||
|
░░░░░░░░ @@
|
||||||
|
██ ██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██████████@
|
||||||
|
░██░░░░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░░ @@
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
██ ░██@
|
||||||
|
░░█████ @
|
||||||
|
░░░░░ @@
|
||||||
|
██ ██@
|
||||||
|
░██ ██ @
|
||||||
|
░██ ██ @
|
||||||
|
░████ @
|
||||||
|
░██░██ @
|
||||||
|
░██░░██ @
|
||||||
|
░██ ░░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░████████@
|
||||||
|
░░░░░░░░ @@
|
||||||
|
████ ████@
|
||||||
|
░██░██ ██░██@
|
||||||
|
░██░░██ ██ ░██@
|
||||||
|
░██ ░░███ ░██@
|
||||||
|
░██ ░░█ ░██@
|
||||||
|
░██ ░ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
████ ██@
|
||||||
|
░██░██ ░██@
|
||||||
|
░██░░██ ░██@
|
||||||
|
░██ ░░██ ░██@
|
||||||
|
░██ ░░██░██@
|
||||||
|
░██ ░░████@
|
||||||
|
░██ ░░███@
|
||||||
|
░░ ░░░ @@
|
||||||
|
███████ @
|
||||||
|
██░░░░░██ @
|
||||||
|
██ ░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░██ ██ @
|
||||||
|
░░███████ @
|
||||||
|
░░░░░░░ @@
|
||||||
|
███████ @
|
||||||
|
░██░░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░███████ @
|
||||||
|
░██░░░░ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░ @@
|
||||||
|
███████ @
|
||||||
|
██░░░░░██ @
|
||||||
|
██ ░░██ @
|
||||||
|
░██ ░██ @
|
||||||
|
░██ ██░██ @
|
||||||
|
░░██ ░░ ██ @
|
||||||
|
░░███████ ██@
|
||||||
|
░░░░░░░ ░░ @@
|
||||||
|
███████ @
|
||||||
|
░██░░░░██ @
|
||||||
|
░██ ░██ @
|
||||||
|
░███████ @
|
||||||
|
░██░░░██ @
|
||||||
|
░██ ░░██ @
|
||||||
|
░██ ░░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
████████@
|
||||||
|
██░░░░░░ @
|
||||||
|
░██ @
|
||||||
|
░█████████@
|
||||||
|
░░░░░░░░██@
|
||||||
|
░██@
|
||||||
|
████████ @
|
||||||
|
░░░░░░░░ @@
|
||||||
|
██████████@
|
||||||
|
░░░░░██░░░ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░ @@
|
||||||
|
██ ██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░███████ @
|
||||||
|
░░░░░░░ @@
|
||||||
|
██ ██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░██ ██ @
|
||||||
|
░░██ ██ @
|
||||||
|
░░████ @
|
||||||
|
░░██ @
|
||||||
|
░░ @@
|
||||||
|
██ ██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ █ ░██@
|
||||||
|
░██ ███ ░██@
|
||||||
|
░██ ██░██░██@
|
||||||
|
░████ ░░████@
|
||||||
|
░██░ ░░░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
██ ██@
|
||||||
|
░░██ ██ @
|
||||||
|
░░██ ██ @
|
||||||
|
░░███ @
|
||||||
|
██░██ @
|
||||||
|
██ ░░██ @
|
||||||
|
██ ░░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
██ ██@
|
||||||
|
░░██ ██ @
|
||||||
|
░░████ @
|
||||||
|
░░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░ @@
|
||||||
|
████████@
|
||||||
|
░░░░░░██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
████████@
|
||||||
|
░░░░░░░░ @@
|
||||||
|
█████@
|
||||||
|
░██░░ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░█████@
|
||||||
|
░░░░░ @@
|
||||||
|
██ @
|
||||||
|
░░██ @
|
||||||
|
░░██ @
|
||||||
|
░░██ @
|
||||||
|
░░██ @
|
||||||
|
░░██ @
|
||||||
|
░░██@
|
||||||
|
░░ @@
|
||||||
|
█████@
|
||||||
|
░░░░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
█████@
|
||||||
|
░░░░░ @@
|
||||||
|
██ @
|
||||||
|
██░ ██ @
|
||||||
|
██ ░░ ██@
|
||||||
|
░░ ░░ @
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
█████@
|
||||||
|
░░░░░ @@
|
||||||
|
██@
|
||||||
|
░█ @
|
||||||
|
░ @
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██████ @
|
||||||
|
░░░░░░██ @
|
||||||
|
███████ @
|
||||||
|
██░░░░██ @
|
||||||
|
░░████████@
|
||||||
|
░░░░░░░░ @@
|
||||||
|
██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██████ @
|
||||||
|
░██░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██████ @
|
||||||
|
░░░░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
█████ @
|
||||||
|
██░░░██@
|
||||||
|
░██ ░░ @
|
||||||
|
░██ ██@
|
||||||
|
░░█████ @
|
||||||
|
░░░░░ @@
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
██████@
|
||||||
|
██░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░██████@
|
||||||
|
░░░░░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
█████ @
|
||||||
|
██░░░██@
|
||||||
|
░███████@
|
||||||
|
░██░░░░ @
|
||||||
|
░░██████@
|
||||||
|
░░░░░░ @@
|
||||||
|
████@
|
||||||
|
░██░ @
|
||||||
|
██████@
|
||||||
|
░░░██░ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
█████ @
|
||||||
|
██░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░██████@
|
||||||
|
░░░░░██@
|
||||||
|
█████ @
|
||||||
|
░░░░░ @@
|
||||||
|
██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██████ @
|
||||||
|
░██░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
██@
|
||||||
|
░░ @
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░░ @@
|
||||||
|
██@
|
||||||
|
░░ @
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
██░██@
|
||||||
|
░░███ @
|
||||||
|
░░░ @@
|
||||||
|
██ @
|
||||||
|
░██ @
|
||||||
|
░██ ██@
|
||||||
|
░██ ██ @
|
||||||
|
░████ @
|
||||||
|
░██░██ @
|
||||||
|
░██░░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
███@
|
||||||
|
░░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██████████ @
|
||||||
|
░░██░░██░░██@
|
||||||
|
░██ ░██ ░██@
|
||||||
|
░██ ░██ ░██@
|
||||||
|
███ ░██ ░██@
|
||||||
|
░░░ ░░ ░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
███████ @
|
||||||
|
░░██░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
███ ░██@
|
||||||
|
░░░ ░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██████ @
|
||||||
|
██░░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░██████ @
|
||||||
|
░░░░░░ @@
|
||||||
|
@
|
||||||
|
██████ @
|
||||||
|
░██░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██████ @
|
||||||
|
░██░░░ @
|
||||||
|
░██ @
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
████ @
|
||||||
|
██░░██ @
|
||||||
|
░██ ░██ @
|
||||||
|
░░█████ @
|
||||||
|
░░░░██ @
|
||||||
|
░███@
|
||||||
|
░░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██████@
|
||||||
|
░░██░░█@
|
||||||
|
░██ ░ @
|
||||||
|
░██ @
|
||||||
|
░███ @
|
||||||
|
░░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██████@
|
||||||
|
██░░░░ @
|
||||||
|
░░█████ @
|
||||||
|
░░░░░██@
|
||||||
|
██████ @
|
||||||
|
░░░░░░ @@
|
||||||
|
██ @
|
||||||
|
░██ @
|
||||||
|
██████@
|
||||||
|
░░░██░ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░██ @
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██ ██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░██████@
|
||||||
|
░░░░░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██ ██@
|
||||||
|
░██ ░██@
|
||||||
|
░░██ ░██ @
|
||||||
|
░░████ @
|
||||||
|
░░██ @
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
███ ██@
|
||||||
|
░░██ █ ░██@
|
||||||
|
░██ ███░██@
|
||||||
|
░████░████@
|
||||||
|
███░ ░░░██@
|
||||||
|
░░░ ░░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██ ██@
|
||||||
|
░░██ ██ @
|
||||||
|
░░███ @
|
||||||
|
██░██ @
|
||||||
|
██ ░░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
@
|
||||||
|
██ ██@
|
||||||
|
░░██ ██ @
|
||||||
|
░░███ @
|
||||||
|
░██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██████@
|
||||||
|
░░░░██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██████@
|
||||||
|
░░░░░░ @@
|
||||||
|
███@
|
||||||
|
██░ @
|
||||||
|
░██ @
|
||||||
|
███ @
|
||||||
|
░░░██ @
|
||||||
|
░██ @
|
||||||
|
░░███@
|
||||||
|
░░░ @@
|
||||||
|
█@
|
||||||
|
░█@
|
||||||
|
░█@
|
||||||
|
░ @
|
||||||
|
█@
|
||||||
|
░█@
|
||||||
|
░█@
|
||||||
|
░ @@
|
||||||
|
███ @
|
||||||
|
░░░██ @
|
||||||
|
░██ @
|
||||||
|
░░███@
|
||||||
|
██░ @
|
||||||
|
░██ @
|
||||||
|
███ @
|
||||||
|
░░░ @@
|
||||||
|
██ ███ @
|
||||||
|
░░███░░██@
|
||||||
|
░░░ ░░ @
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
61
ContextCompactionService.cs
Normal file
61
ContextCompactionService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
110
HeaderRenderer.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
81
IDEAS.md
81
IDEAS.md
@@ -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.
|
|
||||||
90
IMPROVEME.md
90
IMPROVEME.md
@@ -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
125
InputProcessor.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
OpenRouter/OpenRouterHeaders.cs
Normal file
19
OpenRouter/OpenRouterHeaders.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
150
Program.cs
150
Program.cs
@@ -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();
|
||||||
|
|||||||
89
Providers/GenericTokenExtractor.cs
Normal file
89
Providers/GenericTokenExtractor.cs
Normal 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
61
Providers/GroqProvider.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Providers/IPricingProvider.cs
Normal file
18
Providers/IPricingProvider.cs
Normal 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);
|
||||||
|
}
|
||||||
25
Providers/ITokenExtractor.cs
Normal file
25
Providers/ITokenExtractor.cs
Normal 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; }
|
||||||
|
}
|
||||||
39
Providers/OllamaTokenExtractor.cs
Normal file
39
Providers/OllamaTokenExtractor.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
Providers/OpenRouterProvider.cs
Normal file
40
Providers/OpenRouterProvider.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
Providers/OpenRouterTokenExtractor.cs
Normal file
42
Providers/OpenRouterTokenExtractor.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
Providers/ProviderFactory.cs
Normal file
70
Providers/ProviderFactory.cs
Normal 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
135
README.md
@@ -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.
|
||||||
|
|||||||
244
ReplLoop.cs
244
ReplLoop.cs
@@ -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,208 +44,121 @@ 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);
|
catch (OperationCanceledException)
|
||||||
|
|
||||||
string? firstChunk = null;
|
|
||||||
int respIn = 0, respOut = 0;
|
|
||||||
|
|
||||||
void CaptureUsage(ChatResponseUpdate update)
|
|
||||||
{
|
{
|
||||||
if (update.RawRepresentation is OpenAI.Chat.StreamingChatCompletionUpdate raw
|
HandleCancellation();
|
||||||
&& raw.Usage != null)
|
}
|
||||||
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
respIn = raw.Usage.InputTokenCount; // last call = actual context size
|
DisplayError(ex);
|
||||||
respOut += raw.Usage.OutputTokenCount; // additive — each round generates new output
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
responseCts?.Dispose();
|
||||||
|
responseCts = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
object consoleLock = new();
|
private async Task ProcessTurnAsync(CancellationToken cancellationToken)
|
||||||
using var spinnerCts = CancellationTokenSource.CreateLinkedTokenSource(responseCts.Token);
|
{
|
||||||
bool showSpinner = true;
|
using var spinner = new SpinnerService();
|
||||||
|
spinner.Start(cancellationToken);
|
||||||
|
|
||||||
CommandTool.PauseSpinner = () =>
|
// Configure tool callbacks for spinner control and stale result compaction
|
||||||
{
|
var originalPause = CommandTool.PauseSpinner;
|
||||||
lock (consoleLock)
|
var originalResume = CommandTool.ResumeSpinner;
|
||||||
{
|
var originalOnFileRead = FileTools.OnFileRead;
|
||||||
showSpinner = false;
|
|
||||||
Console.Write("\r" + new string(' ', 40) + "\r");
|
CommandTool.PauseSpinner = spinner.Pause;
|
||||||
}
|
CommandTool.ResumeSpinner = spinner.Resume;
|
||||||
};
|
|
||||||
CommandTool.ResumeSpinner = () =>
|
|
||||||
{
|
|
||||||
lock (consoleLock)
|
|
||||||
{
|
|
||||||
showSpinner = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
FileTools.OnFileRead = _ =>
|
FileTools.OnFileRead = _ =>
|
||||||
{
|
{
|
||||||
int n = ContextCompactor.CompactStaleToolResults(_session.History);
|
int n = ContextCompactor.CompactStaleToolResults(_session.History);
|
||||||
if (n > 0)
|
if (n > 0)
|
||||||
AnsiConsole.MarkupLine(
|
AnsiConsole.MarkupLine($"[dim grey] ♻ Compacted {n} stale tool result(s)[/]");
|
||||||
$"[dim grey] ♻ Compacted {n} stale tool result(s)[/]");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var spinnerTask = Task.Run(async () =>
|
var responseBuilder = new StringBuilder();
|
||||||
{
|
bool firstChunkDisplayed = false;
|
||||||
var frames = Spinner.Known.BouncingBar.Frames;
|
|
||||||
var interval = Spinner.Known.BouncingBar.Interval;
|
|
||||||
int i = 0;
|
|
||||||
|
|
||||||
Console.Write("\x1b[?25l");
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (!spinnerCts.Token.IsCancellationRequested)
|
await foreach (var chunk in _streamer.StreamAsync(cancellationToken))
|
||||||
{
|
{
|
||||||
lock (consoleLock)
|
// Stop spinner before displaying first chunk
|
||||||
|
if (!firstChunkDisplayed)
|
||||||
{
|
{
|
||||||
if (showSpinner && !spinnerCts.Token.IsCancellationRequested)
|
await spinner.StopAsync();
|
||||||
{
|
firstChunkDisplayed = true;
|
||||||
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 { }
|
AnsiConsole.Markup(Markup.Escape(chunk));
|
||||||
|
responseBuilder.Append(chunk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
lock (consoleLock)
|
if (!firstChunkDisplayed)
|
||||||
{
|
{
|
||||||
if (showSpinner)
|
await spinner.StopAsync();
|
||||||
Console.Write("\r" + new string(' ', 40) + "\r");
|
|
||||||
Console.Write("\x1b[?25h");
|
|
||||||
}
|
}
|
||||||
}
|
CommandTool.PauseSpinner = originalPause;
|
||||||
});
|
CommandTool.ResumeSpinner = originalResume;
|
||||||
|
FileTools.OnFileRead = originalOnFileRead;
|
||||||
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)
|
var fullResponse = responseBuilder.ToString();
|
||||||
{
|
|
||||||
AnsiConsole.Markup(Markup.Escape(firstChunk));
|
// 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
while (await stream.MoveNextAsync())
|
private void HandleCancellation()
|
||||||
{
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
AnsiConsole.WriteLine();
|
AnsiConsole.WriteLine();
|
||||||
AnsiConsole.MarkupLine("[dim grey] ✗ Cancelled[/]");
|
AnsiConsole.MarkupLine("[dim grey] ✗ Cancelled[/]");
|
||||||
AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
|
AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
|
||||||
AnsiConsole.WriteLine();
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(fullResponse))
|
_session.History.Add(new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.User,
|
||||||
{
|
|
||||||
_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.]"));
|
"[Response cancelled by user. Acknowledge briefly and wait for the next instruction. Do not repeat what was already said.]"));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
|
private void DisplayError(Exception ex)
|
||||||
{
|
{
|
||||||
AnsiConsole.WriteLine();
|
AnsiConsole.WriteLine();
|
||||||
AnsiConsole.Write(
|
AnsiConsole.Write(
|
||||||
@@ -242,11 +169,4 @@ internal sealed class ReplLoop
|
|||||||
.Padding(1, 0));
|
.Padding(1, 0));
|
||||||
AnsiConsole.WriteLine();
|
AnsiConsole.WriteLine();
|
||||||
}
|
}
|
||||||
finally
|
|
||||||
{
|
|
||||||
responseCts?.Dispose();
|
|
||||||
responseCts = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
60
ResponseStreamer.cs
Normal file
60
ResponseStreamer.cs
Normal 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; }
|
||||||
|
}
|
||||||
334
SANDBOX.md
334
SANDBOX.md
@@ -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
83
SessionManager.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
SetupTui.cs
83
SetupTui.cs
@@ -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
|
||||||
|
{
|
||||||
|
"groq" => new List<(string Value, string Description)>
|
||||||
|
{
|
||||||
|
("llama-3.3-70b-versatile", "fast, powerful"),
|
||||||
|
("llama-3.1-8b-instant", "very fast"),
|
||||||
|
("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-397b-a17b", "smart, expensive"),
|
||||||
("qwen/qwen3.5-35b-a3b", "cheapest"),
|
("qwen/qwen3.5-122b-a10b", "faster"),
|
||||||
("qwen/qwen3.5-27b", "fast"),
|
("qwen/qwen3.5-27b", "fast"),
|
||||||
("qwen/qwen3.5-122b-a10b", "smart"),
|
("qwen/qwen3.5-flash-02-23", "cloud, fast"),
|
||||||
|
("qwen/qwen3.5-plus-02-15", "cloud, smart"),
|
||||||
("Custom...", "")
|
("Custom...", "")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
string selectedModel = AnsiConsole.Prompt(
|
string selectedModel = AnsiConsole.Prompt(
|
||||||
|
|||||||
100
SpinnerService.cs
Normal file
100
SpinnerService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}";
|
||||||
|
|||||||
@@ -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,49 +129,38 @@ 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
|
try
|
||||||
{
|
{
|
||||||
if (initialLines is not null)
|
Directory.Delete(path, true);
|
||||||
initialLines = SanitizeNewLines(initialLines);
|
return $"OK: Directory deleted: '{path}'";
|
||||||
string? dir = Path.GetDirectoryName(path);
|
|
||||||
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
|
|
||||||
Directory.CreateDirectory(dir);
|
|
||||||
|
|
||||||
if (initialLines is not null && initialLines.Length > 0)
|
|
||||||
File.WriteAllLines(path, initialLines);
|
|
||||||
else
|
|
||||||
File.WriteAllText(path, "");
|
|
||||||
|
|
||||||
return $"OK fp:{HashlineEncoder.FileFingerprint(initialLines ?? [])}";
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return $"ERROR creating '{path}': {ex.Message}";
|
return $"ERROR deleting directory '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
[Description("Delete a file permanently.")]
|
|
||||||
public static string DeleteFile(
|
|
||||||
[Description("Path to the file to delete.")] string path)
|
|
||||||
{
|
{
|
||||||
path = FileTools.ResolvePath(path);
|
|
||||||
Log($"Deleting file: {path}");
|
|
||||||
|
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
return $"ERROR: File not found: {path}";
|
return $"ERROR: File not found: {path}";
|
||||||
|
|
||||||
@@ -211,47 +171,21 @@ internal static partial class EditTools
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return $"ERROR deleting '{path}': {ex.Message}";
|
return $"ERROR deleting '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Description("Rename or move a file. Auto-creates target dirs.")]
|
[Description("Move or copy a file to a new location.")]
|
||||||
public static string RenameFile(
|
public static string MoveFile(
|
||||||
[Description("Current path to the file.")] string sourcePath,
|
[Description("Current path to the file.")] string sourcePath,
|
||||||
[Description("New path for the file.")] string destinationPath)
|
[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);
|
sourcePath = FileTools.ResolvePath(sourcePath);
|
||||||
destinationPath = FileTools.ResolvePath(destinationPath);
|
destinationPath = FileTools.ResolvePath(destinationPath);
|
||||||
Log($"Renaming file: {sourcePath} -> {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);
|
|
||||||
|
|
||||||
File.Move(sourcePath, destinationPath);
|
|
||||||
return $"OK (moved to {destinationPath})";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return $"ERROR moving file: {ex.Message}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Description("Copy a file to a new location.")]
|
|
||||||
public static string CopyFile(
|
|
||||||
[Description("Path to the existing file.")] string sourcePath,
|
|
||||||
[Description("Path for the copy.")] string destinationPath)
|
|
||||||
{
|
|
||||||
sourcePath = FileTools.ResolvePath(sourcePath);
|
|
||||||
destinationPath = FileTools.ResolvePath(destinationPath);
|
|
||||||
Log($"Copying file: {sourcePath} -> {destinationPath}");
|
|
||||||
|
|
||||||
if (!File.Exists(sourcePath))
|
if (!File.Exists(sourcePath))
|
||||||
return $"ERROR: Source file not found: {sourcePath}";
|
return $"ERROR: Source file not found: {sourcePath}";
|
||||||
@@ -264,24 +198,31 @@ internal static partial class EditTools
|
|||||||
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
|
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
|
||||||
Directory.CreateDirectory(dir);
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
if (copy)
|
||||||
File.Copy(sourcePath, destinationPath);
|
File.Copy(sourcePath, destinationPath);
|
||||||
return $"OK (copied to {destinationPath})";
|
else
|
||||||
|
File.Move(sourcePath, destinationPath);
|
||||||
|
|
||||||
|
return copy ? $"OK (copied to {destinationPath})" : $"OK (moved to {destinationPath})";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return $"ERROR copying file: {ex.Message}";
|
return $"ERROR {action.ToLower()} file: {ex.Message}\nThis is a bug. Tell the user about it.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Description("Append lines to EOF (auto-creating the file if missing).")]
|
|
||||||
public static string AppendToFile(
|
[Description("Write to a file with different modes: create, append, or insert.")]
|
||||||
[Description("Path to the file to append to.")] string path,
|
public static string WriteToFile(
|
||||||
[Description("Raw source code to append. Do NOT include 'lineNumber:hash|' prefixes.")] string[] lines)
|
[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)
|
||||||
{
|
{
|
||||||
lines = SanitizeNewLines(lines);
|
content = SanitizeNewLines(content);
|
||||||
path = FileTools.ResolvePath(path);
|
path = FileTools.ResolvePath(path);
|
||||||
Log($"Appending to file: {path}");
|
Log($" ● write_to_file: {path}");
|
||||||
Log($" Appending {lines.Length} lines");
|
Log($" mode: {mode} with {content.Length} lines");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -289,6 +230,20 @@ internal static partial class EditTools
|
|||||||
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
|
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
|
||||||
Directory.CreateDirectory(dir);
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
switch (mode.ToLower())
|
||||||
|
{
|
||||||
|
case "create":
|
||||||
|
if (File.Exists(path))
|
||||||
|
return $"ERROR: File already exists: {path}";
|
||||||
|
|
||||||
|
if (content.Length > 0)
|
||||||
|
File.WriteAllLines(path, content);
|
||||||
|
else
|
||||||
|
File.WriteAllText(path, "");
|
||||||
|
|
||||||
|
return $"OK fp:{HashlineEncoder.FileFingerprint(content)}";
|
||||||
|
|
||||||
|
case "append":
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
{
|
{
|
||||||
File.WriteAllText(path, "");
|
File.WriteAllText(path, "");
|
||||||
@@ -297,18 +252,138 @@ internal static partial class EditTools
|
|||||||
|
|
||||||
using (var writer = new System.IO.StreamWriter(path, true))
|
using (var writer = new System.IO.StreamWriter(path, true))
|
||||||
{
|
{
|
||||||
foreach (var line in lines)
|
foreach (var line in content)
|
||||||
{
|
{
|
||||||
writer.WriteLine(line);
|
writer.WriteLine(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
string[] allLines = File.ReadAllLines(path);
|
string[] appendedLines = File.ReadAllLines(path);
|
||||||
return $"OK fp:{HashlineEncoder.FileFingerprint([.. allLines])}";
|
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 appending to '{path}': {ex.Message}";
|
return $"ERROR writing to '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 BatchEdit(
|
||||||
|
[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);
|
||||||
|
Log($" ● batch_edit: {path}");
|
||||||
|
Log($" operations: {operations.Length}");
|
||||||
|
|
||||||
|
if (!File.Exists(path))
|
||||||
|
return $"ERROR: File not found: {path}";
|
||||||
|
|
||||||
|
if (operations.Length == 0)
|
||||||
|
return "ERROR: No operations provided";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Read file once
|
||||||
|
string[] lines = File.ReadAllLines(path);
|
||||||
|
|
||||||
|
// Pre-validate all anchors against original content (fail-fast)
|
||||||
|
var resolvedOps = new List<(int StartIdx, int EndIdx, BatchOperation Op)>();
|
||||||
|
for (int i = 0; i < operations.Length; i++)
|
||||||
|
{
|
||||||
|
var op = operations[i];
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for overlapping ranges (conflicting operations)
|
||||||
|
for (int i = 0; i < resolvedOps.Count; i++)
|
||||||
|
{
|
||||||
|
for (int j = i + 1; j < resolvedOps.Count; j++)
|
||||||
|
{
|
||||||
|
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}].";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort operations top-to-bottom (by start index ascending) because we build a new list sequentially
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
return $"ERROR batch editing '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,15 +56,95 @@ internal static class FileTools
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Description("Search a file for a regex pattern. Returns matches with line:hash| anchors.")]
|
|
||||||
public static string GrepFile(
|
[Description("List files and subdirectories.")]
|
||||||
[Description("Path to the file to search.")] string path,
|
public static string ListDir(
|
||||||
[Description("Regex pattern.")] string pattern)
|
[Description("Path to the directory.")] string path = ".")
|
||||||
{
|
{
|
||||||
path = ResolvePath(path);
|
path = ResolvePath(path);
|
||||||
Log($"Searching file: {path}");
|
Log($" ● list_dir: {path}");
|
||||||
|
|
||||||
|
if (!Directory.Exists(path))
|
||||||
|
return $"ERROR: Directory not found: {path}";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
sb.AppendLine($"Directory: {path}");
|
||||||
|
|
||||||
|
foreach (string dir in Directory.GetDirectories(path))
|
||||||
|
sb.AppendLine($" [dir] {Path.GetFileName(dir)}/");
|
||||||
|
|
||||||
|
foreach (string file in Directory.GetFiles(path))
|
||||||
|
{
|
||||||
|
var info = new FileInfo(file);
|
||||||
|
sb.AppendLine($" [file] {info.Name} ({info.Length} bytes)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"ERROR listing '{path}': {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Find files matching a glob pattern (e.g. '*.cs', '**/*.json').")]
|
||||||
|
public static string FindFiles(
|
||||||
|
[Description("Directory to start search.")] string path,
|
||||||
|
[Description("Glob pattern (supports * and **).")] string pattern)
|
||||||
|
{
|
||||||
|
path = ResolvePath(path);
|
||||||
|
Log($" ● find_files: {pattern} in {path}");
|
||||||
|
|
||||||
|
if (!Directory.Exists(path))
|
||||||
|
return $"ERROR: Directory not found: {path}";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var searchOption = pattern.Contains("**")
|
||||||
|
? System.IO.SearchOption.AllDirectories
|
||||||
|
: System.IO.SearchOption.TopDirectoryOnly;
|
||||||
|
|
||||||
|
string[] files = Directory.GetFiles(path, pattern.Replace("**/", ""), searchOption);
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
|
||||||
|
if (files.Length == 0)
|
||||||
|
return $"(no files matching '{pattern}' in {path})";
|
||||||
|
|
||||||
|
sb.AppendLine($"Found {files.Length} file(s) matching '{pattern}':");
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
sb.AppendLine($" {file}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"ERROR searching for files: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[Description("Consolidated grep operation for single file or recursive directory search.")]
|
||||||
|
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("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);
|
||||||
|
mode = mode.ToLowerInvariant();
|
||||||
|
|
||||||
|
if (mode == "file")
|
||||||
|
{
|
||||||
|
Log($" ● grep_file: {path}");
|
||||||
|
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
|
if (Directory.Exists(path))
|
||||||
|
return $"ERROR: {path} is a directory, not a file.";
|
||||||
|
else
|
||||||
return $"ERROR: File not found: {path}";
|
return $"ERROR: File not found: {path}";
|
||||||
|
|
||||||
Regex regex;
|
Regex regex;
|
||||||
@@ -101,89 +181,17 @@ internal static class FileTools
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return $"ERROR searching '{path}': {ex.Message}";
|
return $"ERROR searching '{path}': {ex.Message}\nThis is a bug, tell the user about it.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (mode == "recursive")
|
||||||
[Description("List files and subdirectories.")]
|
|
||||||
public static string ListDir(
|
|
||||||
[Description("Path to the directory.")] string path = ".")
|
|
||||||
{
|
{
|
||||||
path = ResolvePath(path);
|
Log($" ● grep_recursive: {pattern} in {path}" + (filePattern != null ? $" (files: {filePattern})" : ""));
|
||||||
Log($"Listing directory: {path}");
|
|
||||||
|
|
||||||
if (!Directory.Exists(path))
|
|
||||||
return $"ERROR: Directory not found: {path}";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var sb = new System.Text.StringBuilder();
|
|
||||||
sb.AppendLine($"Directory: {path}");
|
|
||||||
|
|
||||||
foreach (string dir in Directory.GetDirectories(path))
|
|
||||||
sb.AppendLine($" [dir] {Path.GetFileName(dir)}/");
|
|
||||||
|
|
||||||
foreach (string file in Directory.GetFiles(path))
|
|
||||||
{
|
|
||||||
var info = new FileInfo(file);
|
|
||||||
sb.AppendLine($" [file] {info.Name} ({info.Length} bytes)");
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return $"ERROR listing '{path}': {ex.Message}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Description("Find files matching a glob pattern (e.g. '*.cs', '**/*.json').")]
|
|
||||||
public static string FindFiles(
|
|
||||||
[Description("Directory to start search.")] string path,
|
|
||||||
[Description("Glob pattern (supports * and **).")] string pattern)
|
|
||||||
{
|
|
||||||
path = ResolvePath(path);
|
|
||||||
Log($"Finding files: {pattern} in {path}");
|
|
||||||
|
|
||||||
if (!Directory.Exists(path))
|
|
||||||
return $"ERROR: Directory not found: {path}";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var searchOption = pattern.Contains("**")
|
|
||||||
? System.IO.SearchOption.AllDirectories
|
|
||||||
: System.IO.SearchOption.TopDirectoryOnly;
|
|
||||||
|
|
||||||
string[] files = Directory.GetFiles(path, pattern.Replace("**/", ""), searchOption);
|
|
||||||
var sb = new System.Text.StringBuilder();
|
|
||||||
|
|
||||||
if (files.Length == 0)
|
|
||||||
return $"(no files matching '{pattern}' in {path})";
|
|
||||||
|
|
||||||
sb.AppendLine($"Found {files.Length} file(s) matching '{pattern}':");
|
|
||||||
foreach (var file in files)
|
|
||||||
{
|
|
||||||
sb.AppendLine($" {file}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return $"ERROR searching for files: {ex.Message}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Description("Recursive regex search across all files. Returns matches with file:line:hash| format.")]
|
|
||||||
public static string GrepRecursive(
|
|
||||||
[Description("Directory to search.")] string path,
|
|
||||||
[Description("Regex pattern.")] string pattern,
|
|
||||||
[Description("Optional glob to filter files (e.g. '*.cs').")] string? filePattern = null)
|
|
||||||
{
|
|
||||||
path = ResolvePath(path);
|
|
||||||
Log($"Recursive grep: {pattern} in {path}" + (filePattern != null ? $" (files: {filePattern})" : ""));
|
|
||||||
|
|
||||||
if (!Directory.Exists(path))
|
if (!Directory.Exists(path))
|
||||||
|
if (File.Exists(path))
|
||||||
|
return $"ERROR: {path} is a file, not a directory.";
|
||||||
|
else
|
||||||
return $"ERROR: Directory not found: {path}";
|
return $"ERROR: Directory not found: {path}";
|
||||||
|
|
||||||
Regex regex;
|
Regex regex;
|
||||||
@@ -242,7 +250,12 @@ internal static class FileTools
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return $"ERROR in recursive grep: {ex.Message}";
|
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
48
UsageDisplayer.cs
Normal 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
36
docs/COMMANDS.md
Normal 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
55
docs/HASHLINE.md
Normal 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
53
docs/TOOLS.md
Normal 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)*
|
||||||
Reference in New Issue
Block a user