commit e25ac591a739b3f57ed61d5797ab172b03d0360b Author: 应文浩wenhao.ying@xiaobao100.com Date: Mon Apr 6 07:24:24 2026 +0800 init easy-code diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0808c4a --- /dev/null +++ b/.gitignore @@ -0,0 +1,482 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..1fb53a2 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,23 @@ + + + net10.0 + 13.0 + enable + enable + true + true + true + true + false + true + false + CS1591;CS1998;CS0168;CS0219;IL2026;IL2062;IL2070;IL2072;IL2075;IL2087;IL2118;IL2126;IL3050 + 0.1.0 + 0.1.0.0 + + + + true + false + + diff --git a/docs/UI与扩展设计/UI与扩展设计-Terminal-Gui终端UI.md b/docs/UI与扩展设计/UI与扩展设计-Terminal-Gui终端UI.md new file mode 100644 index 0000000..0f7613b --- /dev/null +++ b/docs/UI与扩展设计/UI与扩展设计-Terminal-Gui终端UI.md @@ -0,0 +1,337 @@ +# Terminal.Gui 终端 UI + +> 所属项目: free-code .NET 10 重写 +> 所属模块: [UI 与扩展设计](UI与扩展设计.md) +> 原始代码: `../../src/screens/REPL.tsx` + `../../src/components/`(50+ React/Ink 组件) +> 交叉参考: [核心模块设计 — 查询引擎 (QueryEngine)](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md) + +--- + +## 概述 + +本模块用 Terminal.Gui 重新实现原始项目的 React/Ink 终端 UI 层。原始 `REPL.tsx` 是约 800 行的 React 函数式组件,依赖 Ink 的虚拟 DOM 将 JSX 渲染到终端。.NET 重写将其拆分为 `TerminalApp`(应用入口与主题)和 `REPLScreen`(主交互窗口),并将 50+ Ink 组件中最关键的两个(`PermissionDialog`、`CompanionSpriteView`)映射为 Terminal.Gui 的 `Dialog` 和 `View` 子类。 + +--- + +## 15.1 应用启动 + +`TerminalApp` 是整个 UI 的入口,对应原始 `cli.tsx` 到 `REPL.tsx` 的初始化链路。 + +```csharp +/// +/// Terminal.Gui 应用入口 +/// 对应原始 cli.tsx → REPL.tsx 的初始化 +/// +public static class TerminalApp +{ + public static void Run(IHost host) + { + Application.Init(); + Application.UseSystemConsole = true; + + // 全局主题 + ApplyTheme(); + + // 创建主窗口 + var top = Application.Top; + var replScreen = new REPLScreen(host.Services); + top.Add(replScreen); + + // 全局键绑定 + Application.KeyBindings.Add(Key.Esc, Command.Quit); + Application.KeyBindings.Add(Key.CtrlMask | Key.C, Command.Cancel); + + Application.Run(top); + Application.Shutdown(); + } + + private static void ApplyTheme() + { + var colors = Colors.ColorSchemes["Base"]; + colors.Normal = new Attribute(Color.White, Color.Black); + colors.Focus = new Attribute(Color.Green, Color.Black); + colors.HotNormal = new Attribute(Color.BrightCyan, Color.Black); + colors.HotFocus = new Attribute(Color.BrightGreen, Color.Black); + } +} +``` + +**设计意图** + +Terminal.Gui 的 `Application.Init()` 对应 Ink 的 `render()` 调用。`UseSystemConsole = true` 确保在大多数终端模拟器下使用原生控制台输出,避免 Terminal.Gui 的 curses 后端与某些终端的兼容性问题。主题颜色选取刻意与原始 Ink 样式保持一致:白色前景、黑色背景、绿色高亮。 + +--- + +## 15.2 REPL 主屏幕 + +`REPLScreen` 对应原始 `REPL.tsx` 的全部交互逻辑,包括消息流渲染、斜杠命令路由和状态订阅。 + +```csharp +/// +/// REPL 主屏幕 — Terminal.Gui 版本 +/// 对应原始 REPL.tsx(~800 行 React/Ink 组件) +/// +public sealed class REPLScreen : Window +{ + private readonly IQueryEngine _queryEngine; + private readonly IAppStateStore _stateStore; + private readonly ICommandRegistry _commands; + private readonly IBackgroundTaskManager _taskManager; + private readonly TextView _promptInput; + private readonly ListView _messageList; + private readonly StatusBar _statusBar; + private readonly Label _spinner; + private readonly IDisposable _stateSubscription; + + public REPLScreen(IServiceProvider services) : base("free-code") + { + _queryEngine = services.GetRequiredService(); + _stateStore = services.GetRequiredService(); + _commands = services.GetRequiredService(); + _taskManager = services.GetRequiredService(); + + // 布局: 上方消息列表 + 下方输入框 + 底部状态栏 + var messagesFrame = new FrameView("Messages") + { + X = 0, Y = 0, + Width = Dim.Fill(), + Height = Dim.Fill() - 5 + }; + + _messageList = new ListView() + { + X = 0, Y = 0, + Width = Dim.Fill(), + Height = Dim.Fill(), + }; + messagesFrame.Add(_messageList); + + _spinner = new Label("●") + { + X = 0, Y = Pos.Bottom(messagesFrame), + Visible = false + }; + + _promptInput = new TextView() + { + X = 2, Y = Pos.Bottom(messagesFrame), + Width = Dim.Fill(), + Height = 4, + }; + _promptInput.KeyPress += OnPromptKeyPress; + + _statusBar = new StatusBar() + { + X = 0, Y = Pos.Bottom(_promptInput), + }; + + Add(messagesFrame, _spinner, _promptInput, _statusBar); + + // 订阅状态变更 + _stateSubscription = _stateStore.Subscribe(OnStateChanged); + } + + private void OnPromptKeyPress(KeyEventEventArgs e) + { + // Enter 提交, Shift+Enter 换行 + if (e.KeyEvent.Key == Key.Enter && !e.KeyEvent.IsShift) + { + e.Handled = true; + var input = _promptInput.Text.ToString()?.Trim(); + if (string.IsNullOrEmpty(input)) return; + + // 斜杠命令检测 + if (input.StartsWith('/')) + _ = HandleCommandAsync(input); + else + _ = HandleQueryAsync(input); + + _promptInput.Text = ""; + } + + // Ctrl+C 取消 + if (e.KeyEvent.Key == (Key.CtrlMask | Key.C)) + { + _ = _queryEngine.CancelAsync(); + e.Handled = true; + } + } + + private async Task HandleQueryAsync(string input) + { + _spinner.Visible = true; + _spinner.Text = "◉ Thinking..."; + + try + { + await foreach (var msg in _queryEngine.SubmitMessageAsync(input)) + { + switch (msg) + { + case SDKMessage.StreamingDelta delta: + UpdateAssistantMessage(delta.Text); + break; + case SDKMessage.ToolUseStart tus: + AddToolUseMessage(tus.Name, tus.Input); + break; + case SDKMessage.ToolUseResult tur: + UpdateToolResult(tur.ToolUseId, tur.Output); + break; + case SDKMessage.AssistantMessage: + _spinner.Visible = false; + break; + } + } + } + catch (OperationCanceledException) + { + AddSystemMessage("[Cancelled]"); + } + + _spinner.Visible = false; + } + + private async Task HandleCommandAsync(string input) + { + var parts = input.Split(' ', 2); + var commandName = parts[0][1..]; // 去掉 '/' + var args = parts.Length > 1 ? parts[1] : null; + + var commands = await _commands.GetEnabledCommandsAsync(); + var command = commands.FirstOrDefault(c => + c.Name == commandName || c.Aliases?.Contains(commandName) == true); + + if (command == null) + { + AddSystemMessage($"Unknown command: /{commandName}"); + return; + } + + var result = await command.ExecuteAsync( + new CommandContext(_stateStore, _queryEngine), args); + if (result.Output != null) + AddSystemMessage(result.Output); + } + + private void OnStateChanged(AppState state) + { + // 更新状态栏(须切回主线程) + Application.MainLoop.Invoke(() => + { + var model = state.MainLoopModelForSession ?? "claude-sonnet-4-6"; + var bgTasks = state.Tasks.Values + .Count(t => t.Status is TaskStatus.Running or TaskStatus.Pending); + _statusBar.Text = + $" Model: {model} | Tasks: {bgTasks} | Dir: {Directory.GetCurrentDirectory()}"; + }); + } +} +``` + +**设计意图** + +原始 `REPL.tsx` 通过 React 状态(`useState`/`useReducer`)驱动 UI 更新。`REPLScreen` 将这套推送模型转换为 `IAppStateStore.Subscribe` 订阅回调,并通过 `Application.MainLoop.Invoke` 确保 UI 变更在主线程执行,与 Ink 的 React reconciler 在语义上等价。 + +消息流处理通过 `await foreach` 消费 `IQueryEngine.SubmitMessageAsync` 返回的 `IAsyncEnumerable`,完整保留原始流式渲染语义。Ctrl+C 调用 `_queryEngine.CancelAsync()` 对应原始组件中的 `AbortController` 取消机制。 + +--- + +## 15.3 组件库 + +对应原始 `../../src/components/` 目录中的 50+ React/Ink 组件。以下列出两个核心组件的 .NET 实现。 + +### PermissionDialog + +```csharp +/// +/// 权限对话框 — 用户审批工具使用 +/// 对应原始 PermissionRequest 组件 +/// +public sealed class PermissionDialog : Dialog +{ + public PermissionResponse Response { get; private set; } + + public PermissionDialog(string toolName, string description, string input) + : base($"Permission: {toolName}", 60, 12) + { + var descLabel = new Label(description) { X = 1, Y = 1, Width = Dim.Fill() - 2 }; + var inputLabel = new Label(Truncate(input, 200)) { X = 1, Y = 3, Width = Dim.Fill() - 2 }; + + var allowBtn = new Button("Allow (y)") { X = 5, Y = 8 }; + var denyBtn = new Button("Deny (n)") { X = 25, Y = 8 }; + var alwaysBtn = new Button("Always Allow (a)") { X = 40, Y = 8 }; + + allowBtn.Clicked += () => { Response = PermissionResponse.AllowOnce; RequestStop(); }; + denyBtn.Clicked += () => { Response = PermissionResponse.Deny; RequestStop(); }; + alwaysBtn.Clicked += () => { Response = PermissionResponse.AllowAlways; RequestStop(); }; + + Add(descLabel, inputLabel, allowBtn, denyBtn, alwaysBtn); + } +} +``` + +**设计意图** + +原始 `PermissionRequest` 组件通过 React props 回调传递用户选择。`PermissionDialog` 改用 `PermissionResponse` 属性,调用方在 `Application.Run(dialog)` 返回后读取该属性,取得与原始回调语义等价的结果,但完全同步。 + +### CompanionSpriteView + +```csharp +/// +/// 同伴精灵渲染 — ASCII art sprite +/// 对应原始 CompanionSprite 组件 +/// +public sealed class CompanionSpriteView : View +{ + private readonly Companion _companion; + + public CompanionSpriteView(Companion companion) + { + _companion = companion; + Width = 20; + Height = 10; + } + + public override void Draw() + { + base.Draw(); + var sprite = CompanionSpriteRenderer.Render(_companion); + Move(0, 0); + for (var i = 0; i < sprite.Length; i++) + { + var line = sprite[i]; + Driver.AddStr(line); + Move(0, i + 1); + } + } +} +``` + +**设计意图** + +原始 `CompanionSprite` 是纯渲染组件,通过 Ink 的 `` 将 ASCII art 输出到终端。`CompanionSpriteView` 重写 `Draw()` 方法,用 Terminal.Gui 的 `Driver.AddStr` 逐行写入,保持与原始输出完全相同的视觉效果。`CompanionSpriteRenderer.Render` 静态方法封装精灵图案生成逻辑,独立于 UI 层测试。 + +--- + +## 组件映射一览 + +| 原始 Ink 组件 | .NET Terminal.Gui 实现 | 说明 | +|---|---|---| +| `REPL.tsx` | `REPLScreen : Window` | 主屏幕 | +| `PermissionRequest` | `PermissionDialog : Dialog` | 工具审批对话框 | +| `CompanionSprite` | `CompanionSpriteView : View` | ASCII 精灵渲染 | +| `StatusBar` | `StatusBar`(Terminal.Gui 内置)| 底部状态栏 | +| `Spinner` | `Label`(动态文本)| 思考中指示器 | +| `MessageList` | `ListView` | 消息历史列表 | +| `PromptInput` | `TextView` | 多行输入框 | + +其余 43+ 组件(`FileDiff`、`TokenBudget`、`HistoryPicker` 等)由对应的特性开关控制加载,在各自模块文档中描述。 + +--- + +## 参考资料 + +- [UI 与扩展设计 — 总览](UI与扩展设计.md) +- [核心模块设计 — 查询引擎 (QueryEngine)](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md) +- [核心模块设计 — 命令系统](../核心模块设计/核心模块设计-命令系统.md) +- [UI 与扩展设计 — 特性开关系统](UI与扩展设计-特性开关系统.md) diff --git a/docs/UI与扩展设计/UI与扩展设计-技能系统.md b/docs/UI与扩展设计/UI与扩展设计-技能系统.md new file mode 100644 index 0000000..74f82a3 --- /dev/null +++ b/docs/UI与扩展设计/UI与扩展设计-技能系统.md @@ -0,0 +1,265 @@ +# 技能系统 + +> 所属项目: free-code .NET 10 重写 +> 所属模块: [UI 与扩展设计](UI与扩展设计.md) +> 原始代码: `../../src/skills/` + +--- + +## 概述 + +技能系统允许用户以 Markdown 文件的形式定义可复用的指令集,通过斜杠命令或提示注入的方式触发。原始实现扫描 `.free-code/skills/` 目录,解析每个文件的 YAML frontmatter 获取元数据,并将 Markdown 正文作为 System Prompt 内容注入查询引擎。 + +.NET 重写通过 `ISkillLoader` 接口封装这一流程,用 `SkillDefinition` record 描述单条技能,用 `SkillHooks` record 描述生命周期钩子。`SkillLoader` 是默认实现,负责目录扫描、YAML 解析、结果缓存和执行委托。 + +--- + +## 16.1 接口与数据模型 + +```csharp +/// +/// 技能加载器 — 从 .free-code/skills/ 目录加载 SKILL.md +/// 对应原始 skill system 的目录扫描 + frontmatter 解析 +/// +public interface ISkillLoader +{ + Task> LoadAllSkillsAsync(); + Task LoadSkillAsync(string skillName); + Task ExecuteSkillAsync(SkillDefinition skill, string? args); +} +``` + +### SkillDefinition + +```csharp +/// +/// 技能定义 — 对应一个 SKILL.md 文件的完整解析结果 +/// +public sealed record SkillDefinition +{ + /// 技能名称,来自 frontmatter 或文件名 + public required string Name { get; init; } + + /// 技能描述,用于斜杠命令帮助文本 + public string? Description { get; init; } + + /// Markdown 正文,作为 System Prompt 注入 + public required string Content { get; init; } + + /// 技能允许使用的工具名称列表 + public IReadOnlyList Tools { get; init; } = []; + + /// 覆盖默认模型(如 "claude-haiku-4-5") + public string? Model { get; init; } + + /// 技能参数定义(参数名 → 描述) + public IReadOnlyDictionary? Arguments { get; init; } + + /// 生命周期钩子 + public SkillHooks? Hooks { get; init; } + + /// 磁盘路径,仅供调试 + public string? FilePath { get; init; } +} +``` + +### SkillHooks + +```csharp +/// +/// 技能生命周期钩子 +/// 对应原始 skill hooks 机制 +/// +public sealed record SkillHooks +{ + /// 执行前的提示注入片段 + public string? PreExecute { get; init; } + + /// 执行后的提示注入片段 + public string? PostExecute { get; init; } + + /// 发生错误时的提示注入片段 + public string? OnError { get; init; } +} +``` + +**设计意图** + +`SkillDefinition` 使用 `sealed record` 确保不可变性:一旦从磁盘解析完成,技能定义在整个会话生命周期内不会改变。`Tools` 字段对应原始 frontmatter 中的 `tools` 数组,允许技能声明自己需要哪些工具,`ToolRegistry` 在组装工具池时可据此过滤。 + +--- + +## 16.2 SkillLoader 实现 + +```csharp +public sealed class SkillLoader : ISkillLoader +{ + private readonly string[] _skillDirectories; + private List? _cached; + private readonly ILogger _logger; + + public SkillLoader(ILogger logger) + { + _logger = logger; + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var cwd = Directory.GetCurrentDirectory(); + + // 扫描顺序: 项目级 → 用户级 + _skillDirectories = + [ + Path.Combine(cwd, ".free-code", "skills"), + Path.Combine(home, ".free-code", "skills"), + ]; + } + + public async Task> LoadAllSkillsAsync() + { + if (_cached != null) return _cached; + _cached = []; + + foreach (var dir in _skillDirectories) + { + if (!Directory.Exists(dir)) continue; + foreach (var file in Directory.GetFiles(dir, "*.md", SearchOption.AllDirectories)) + { + try + { + var skill = await ParseSkillFileAsync(file); + if (skill != null) _cached.Add(skill); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load skill from {File}", file); + } + } + } + return _cached; + } + + public async Task LoadSkillAsync(string skillName) + { + var all = await LoadAllSkillsAsync(); + return all.FirstOrDefault(s => + string.Equals(s.Name, skillName, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// 解析 SKILL.md — YAML frontmatter + Markdown body + /// + private static async Task ParseSkillFileAsync(string filePath) + { + var content = await File.ReadAllTextAsync(filePath); + if (!content.StartsWith("---")) return null; + + var endIdx = content.IndexOf("\n---", 3); + if (endIdx < 0) return null; + + var frontmatter = content[3..endIdx]; + var body = content[(endIdx + 4)..].TrimStart(); + + // 解析 YAML frontmatter + var deserializer = new YamlDotNet.Serialization.DeserializerBuilder().Build(); + var meta = deserializer.Deserialize>(frontmatter); + + return new SkillDefinition + { + Name = meta.GetValueOrDefault("name", + Path.GetFileNameWithoutExtension(filePath))?.ToString() ?? "", + Description = meta.GetValueOrDefault("description")?.ToString(), + Content = body, + Tools = (meta.GetValueOrDefault("tools") as List) + ?.Select(o => o.ToString() ?? "") + .ToList() ?? [], + Model = meta.GetValueOrDefault("model")?.ToString(), + FilePath = filePath, + }; + } + + public async Task ExecuteSkillAsync(SkillDefinition skill, string? args) + { + // 技能内容注入 System Prompt,参数拼接到末尾,由 QueryEngine 执行 + var fullPrompt = skill.Content; + if (args != null) + fullPrompt += $"\n\nArguments: {args}"; + + // 通过 QueryEngine 提交,渲染到 UI + var engine = /* 从 IServiceProvider 解析 */; + await foreach (var msg in engine.SubmitMessageAsync(fullPrompt)) + { + // UI 渲染逻辑 + } + } +} +``` + +--- + +## 目录扫描与优先级 + +`SkillLoader` 按以下顺序扫描两个目录,先找到的技能名称优先: + +| 优先级 | 路径 | 说明 | +|---|---|---| +| 1(高)| `<当前工作目录>/.free-code/skills/` | 项目级技能,随代码库版本管理 | +| 2(低)| `~/.free-code/skills/` | 用户级技能,跨项目共享 | + +同名技能以项目级为准,允许项目级覆盖用户级的默认行为。 + +--- + +## SKILL.md 文件格式 + +```markdown +--- +name: code-review +description: 对当前分支的变更进行代码审查 +tools: + - Bash + - Read +model: claude-sonnet-4-6 +--- + +你是一位严谨的代码审查员。分析提供的代码变更,重点关注: +- 潜在的 bug 和边界条件 +- 安全漏洞 +- 性能问题 +- 代码风格与项目规范的一致性 + +输出格式:按严重性分组,每条问题标注文件和行号。 +``` + +frontmatter 字段说明: + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `name` | `string` | 否 | 技能名称,默认使用文件名 | +| `description` | `string` | 否 | 显示在斜杠命令帮助中的描述 | +| `tools` | `string[]` | 否 | 允许使用的工具,空则使用全部 | +| `model` | `string` | 否 | 覆盖默认模型 | +| `arguments` | `object` | 否 | 参数定义(名称 → 描述) | + +--- + +## 生命周期钩子执行顺序 + +``` +ExecuteSkillAsync(skill, args) + │ + ├── 1. hooks.PreExecute → 注入到提示前缀 + ├── 2. skill.Content → 主提示内容 + ├── 3. args → 用户传入参数 + │ + └── QueryEngine.SubmitMessageAsync(fullPrompt) + │ + └── [成功] hooks.PostExecute + └── [失败] hooks.OnError +``` + +--- + +## 参考资料 + +- [UI 与扩展设计 — 总览](UI与扩展设计.md) +- [核心模块设计 — 查询引擎 (QueryEngine)](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md) +- [核心模块设计 — 命令系统](../核心模块设计/核心模块设计-命令系统.md) +- [UI 与扩展设计 — 插件系统](UI与扩展设计-插件系统.md) diff --git a/docs/UI与扩展设计/UI与扩展设计-插件系统.md b/docs/UI与扩展设计/UI与扩展设计-插件系统.md new file mode 100644 index 0000000..bdc936f --- /dev/null +++ b/docs/UI与扩展设计/UI与扩展设计-插件系统.md @@ -0,0 +1,280 @@ +# 插件系统 + +> 所属项目: free-code .NET 10 重写 +> 所属模块: [UI 与扩展设计](UI与扩展设计.md) +> 原始代码: `../../src/plugins/` + +--- + +## 概述 + +插件系统允许第三方代码在运行时扩展 free-code 的能力,包括注册新技能、新斜杠命令和新 MCP 服务器配置。原始实现通过 Node.js 的动态 `import()` 加载插件模块,利用模块缓存隔离各插件的命名空间。 + +.NET 重写用 `AssemblyLoadContext`(ALC)替代动态导入,实现更强的隔离性和确定性卸载。每个插件运行在独立的 `PluginLoadContext` 实例中,其依赖项从插件目录优先解析,不与宿主进程或其他插件共享程序集版本。`PluginManager` 管理所有插件的生命周期,支持安装、卸载、启用和禁用操作。 + +--- + +## 17.1 接口定义 + +```csharp +/// +/// 插件管理器 — AssemblyLoadContext 隔离加载 +/// 对应原始 plugin system +/// +public interface IPluginManager +{ + Task> LoadAllPluginsAsync(); + Task InstallPluginAsync(string pluginId); + Task UninstallPluginAsync(string pluginId); + Task EnablePluginAsync(string pluginId); + Task DisablePluginAsync(string pluginId); + Task RefreshAsync(); +} +``` + +--- + +## 17.2 LoadedPlugin 与 PluginManifest + +```csharp +/// +/// 已加载的插件实例描述 +/// +public sealed record LoadedPlugin +{ + /// 插件唯一标识符(目录名) + public required string Id { get; init; } + + /// 插件显示名称 + public required string Name { get; init; } + + /// 版本字符串 + public required string Version { get; init; } + + /// 来源:marketplace | local | github + public required string Source { get; init; } + + /// 主程序集路径 + public required string AssemblyPath { get; init; } + + /// 是否启用 + public bool IsEnabled { get; init; } + + /// 插件清单 + public PluginManifest Manifest { get; init; } = new(); +} + +/// +/// 插件清单 — plugin.json 反序列化目标 +/// 描述插件向宿主暴露的所有扩展点 +/// +public sealed record PluginManifest +{ + /// 插件提供的技能列表 + public IReadOnlyList Skills { get; init; } = []; + + /// 插件提供的斜杠命令列表 + public IReadOnlyList Commands { get; init; } = []; + + /// 插件注册的 MCP 服务器配置(服务器名 → 配置) + public IReadOnlyDictionary McpServers { get; init; } + = new Dictionary(); +} +``` + +**设计意图** + +`PluginManifest` 直接对应 `plugin.json` 的顶层结构,三个扩展点(技能、命令、MCP 服务器)分别映射为 `SkillLoader`、`CommandRegistry`、`McpClientManager` 的动态注册入口。插件卸载时,这三个注册表各自移除该插件贡献的条目。 + +--- + +## 17.3 PluginLoadContext + +```csharp +/// +/// 可卸载的程序集加载上下文 +/// 对应原始动态 import() 的隔离语义 +/// +public sealed class PluginLoadContext : AssemblyLoadContext +{ + private readonly AssemblyDependencyResolver _resolver; + + public PluginLoadContext(string pluginPath) : base(isCollectible: true) + { + _resolver = new AssemblyDependencyResolver(pluginPath); + } + + protected override Assembly? Load(AssemblyName assemblyName) + { + // 优先从插件目录解析,回退到默认加载上下文 + var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName); + return assemblyPath != null + ? LoadFromAssemblyPath(assemblyPath) + : null; // null 触发回退到默认 ALC + } + + protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) + { + var libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName); + return libraryPath != null + ? LoadUnmanagedDllFromPath(libraryPath) + : IntPtr.Zero; + } +} +``` + +**设计意图** + +`isCollectible: true` 是关键参数。普通 `AssemblyLoadContext` 加载的程序集在整个进程生命周期内无法卸载。可回收 ALC 允许在所有对该上下文中类型的强引用归零后,GC 完整回收其托管内存和元数据,这是 `/plugin uninstall` 命令实现热卸载的基础。 + +`AssemblyDependencyResolver` 读取插件主程序集旁边的 `.deps.json` 文件,自动解析所有传递依赖的路径。插件作者只需将所有依赖打包到插件目录,无需关心宿主进程中已有哪些程序集版本。 + +--- + +## 17.4 PluginManager 实现 + +```csharp +public sealed class PluginManager : IPluginManager +{ + private readonly ConcurrentDictionary _contexts = new(); + private readonly ConcurrentDictionary _plugins = new(); + + public async Task> LoadAllPluginsAsync() + { + var pluginDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".free-code", "plugins"); + + if (!Directory.Exists(pluginDir)) return []; + + foreach (var dir in Directory.GetDirectories(pluginDir)) + { + var manifestPath = Path.Combine(dir, "plugin.json"); + if (!File.Exists(manifestPath)) continue; + + var manifest = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(manifestPath)); + + var dllPath = Directory.GetFiles(dir, "*.dll").FirstOrDefault(); + if (dllPath == null) continue; + + var context = new PluginLoadContext(dllPath); + var assembly = context.LoadFromAssemblyPath(dllPath); + + _contexts[dir] = context; + _plugins[dir] = new LoadedPlugin + { + Id = Path.GetFileName(dir), + Name = manifest?.Skills.FirstOrDefault()?.Name + ?? Path.GetFileName(dir), + Version = "1.0", + Source = "local", + AssemblyPath = dllPath, + IsEnabled = true, + }; + } + + return _plugins.Values.ToList(); + } + + public async Task UninstallPluginAsync(string pluginId) + { + if (_contexts.TryRemove(pluginId, out var context)) + { + context.Unload(); // 触发 GC 可回收流程 + } + _plugins.TryRemove(pluginId, out _); + } + + public Task RefreshAsync() + { + // 重新扫描目录,加载新插件,卸载已删除的 + return LoadAllPluginsAsync(); + } + + public Task InstallPluginAsync(string pluginId) + { + // 从 marketplace / GitHub 下载到 ~/.free-code/plugins// + // 然后调用 LoadAllPluginsAsync 触发加载 + throw new NotImplementedException("待实现:marketplace 下载逻辑"); + } + + public Task EnablePluginAsync(string pluginId) + { + if (_plugins.TryGetValue(pluginId, out var plugin)) + _plugins[pluginId] = plugin with { IsEnabled = true }; + return Task.CompletedTask; + } + + public Task DisablePluginAsync(string pluginId) + { + if (_plugins.TryGetValue(pluginId, out var plugin)) + _plugins[pluginId] = plugin with { IsEnabled = false }; + return Task.CompletedTask; + } +} +``` + +--- + +## 插件目录结构 + +``` +~/.free-code/plugins/ +└── my-plugin/ + ├── plugin.json # 插件清单 + ├── MyPlugin.dll # 主程序集 + ├── MyPlugin.deps.json # 依赖清单(供 AssemblyDependencyResolver 使用) + └── SomeDependency.dll # 插件私有依赖 +``` + +### plugin.json 示例 + +```json +{ + "skills": [ + { + "name": "my-skill", + "description": "自定义技能示例", + "content": "你是一个帮助用户完成特定任务的助手。" + } + ], + "commands": [], + "mcpServers": { + "my-mcp": { + "command": "npx", + "args": ["-y", "my-mcp-server"], + "scope": "local" + } + } +} +``` + +--- + +## 生命周期图 + +``` +LoadAllPluginsAsync() + │ + ├── 扫描 ~/.free-code/plugins/ 目录 + ├── 读取 plugin.json → 反序列化 PluginManifest + ├── 创建 PluginLoadContext(dllPath) + ├── LoadFromAssemblyPath(dllPath) + └── 注册到 _plugins / _contexts + +UninstallPluginAsync(id) + │ + ├── context.Unload() [ALC 标记为待回收] + ├── 移除 _contexts[id] [WeakReference 归零后 GC 回收] + └── 移除 _plugins[id] [插件对象消失] +``` + +--- + +## 参考资料 + +- [UI 与扩展设计 — 总览](UI与扩展设计.md) +- [UI 与扩展设计 — 技能系统](UI与扩展设计-技能系统.md) +- [核心模块设计 — 命令系统](../核心模块设计/核心模块设计-命令系统.md) +- [基础设施设计 — MCP 协议集成](../基础设施设计/基础设施设计-MCP协议集成.md) diff --git a/docs/UI与扩展设计/UI与扩展设计-特性开关系统.md b/docs/UI与扩展设计/UI与扩展设计-特性开关系统.md new file mode 100644 index 0000000..431fdb7 --- /dev/null +++ b/docs/UI与扩展设计/UI与扩展设计-特性开关系统.md @@ -0,0 +1,270 @@ +# 特性开关系统 + +> 所属项目: free-code .NET 10 重写 +> 所属模块: [UI 与扩展设计](UI与扩展设计.md) +> 原始代码: `scripts/build.ts` `feature()` 编译时开关 + 运行时 GrowthBook + +--- + +## 概述 + +特性开关系统控制 free-code 的 88 个功能标志,其中 54 个可编译、34 个损坏。原始实现有两个层次:编译时用 `scripts/build.ts` 的 `feature()` 函数注入 `#define`,运行时用 GrowthBook SDK 从云端拉取实验配置。 + +.NET 重写将这两个层次统一到同一套接口下。`FeatureFlags` 静态类持有全部 54 个常量,供编译时 `#if` 和运行时查询共同使用;`IFeatureFlagService` 抽象运行时查询;`FeatureFlagService` 从 `appsettings.json` 的 `FeatureFlags` 配置节读取初始值,取代原始 GrowthBook 的云端下发机制,确保零网络依赖。 + +--- + +## 18.1 FeatureFlags 常量清单 + +```csharp +/// +/// 特性开关常量 — 编译时 #if + 运行时查询 +/// 对应原始 88 个 feature flags(54 可编译,34 损坏) +/// +public static class FeatureFlags +{ + // ===== 交互与 UI ===== + public const string Ultraplan = "ULTRAPLAN"; + public const string Ultrathink = "ULTRATHINK"; + public const string VoiceMode = "VOICE_MODE"; + public const string TokenBudget = "TOKEN_BUDGET"; + public const string HistoryPicker = "HISTORY_PICKER"; + public const string MessageActions = "MESSAGE_ACTIONS"; + public const string QuickSearch = "QUICK_SEARCH"; + public const string ShotStats = "SHOT_STATS"; + + // ===== Agent、记忆与规划 ===== + public const string BuiltinExplorePlanAgents = "BUILTIN_EXPLORE_PLAN_AGENTS"; + public const string VerificationAgent = "VERIFICATION_AGENT"; + public const string AgentTriggers = "AGENT_TRIGGERS"; + public const string AgentTriggersRemote = "AGENT_TRIGGERS_REMOTE"; + public const string ExtractMemories = "EXTRACT_MEMORIES"; + public const string CompactionReminders = "COMPACTION_REMINDERS"; + public const string CachedMicrocompact = "CACHED_MICROCOMPACT"; + public const string TeamMem = "TEAMMEM"; + + // ===== 工具与基础设施 ===== + public const string BridgeMode = "BRIDGE_MODE"; + public const string BashClassifier = "BASH_CLASSIFIER"; + public const string PromptCacheBreakDetection = "PROMPT_CACHE_BREAK_DETECTION"; + + // ===== 其余 35 个可编译标志 ===== + public const string Buddy = "BUDDY"; + public const string V2Todo = "V2_TODO"; + public const string EnableLspTool = "ENABLE_LSP_TOOL"; + public const string ContextCollapse = "CONTEXT_COLLAPSE"; + public const string TokenBudgetWarning = "TOKEN_BUDGET_WARNING"; + public const string MemoryAutoExtract = "MEMORY_AUTO_EXTRACT"; + public const string CompactHistory = "COMPACT_HISTORY"; + public const string PermissionClassifier = "PERMISSION_CLASSIFIER"; + public const string AsyncToolExecution = "ASYNC_TOOL_EXECUTION"; + public const string Speculation = "SPECULATION"; + public const string PromptSuggestion = "PROMPT_SUGGESTION"; + public const string SkillImprovement = "SKILL_IMPROVEMENT"; + public const string AgentSwarm = "ENABLE_AGENT_SWARMS"; + public const string WorkflowTool = "WORKFLOW_TOOL"; + public const string MonitorTool = "MONITOR_TOOL"; + public const string McpSkills = "MCP_SKILLS"; + public const string ChicagoMcp = "CHICAGO_MCP"; + public const string EnableDeskApi = "ENABLE_DESK_API"; + public const string Heapdump = "HEAPDUMP"; + public const string EffortFlag = "EFFORT"; + public const string Advisor = "ADVISOR"; + public const string ThinkingBudget = "THINKING_BUDGET"; + public const string RemoteEnv = "REMOTE_ENV"; + public const string NotifyTool = "NOTIFY_TOOL"; + public const string CaptureTool = "CAPTURE_TOOL"; + public const string ReplayUserMessages = "REPLAY_USER_MESSAGES"; + public const string TelemetryFlush = "TELEMETRY_FLUSH"; + public const string StripUnusedImports = "STRIP_UNUSED_IMPORTS"; + public const string ToolResultStorage = "TOOL_RESULT_STORAGE"; + public const string McpStreaming = "MCP_STREAMING"; + public const string PlanMode = "PLAN_MODE"; + public const string PlanModeV2 = "PLAN_MODE_V2"; + public const string RemoteControl = "REMOTE_CONTROL"; + public const string ConsentedRemoteControl = "CONSENTED_REMOTE_CONTROL"; + public const string CcrV2 = "CCR_V2"; + public const string EnableSandbox = "ENABLE_SANDBOX"; + public const string DiffBasedEdit = "DIFF_BASED_EDIT"; + public const string OutputTruncation = "OUTPUT_TRUNCATION"; + public const string WebSearchTool = "WEB_SEARCH_TOOL"; + public const string PythonTool = "PYTHON_TOOL"; + public const string NotebookEditTool = "NOTEBOOK_EDIT_TOOL"; + public const string DedupeToolCalls = "DEDUPE_TOOL_CALLS"; + public const string IdeIntegration = "IDE_INTEGRATION"; + public const string RateLimitUi = "RATE_LIMIT_UI"; + public const string TokenCounting = "TOKEN_COUNTING"; +} +``` + +--- + +## 标志分类一览 + +| 类别 | 标志数量 | 说明 | +|---|---|---| +| 交互与 UI | 8 | 提示词增强、历史记录、语音模式等 | +| Agent、记忆与规划 | 8 | 多 Agent、记忆提取、规划模式等 | +| 工具与基础设施 | 3 | IDE 桥接、分类器、缓存检测 | +| 其余可编译标志 | 35 | 各类实验功能 | +| **合计可编译** | **54** | | +| 损坏标志(不含)| 34 | 需重建,详见 FEATURES.md | + +--- + +## 18.2 IFeatureFlagService 接口 + +```csharp +/// +/// 运行时特性开关查询接口 +/// +public interface IFeatureFlagService +{ + /// 查询指定标志是否启用 + bool IsEnabled(string flag); + + /// 动态设置标志状态(用于测试或运行时覆盖) + void SetFlag(string flag, bool enabled); +} +``` + +--- + +## 18.3 FeatureFlagService 实现 + +```csharp +/// +/// 运行时特性开关实现 +/// 编译时使用 #if FLAG_NAME,运行时使用 IsEnabled() +/// 对应原始 GrowthBook SDK 的本地替代 +/// +public sealed class FeatureFlagService : IFeatureFlagService +{ + private readonly ConcurrentDictionary _flags = new(); + + public FeatureFlagService(IConfiguration config) + { + // 从 appsettings.json 的 FeatureFlags 节加载 + var section = config.GetSection("FeatureFlags"); + foreach (var child in section.GetChildren()) + _flags[child.Key] = bool.Parse(child.Value ?? "false"); + } + + public bool IsEnabled(string flag) + => _flags.GetValueOrDefault(flag, false); + + public void SetFlag(string flag, bool enabled) + => _flags[flag] = enabled; +} +``` + +--- + +## 双模式设计 + +特性开关系统同时支持两种工作模式,对应原始实现的两个不同层次。 + +### 编译时模式(`#if`) + +```csharp +// 编译时完全消除死代码,减小二进制体积 +#if ULTRAPLAN +services.AddSingleton(); +#endif + +#if VOICE_MODE +services.AddSingleton(); +#endif +``` + +编译时标志通过 MSBuild 属性注入,对应原始 `build.ts` 的 `feature()` 函数: + +```xml + + + $(DefineConstants);ULTRAPLAN + +``` + +构建命令示例: + +```bash +# 全功能构建(对应原始 build:dev:full) +dotnet build -p:FEATURE_ULTRAPLAN=true -p:FEATURE_VOICE_MODE=true ... + +# 仅启用语音模式(对应原始默认构建) +dotnet build -p:FEATURE_VOICE_MODE=true +``` + +### 运行时模式(`IsEnabled()`) + +```csharp +// 不重新编译就能切换,由配置文件驱动 +if (_features.IsEnabled(FeatureFlags.TokenBudget)) +{ + // 显示 token 预算组件 +} + +if (_features.IsEnabled(FeatureFlags.ExtractMemories)) +{ + await _memoryService.TryExtractAsync(messages); +} +``` + +`appsettings.json` 配置示例: + +```json +{ + "FeatureFlags": { + "TOKEN_BUDGET": true, + "HISTORY_PICKER": true, + "EXTRACT_MEMORIES": false, + "ULTRAPLAN": false + } +} +``` + +--- + +## 原始实现对比 + +| 原始(TypeScript)| .NET 重写 | 说明 | +|---|---|---| +| `scripts/build.ts` `feature("FLAG")` | MSBuild `-p:FEATURE_FLAG=true` | 编译时开关 | +| `#if defined(FLAG)` in bundled output | `#if FLAG_NAME` in C# | 死代码消除 | +| GrowthBook SDK(云端下发)| `FeatureFlagService`(配置文件)| 运行时开关 | +| `growthbook.isOn("flag-name")` | `_features.IsEnabled(FeatureFlags.Xxx)` | 查询 API | +| 34 损坏标志(缺少资源/类型)| 保留常量,实现体标记 TODO | 向后兼容占位 | + +**关键差异**:.NET 重写移除了 GrowthBook 的网络依赖。原始实现在每次启动时联系 GrowthBook 服务器更新标志状态,这是一个隐式的遥测上报渠道。`FeatureFlagService` 完全本地化,配置通过 `appsettings.json` 或环境变量覆盖,无任何出站请求。 + +--- + +## DI 注册 + +```csharp +// Program.cs 或服务注册扩展方法 +services.AddSingleton(); + +// 使用示例(构造函数注入) +public sealed class SomeService(IFeatureFlagService features) +{ + public void DoWork() + { + if (features.IsEnabled(FeatureFlags.AsyncToolExecution)) + { + // 异步工具执行路径 + } + } +} +``` + +--- + +## 参考资料 + +- [UI 与扩展设计 — 总览](UI与扩展设计.md) +- [UI 与扩展设计 — Terminal.Gui 终端 UI](UI与扩展设计-Terminal-Gui终端UI.md) +- [核心模块设计 — 查询引擎 (QueryEngine)](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md) +- [测试与构建](../测试与构建/) +- [FEATURES.md](../../../FEATURES.md)(完整 88 个标志审计) diff --git a/docs/UI与扩展设计/UI与扩展设计.md b/docs/UI与扩展设计/UI与扩展设计.md new file mode 100644 index 0000000..5e5aacb --- /dev/null +++ b/docs/UI与扩展设计/UI与扩展设计.md @@ -0,0 +1,95 @@ +# UI 与扩展设计 — 总览 + +> 所属项目: free-code .NET 10 重写 +> 配套文档: [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md) | [核心模块设计](../核心模块设计/核心模块设计.md) | [基础设施设计](../基础设施设计/) | [服务子系统设计](../服务子系统设计/) + +--- + +## 概述 + +UI 与扩展设计覆盖 free-code .NET 10 重写中所有面向用户的终端界面层,以及三个扩展机制:技能、插件和特性开关。 + +原始 TypeScript 实现以 React/Ink 渲染终端 UI,用文件系统约定管理技能,用动态 `import()` 加载插件,用编译时 `feature()` 函数控制特性开关。.NET 重写将这四个关注点各自封装为独立模块:Terminal.Gui 替代 Ink 提供声明式终端 UI,`ISkillLoader` 替代目录扫描约定,`AssemblyLoadContext` 替代动态导入实现插件隔离,`IFeatureFlagService` 在运行时桥接编译时标志与配置驱动开关。 + +--- + +## 架构概览 + +``` +REPLScreen (Terminal.Gui Window) + │ + ├── PermissionDialog 工具使用审批 + ├── CompanionSpriteView ASCII 精灵渲染 + │ + ├── ISkillLoader 技能加载与执行 + │ └── SkillDefinition + SkillHooks + │ + ├── IPluginManager 插件生命周期管理 + │ └── PluginLoadContext (AssemblyLoadContext) + │ + └── IFeatureFlagService 特性开关查询 + └── FeatureFlags (54+ 常量) +``` + +--- + +## 子模块列表 + +### [Terminal.Gui 终端 UI](UI与扩展设计-Terminal-Gui终端UI.md) + +覆盖 `TerminalApp` 应用启动与主题配置、`REPLScreen` 主交互屏幕的完整实现,以及 `PermissionDialog` 和 `CompanionSpriteView` 组件。 + +原始来源: `../../src/screens/REPL.tsx`, `../../src/components/`(50+ React/Ink 组件) + +--- + +### [技能系统](UI与扩展设计-技能系统.md) + +覆盖 `ISkillLoader` 接口、`SkillDefinition` 数据模型、`SkillHooks` 生命周期钩子,以及 `SkillLoader` 的目录扫描与 YAML frontmatter 解析实现。 + +原始来源: `../../src/skills/` + +--- + +### [插件系统](UI与扩展设计-插件系统.md) + +覆盖 `IPluginManager` 接口、`PluginLoadContext` 可卸载程序集加载上下文、`PluginManager` 的完整实现,以及 `PluginManifest` 清单结构。 + +原始来源: `../../src/plugins/` + +--- + +### [特性开关系统](UI与扩展设计-特性开关系统.md) + +覆盖 `FeatureFlags` 完整常量清单(54+ 标志)、`IFeatureFlagService` 接口,以及 `FeatureFlagService` 的配置驱动实现。说明编译时 `#if` 与运行时 `IsEnabled()` 的双模式设计。 + +原始来源: `scripts/build.ts` `feature()` 编译时开关 + 运行时 GrowthBook + +--- + +## 关键设计决策 + +**Terminal.Gui 替代 React/Ink** + +原始项目深度依赖 React 组件模型和 Ink 的虚拟 DOM 终端渲染。.NET 重写选用 Terminal.Gui,它提供与 Ink 语义最接近的声明式布局(`Pos`/`Dim`)、事件驱动模型和色彩主题系统,同时完全原生于 .NET 运行时,无需 Node.js 桥接。 + +**技能作为 System Prompt 注入** + +技能系统不引入自定义执行运行时。技能的 Markdown 内容直接注入 `QueryEngine` 的 System Prompt,参数拼接到提示末尾,其余执行逻辑全部复用主查询管道。这与原始实现的语义完全一致,且不新增任何抽象层。 + +**插件隔离通过 `isCollectible: true`** + +`PluginLoadContext` 以 `isCollectible: true` 创建,允许在 `UninstallPluginAsync` 调用后通过 GC 完整回收插件的所有托管内存和类型元数据。这在语义上等价于原始 TypeScript 的动态 `import()` 隔离,但提供更强的确定性卸载保证。 + +**特性开关双模式并存** + +编译时标志(`#if ULTRAPLAN`)用于完全消除死代码,减小最终二进制体积。运行时标志(`IFeatureFlagService.IsEnabled`)用于不重新编译就能切换的开关,由 `appsettings.json` 中的 `FeatureFlags` 配置节驱动,取代原始 GrowthBook 的云端下发机制。 + +--- + +## 参考资料 + +- [核心模块设计 — 查询引擎 (QueryEngine)](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md) +- [核心模块设计 — 命令系统](../核心模块设计/核心模块设计-命令系统.md) +- [服务子系统设计](../服务子系统设计/) +- [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md) diff --git a/docs/UI与扩展设计/reference/原始代码映射-UI与扩展.md b/docs/UI与扩展设计/reference/原始代码映射-UI与扩展.md new file mode 100644 index 0000000..e7ed52a --- /dev/null +++ b/docs/UI与扩展设计/reference/原始代码映射-UI与扩展.md @@ -0,0 +1,30 @@ +# 原始代码映射:UI 与扩展设计 + +> 交叉引用:详见《UI 与扩展设计》总览文档中的模块分层、命名约定与扩展边界说明。 + +## .NET 类型 → 原始 TypeScript 源文件映射 + +| .NET 类型/模块 | 原始 TypeScript 源文件 | 对应关系说明 | +|---|---|---| +| TerminalApp | `../../../src/screens/REPL.tsx` + `../../../src/components/` | 对应终端主入口与交互循环;UI 逻辑主要集中在 REPL 屏幕,配套的 Ink/React 组件负责局部渲染与交互。 | +| REPLScreen | `../../../src/screens/REPL.tsx` | 直接对应 REPL 主屏幕实现,是终端 UI 的核心承载层。 | +| PermissionDialog | `../../../src/components/` | 对应权限确认类对话框组件,属于终端 UI 中的交互式弹窗/提示层。 | +| CompanionSpriteView | `../../../src/components/` | 对应陪伴角色/装饰性展示组件,通常作为界面中的独立视图单元实现。 | +| ISkillLoader | `../../../src/skills/` | 对应技能加载抽象接口,用于定义技能发现、解析与装载能力。 | +| SkillDefinition | `../../../src/skills/` | 对应技能定义模型,承载技能元数据、入口与执行相关描述。 | +| SkillHooks | `../../../src/skills/` | 对应技能生命周期钩子集合,用于扩展技能执行流程。 | +| SkillLoader | `../../../src/skills/` | 对应技能加载器实现,负责读取并实例化技能配置。 | +| IPluginManager | `../../../src/plugins/` | 对应插件管理抽象接口,定义插件注册、启用与调度能力。 | +| PluginLoadContext | `../../../src/plugins/` | 对应插件加载上下文,封装插件初始化与运行时依赖。 | +| PluginManager | `../../../src/plugins/` | 对应插件管理器实现,负责插件生命周期管理。 | +| LoadedPlugin | `../../../src/plugins/` | 对应已加载插件实例模型,表示运行时插件对象。 | +| PluginManifest | `../../../src/plugins/` | 对应插件清单/描述文件模型,保存插件名称、版本、入口等信息。 | +| FeatureFlags | `scripts/build.ts` 的 `feature()` + 运行时 GrowthBook | 对应构建期特性开关与运行时实验能力的统一抽象。 | +| IFeatureFlagService | `scripts/build.ts` 的 `feature()` + 运行时 GrowthBook | 对应特性开关服务接口,既覆盖编译期注入,也覆盖运行时读取。 | +| FeatureFlagService | `scripts/build.ts` 的 `feature()` + 运行时 GrowthBook | 对应特性开关服务实现,负责聚合构建时与运行时的开关结果。 | + +## .NET 命名约定说明 + +- `.NET 类型/模块` 列优先使用 C# 语义化命名,表示面向对象层的抽象、服务或模型。 +- `原始 TypeScript 源文件` 列仅记录最接近的源代码位置;当实现分散时,用 `+` 标明多个来源。 +- `对应关系说明` 以职责映射为准,不强求 1:1 文件对应,便于后续从 TypeScript 迁移到 .NET 结构时保持概念一致。 diff --git a/docs/free-code 项目结构完整分析报告.md b/docs/free-code 项目结构完整分析报告.md new file mode 100644 index 0000000..a631806 --- /dev/null +++ b/docs/free-code 项目结构完整分析报告.md @@ -0,0 +1,968 @@ +# free-code 项目结构完整分析报告 + +## 一、项目概览 + +| 项目 | 详情 | +|---|---| +| **名称** | free-code (Claude Code 的开源重建版本) | +| **版本** | 2.1.87 | +| **来源** | Anthropic Claude Code CLI 的源码快照(2026-03-31 npm 分发包 source map 暴露后重建) | +| **运行时** | Bun >= 1.3.11 | +| **语言** | TypeScript (ESNext, JSX: react-jsx) | +| **总代码量** | ~1,997 个 TS/TSX 文件,共 ~512,834 行代码 | +| **核心框架** | React 19 + Ink 6 (终端 UI) | + +### 三大修改方向 + +1. **遥测移除** — 所有 OpenTelemetry/gRPC、GrowthBook 分析、Sentry 报告等外发遥测全部替换为空实现(stub) +2. **安全提示护栏移除** — 移除了 Anthropic 注入的额外限制性系统提示 +3. **实验性功能解锁** — 88 个 feature flag 中 54 个可编译通过并启用 + +--- + +## 二、目录结构与代码量 + +| 目录 | 文件数 | 代码行数 | 职责 | +|---|---|---|---| +| `../src/utils/` | 577 | 178,924 | 工具函数(模型管理、认证、设置、权限等) | +| `../src/components/` | 400 | 81,808 | Ink/React 终端 UI 组件 | +| `../src/services/` | 147 | 54,351 | API 客户端、OAuth、MCP、分析(已 stub) | +| `../src/tools/` | 210 | 51,068 | 40+ Agent 工具实现 | +| `../src/commands/` | 218 | 26,526 | 70+ 斜杠命令 | +| `../src/ink/` | 98 | 19,844 | Ink 框架内部(终端渲染引擎) | +| `../src/hooks/` | 104 | 19,205 | React hooks | +| `../src/bridge/` | 32 | 12,614 | IDE 远程控制桥接 | +| `../src/cli/` | 21 | 12,408 | CLI 参数解析和命令分发 | +| `../src/screens/` | 3 | 5,981 | 主界面 REPL | +| `../src/entrypoints/` | 11 | 4,162 | 入口点 | +| `../src/skills/` | 50 | 4,092 | 技能系统 | +| `../src/native-ts/` | 4 | 4,081 | 原生模块绑定 | +| `../src/types/` | 12 | 3,468 | 类型定义 | +| `../src/tasks/` | 14 | 3,288 | 后台任务管理 | +| `../src/keybindings/` | 14 | 3,159 | 快捷键配置 | +| `../src/constants/` | 22 | 2,725 | 常量定义 | +| 其他 (18 个目录) | — | — | vim、状态、迁移、MCP 等 | + +### 完整目录列表 + +``` +../src/ + assistant/ # 助手会话管理 + bootstrap/ # 启动引导 + bridge/ # IDE 远程控制桥接(32 文件) + buddy/ # Buddy 功能 + cli/ # CLI 参数解析和快速路径分发(21 文件) + commands/ # 70+ 斜杠命令实现(218 文件) + components/ # Ink/React 终端 UI 组件(400 文件) + constants/ # 常量定义(22 文件) + context/ # React 上下文 + coordinator/ # 协调器模式 + daemon/ # 后台守护进程 + entrypoints/ # 入口点(11 文件) + environment-runner/ # BYOC 环境运行器 + hooks/ # React hooks(104 文件) + ink/ # Ink 终端渲染引擎(98 文件) + jobs/ # 作业系统 + keybindings/ # 快捷键配置(14 文件) + memdir/ # 记忆目录管理 + migrations/ # 数据迁移(11 文件) + moreright/ # 扩展权限 + native-ts/ # 原生 TypeScript 模块(4 文件) + outputStyles/ # 输出样式 + plugins/ # 插件系统(2 文件) + proactive/ # 主动任务 + query/ # 查询管道(4 文件) + remote/ # 远程会话管理(4 文件) + schemas/ # JSON Schema + screens/ # 主界面屏幕(3 文件) + self-hosted-runner/ # 自托管运行器 + server/ # 服务器模式(11 文件) + services/ # 服务层(147 文件) + skills/ # 技能系统(50 文件) + ssh/ # SSH 远程会话 + state/ # 应用状态管理(6 文件) + tasks/ # 后台任务类型(14 文件) + tools/ # Agent 工具实现(210 文件) + types/ # 类型定义(12 文件) + upstreamproxy/ # 上游代理 + utils/ # 工具函数(577 文件) + vendor/ # 第三方内联代码 + vim/ # Vim 编辑模式(5 文件) + voice/ # 语音输入 +``` + +--- + +## 三、核心架构 + +### 3.1 启动流程 + +``` +cli.tsx (快速路径分发) + ├─ --version → 直接输出,零模块加载 + ├─ --dump-system-prompt → 转储系统提示(feature-gated) + ├─ --computer-use-mcp → Chrome/Computer Use 模式 + ├─ --daemon-worker → 后台守护进程 + ├─ remote-control → 远程控制桥接 + └─ 默认交互模式 → main.tsx + ├─ 并行初始化(MDM、Keychain 预取) + ├─ Commander.js CLI 解析 + ├─ 插件/技能加载 + ├─ MCP 服务器初始化 + └─ REPL.tsx (主 UI) +``` + +**入口文件详解:** + +- **../src/entrypoints/cli.tsx** — 主入口,实现零导入快速路径优化 + - 所有导入都是动态的,以最小化模块评估 + - 特殊模式处理:Chrome 集成、daemon、bridge、后台会话、模板任务等 + - 环境特定优化(CCR 容器分配 8GB 堆内存) + +- **../src/main.tsx** — 完整初始化流程 + - 带性能分析检查点的重型初始化 + - 并行启动优化(MDM 原始读取、Keychain 预取) + - Commander.js CLI 设置 + - 会话管理和恢复 + - 插件/技能初始化 + - MCP 服务器管理 + - 分析和遥测设置 + +### 3.2 查询引擎 (`../src/QueryEngine.ts`) + +`QueryEngine` 是整个 LLM 交互的核心: + +- **异步生成器模式** — `submitMessage()` 以 async generator 流式输出 SDK 消息 +- **系统提示组装** — 整合自定义提示、记忆机制、思考配置 +- **权限管理** — `canUseTool()` 包装,追踪拒绝情况 +- **对话循环** — 多轮对话,带 budget/turn 限制 +- **会话持久化** — API 响应前保存会话,防崩溃丢数据 +- **Snip-boundary 回放** — 长会话的内存管理 +- **结构化输出执行** — 带重试限制 +- **错误追踪** — 基于 watermark 的 turn 范围界定 + +**查询管道:** + +``` +User Input → processUserInput() → QueryEngine.submitMessage() + ↓ + System Prompt Assembly + ↓ + Permission Checking + ↓ + LLM API Call (query.ts) + ↓ + Stream Response → SDK Messages +``` + +### 3.3 工具注册 (`../src/tools.ts`) + +`getAllBaseTools()` 注册 80+ 工具,通过以下方式过滤: +- Feature flag 条件编译 +- 权限上下文过滤 +- 模式过滤(SIMPLE/REPL/普通) +- 工具预设配置 + +关键函数: +- `getAllBaseTools()` — 所有工具的真实来源 +- `getTools()` — 按权限上下文和模式过滤 +- `assembleToolPool()` — 组合内置 + MCP 工具,去重 +- 稳定排序以确保 prompt-cache 效率 + +### 3.4 命令注册 (`../src/commands.ts`) + +`getCommands()` 加载 ~150 个命令,来源包括: +- 内置命令 +- Bundled skills +- 插件 skills +- MCP 命令 +- Workflow 命令 + +关键特性: +- 通过 lodash memoization 实现延迟求值 +- 多源命令组合 +- 技能缓存与粒度失效 +- `REMOTE_SAFE_COMMANDS` / `BRIDGE_SAFE_COMMANDS` 安全过滤 +- `meetsAvailabilityRequirement()` 基于认证/提供商的过滤 + +### 3.5 REPL 屏幕 (`../src/screens/REPL.tsx`) + +- 基于 React 的 Ink TUI,支持虚拟滚动 +- 基于消息的状态管理,不可变更新 +- 复杂的权限处理(auto-mode、sandbox、swarm) +- 实时流式 LLM 响应 +- 多模式输入处理(normal、vim、search) +- 成本追踪和预算执行 +- 后台任务管理 +- Swarm/Teammate 多 Agent 协调 + +--- + +## 四、五大 API 提供商支持 + +| 提供商 | 环境变量 | 认证方式 | +|---|---|---| +| **Anthropic(默认)** | — | `ANTHROPIC_API_KEY` 或 OAuth | +| **OpenAI Codex** | `CLAUDE_CODE_USE_OPENAI=1` | OpenAI OAuth | +| **AWS Bedrock** | `CLAUDE_CODE_USE_BEDROCK=1` | AWS credentials | +| **Google Vertex AI** | `CLAUDE_CODE_USE_VERTEX=1` | gcloud ADC | +| **Anthropic Foundry** | `CLAUDE_CODE_USE_FOUNDRY=1` | API Key | + +**API 客户端架构:** + +`../src/services/api/client.ts` 是统一的 API 客户端工厂,根据配置自动路由到不同提供商。 + +**Codex 适配器:** + +`../src/services/api/codex-fetch-adapter.ts` 负责将 Anthropic 消息格式转换为 OpenAI Codex 格式: +- Anthropic `base64` 图像 schema 映射到 Codex `input_image` 载荷 +- `tool_result` 项路由为顶层 `function_call_output` 对象 +- 剥离 Anthropic 专有的 `cache_control` 注解 +- 拦截 Codex `response.reasoning.delta` SSE 帧并包装为 Anthropic `` 事件 +- 绑定 `response.completed` 事件以追踪 token 使用量 + +### 支持的模型 + +**Anthropic (Direct API) — 默认:** + +| Model | ID | +|---|---| +| Claude Opus 4.6 | `claude-opus-4-6` | +| Claude Sonnet 4.6 | `claude-sonnet-4-6` | +| Claude Haiku 4.5 | `claude-haiku-4-5` | + +**OpenAI Codex:** + +| Model | ID | +|---|---| +| GPT-5.3 Codex (recommended) | `gpt-5.3-codex` | +| GPT-5.4 | `gpt-5.4` | +| GPT-5.4 Mini | `gpt-5.4-mini` | + +### 环境变量参考 + +| Variable | Purpose | +|---|---| +| `ANTHROPIC_API_KEY` | Anthropic API key | +| `ANTHROPIC_AUTH_TOKEN` | Auth token (alternative) | +| `ANTHROPIC_MODEL` | Override default model | +| `ANTHROPIC_BASE_URL` | Custom API endpoint | +| `ANTHROPIC_DEFAULT_OPUS_MODEL` | Custom Opus model ID | +| `ANTHROPIC_DEFAULT_SONNET_MODEL` | Custom Sonnet model ID | +| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | Custom Haiku model ID | +| `CLAUDE_CODE_OAUTH_TOKEN` | OAuth token via env | +| `CLAUDE_CODE_API_KEY_HELPER_TTL_MS` | API key helper cache TTL | +| `AWS_REGION` / `AWS_DEFAULT_REGION` | AWS region for Bedrock | +| `ANTHROPIC_BEDROCK_BASE_URL` | Custom Bedrock endpoint | +| `ANTHROPIC_FOUNDRY_API_KEY` | Foundry API key | + +--- + +## 五、Feature Flag 系统 + +### 构建系统 (`scripts/build.ts`) + +- 使用 `bun:bundle` 的 `feature()` 宏实现编译时死代码消除 +- Bun 编译带字节码生成 +- 宏系统提供构建时常量(VERSION、BUILD_TIME 等) + +### 构建变体 + +| 命令 | 输出 | Feature Flags | 描述 | +|---|---|---|---| +| `bun run build` | `./cli` | 仅 VOICE_MODE | 生产构建 | +| `bun run build:dev` | `./cli-dev` | 仅 VOICE_MODE | 开发版 | +| `bun run build:dev:full` | `./cli-dev` | 全部 54 个实验性 flag | 完整解锁构建 | +| `bun run compile` | `./dist/cli` | 仅 VOICE_MODE | 替代输出路径 | +| 自定义 | 自定义 | 指定 flags | `bun run ./scripts/build.ts --feature=ULTRAPLAN --feature=ULTRATHINK` | + +### 54 个可工作 Feature Flags + +#### 交互与 UI(14 个) + +| Flag | 描述 | +|---|---| +| `AWAY_SUMMARY` | 离开键盘摘要行为 | +| `HISTORY_PICKER` | 交互式提示历史选择器 | +| `HOOK_PROMPTS` | 将 prompt/request 文本传入 hook 执行流程 | +| `KAIROS_BRIEF` | 简短转录布局和 BriefTool UX | +| `KAIROS_CHANNELS` | 频道通知和回调 | +| `LODESTONE` | 深度链接/协议注册相关流程 | +| `MESSAGE_ACTIONS` | 消息操作入口点 | +| `NEW_INIT` | 新版 `/init` 决策路径 | +| `QUICK_SEARCH` | 提示快速搜索 | +| `SHOT_STATS` | 额外的 shot-distribution 统计视图 | +| `TOKEN_BUDGET` | Token 预算追踪和警告 UI | +| `ULTRAPLAN` | 远程多 Agent 规划(Opus 级别) | +| `ULTRATHINK` | 深度思考模式 | +| `VOICE_MODE` | 语音切换、听写、语音通知和 UI | + +#### Agent/记忆/规划(10 个) + +| Flag | 描述 | +|---|---| +| `AGENT_MEMORY_SNAPSHOT` | 存储 Agent 记忆快照状态 | +| `AGENT_TRIGGERS` | 本地 cron/触发器工具 | +| `AGENT_TRIGGERS_REMOTE` | 远程触发器工具路径 | +| `BUILTIN_EXPLORE_PLAN_AGENTS` | 内置 explore/plan agent 预设 | +| `CACHED_MICROCOMPACT` | 通过查询和 API 流程缓存的微压缩状态 | +| `COMPACTION_REMINDERS` | 压缩和附件流程的提醒 | +| `EXTRACT_MEMORIES` | 查询后记忆提取 hooks | +| `PROMPT_CACHE_BREAK_DETECTION` | 压缩/查询流程中的缓存中断检测 | +| `TEAMMEM` | 团队记忆文件和 watcher hooks | +| `VERIFICATION_AGENT` | 验证 Agent 指导 | + +#### 工具/权限/远程(13 个) + +| Flag | 描述 | +|---|---| +| `BASH_CLASSIFIER` | 分类器辅助的 bash 权限决策 | +| `BRIDGE_MODE` | 远程控制 / REPL 桥接命令 | +| `CCR_AUTO_CONNECT` | CCR 自动连接默认路径 | +| `CCR_MIRROR` | 仅出站 CCR 镜像会话 | +| `CCR_REMOTE_SETUP` | 远程设置命令路径 | +| `CHICAGO_MCP` | Computer-use MCP 集成 | +| `CONNECTOR_TEXT` | 连接器文本块处理 | +| `MCP_RICH_OUTPUT` | 更丰富的 MCP UI 渲染 | +| `NATIVE_CLIPBOARD_IMAGE` | 原生 macOS 剪贴板图像快速路径 | +| `POWERSHELL_AUTO_MODE` | PowerShell 自动模式权限处理 | +| `TREE_SITTER_BASH` | tree-sitter bash 解析器后端 | +| `TREE_SITTER_BASH_SHADOW` | tree-sitter bash 影子发布路径 | +| `UNATTENDED_RETRY` | API 重试流程中的无人值守重试 | + +#### 支撑性 Flag(17 个) + +| Flag | 描述 | +|---|---| +| `ABLATION_BASELINE` | CLI 消融/基线入口开关 | +| `ALLOW_TEST_VERSIONS` | 允许原生安装程序中的测试版本 | +| `ANTI_DISTILLATION_CC` | 添加反蒸馏请求元数据 | +| `BREAK_CACHE_COMMAND` | 注入 break-cache 命令路径 | +| `COWORKER_TYPE_TELEMETRY` | 添加协作者类型遥测字段 | +| `DOWNLOAD_USER_SETTINGS` | 启用设置同步拉取路径 | +| `DUMP_SYSTEM_PROMPT` | 启用系统提示转储路径 | +| `FILE_PERSISTENCE` | 启用文件持久化管道 | +| `HARD_FAIL` | 启用更严格的失败/日志行为 | +| `IS_LIBC_GLIBC` | 强制 glibc 环境检测 | +| `IS_LIBC_MUSL` | 强制 musl 环境检测 | +| `NATIVE_CLIENT_ATTESTATION` | 添加原生证明标记文本 | +| `PERFETTO_TRACING` | 启用 perfetto 追踪 hooks | +| `SKILL_IMPROVEMENT` | 启用技能改进 hooks | +| `SKIP_DETECTION_WHEN_AUTOUPDATES_DISABLED` | 自动更新禁用时跳过更新检测 | +| `SLOW_OPERATION_LOGGING` | 启用慢操作日志 | +| `UPLOAD_USER_SETTINGS` | 启用设置同步推送路径 | + +### 运行时注意事项 + +部分 flag 虽然编译通过但有运行时限制: + +- `VOICE_MODE` — 需要 claude.ai OAuth 和本地录制后端 +- `NATIVE_CLIPBOARD_IMAGE` — 仅在 macOS 且存在 `image-processor-napi` 时加速 +- `BRIDGE_MODE`、`CCR_*` — 运行时需要 claude.ai OAuth + GrowthBook 权限检查 +- `KAIROS_BRIEF`、`KAIROS_CHANNELS` — 仅暴露已存在的 brief/channel 特定接口 +- `CHICAGO_MCP` — 运行时仍需 `@ant/computer-use-*` 外部包 + +### 34 个不可编译 Flag + +#### 易修复(15 个)— 缺少单个文件或资产 + +| Flag | 缺失内容 | +|---|---| +| `AUTO_THEME` | `../src/utils/systemThemeWatcher.js`(OSC 11 watcher) | +| `BG_SESSIONS` | `../src/cli/bg.js` | +| `BUDDY` | `../src/commands/buddy/index.js` | +| `BUILDING_CLAUDE_APPS` | `../src/claude-api/csharp/claude-api.md`(文档资产) | +| `COMMIT_ATTRIBUTION` | `../src/utils/attributionHooks.js` | +| `FORK_SUBAGENT` | `../src/commands/fork/index.js` | +| `HISTORY_SNIP` | `../src/commands/force-snip.js` | +| `KAIROS_GITHUB_WEBHOOKS` | `../src/tools/SubscribePRTool/SubscribePRTool.js` | +| `KAIROS_PUSH_NOTIFICATION` | `../src/tools/PushNotificationTool/PushNotificationTool.js` | +| `MCP_SKILLS` | `../src/skills/mcpSkills.js` | +| `MEMORY_SHAPE_TELEMETRY` | `../src/memdir/memoryShapeTelemetry.js` | +| `OVERFLOW_TEST_TOOL` | `../src/tools/OverflowTestTool/OverflowTestTool.js` | +| `RUN_SKILL_GENERATOR` | `../src/runSkillGenerator.js` | +| `TEMPLATES` | `../src/cli/handlers/templateJobs.js` | +| `TORCH` | `../src/commands/torch.js` | +| `TRANSCRIPT_CLASSIFIER` | `../src/utils/permissions/yolo-classifier-prompts/auto_mode_system_prompt.txt` | + +#### 中等修复(16 个)— 缺少较大子系统部分 + +| Flag | 缺失内容 | +|---|---| +| `BYOC_ENVIRONMENT_RUNNER` | `../src/environment-runner/main.js` | +| `CONTEXT_COLLAPSE` | `../src/tools/CtxInspectTool/CtxInspectTool.js` | +| `COORDINATOR_MODE` | `../src/coordinator/workerAgent.js` | +| `DAEMON` | `../src/daemon/workerRegistry.js` | +| `DIRECT_CONNECT` | `../src/server/parseConnectUrl.js` | +| `EXPERIMENTAL_SKILL_SEARCH` | `../src/services/skillSearch/localSearch.js` | +| `MONITOR_TOOL` | `../src/tools/MonitorTool/MonitorTool.js` | +| `REACTIVE_COMPACT` | `../src/services/compact/reactiveCompact.js` | +| `REVIEW_ARTIFACT` | `../src/hunter.js` | +| `SELF_HOSTED_RUNNER` | `../src/self-hosted-runner/main.js` | +| `SSH_REMOTE` | `../src/ssh/createSSHSession.js` | +| `TERMINAL_PANEL` | `../src/tools/TerminalCaptureTool/TerminalCaptureTool.js` | +| `UDS_INBOX` | `../src/utils/udsMessaging.js` | +| `WEB_BROWSER_TOOL` | `../src/tools/WebBrowserTool/WebBrowserTool.js` | +| `WORKFLOW_SCRIPTS` | `../src/commands/workflows/index.js` 及多个相关文件 | + +#### 大型缺失(3 个)— 需要重建完整子系统 + +| Flag | 缺失内容 | +|---|---| +| `KAIROS` | `../src/assistant/index.js` 及大部分助手栈 | +| `KAIROS_DREAM` | `../src/dream.js` 及 dream-task 行为 | +| `PROACTIVE` | `../src/proactive/index.js` 及主动任务/工具栈 | + +--- + +## 六、核心子系统详解 + +### 6.1 状态管理 (`../src/state/`) + +自定义 Store 模式(非 Redux/Zustand): + +**核心文件:** + +- **store.ts** — `getState()/setState()/subscribe()` 基础实现 + - 可选 `onChange` 回调用于状态变更追踪 + - Listener set 用于 React 风格订阅 + +- **AppStateStore.ts** — 450+ 行的类型定义,涵盖: + - 设置和配置 + - 任务状态管理(tasks、agentNameRegistry) + - MCP 状态(clients、tools、commands、resources) + - 插件状态(enabled、disabled、errors、installation status) + - 远程桥接状态(connection、session、polling) + - UI 状态(expandedView、footerSelection、spinnerTip) + - 权限上下文和模式 + - 文件历史和归属追踪 + - Tungsten (tmux) 集成状态 + - Computer Use (chicago MCP) 状态 + - Team/swarm 上下文和收件箱管理 + +- **AppState.tsx** — React 集成: + - `AppStateProvider` 上下文组件 + - `useAppState(selector)` — 使用 `useSyncExternalStore` 的优化 hook + - `useSetAppState()` — 非订阅 setter + - `useAppStateStore()` — 直接 store 访问 + - `useAppStateMaybeOutsideOfProvider()` — 可选上下文的安全版本 + +- **selectors.ts** — 纯选择器函数: + - `getViewedTeammateTask()` — 获取当前查看的 teammate + - `getActiveAgentForInput()` — 确定输入路由(leader/viewed/named) + +- **onChangeAppState.ts** — 状态变更副作用处理器: + - 权限模式同步到 CCR/SDK + - 模型设置持久化 + - 展开视图持久化 + - 设置变更时清除认证缓存 + +### 6.2 服务层 (`../src/services/`) + +28 个主要服务区域: + +#### API 客户端 (`../src/services/api/`) + +- **client.ts** — 统一 API 客户端工厂,支持五大提供商 +- **codex-fetch-adapter.ts** — Anthropic → OpenAI Codex 格式转换 +- OAuth token 刷新、自定义 headers、CCH 签名、代理支持 +- 错误处理、重试逻辑、使用量追踪 + +#### OAuth (`../src/services/oauth/`) + +- **index.ts** — OAuthService 类,实现 OAuth 2.0 Authorization Code Flow + PKCE +- **client.ts** — OAuth 客户端操作(构建 auth URL、交换 code 为 token、获取 profile) +- **auth-code-listener.ts** — 本地 HTTP 服务器用于自动 OAuth 回调处理 +- **crypto.ts** — PKCE code verifier/challenge 生成 +- **codex-client.ts** — Codex 特定 OAuth 客户端 + +#### MCP 集成 (`../src/services/mcp/`) + +- **client.ts** — MCP 客户端管理 +- **types.ts** — MCP 服务器配置的全面类型定义(stdio、SSE、HTTP、WebSocket、SDK、IDE 特定) +- **config.ts** — MCP 配置管理 +- **useManageMCPConnections.ts** — MCP 连接生命周期的 React hook +- **channelPermissions.ts** — MCP 频道权限处理(Telegram、iMessage 等) +- 支持 XAA(Cross-App Access)、elicitation 处理、资源管理 + +#### 分析 (`../src/services/analytics/`) — 已 STUB + +- 所有函数都是空操作(logEvent、attachAnalyticsSink 等) +- 作为兼容性边界,使现有调用点保持不变而遥测被禁用 +- 包括:datadog.ts、firstPartyEventLogger.ts、growthbook.ts、sink.ts + +#### 其他主要服务 + +| 服务 | 职责 | 状态 | +|---|---|---| +| `compact/` | 上下文压缩(auto-compact、micro-compact、reactive compact) | 完整 | +| `tools/` | 工具执行编排(StreamingToolExecutor、toolOrchestration) | 完整 | +| `plugins/` | 插件操作和安装管理 | 完整 | +| `SessionMemory/` | 会话记忆持久化和检索 | 完整 | +| `skillSearch/` | 技能搜索,带远程状态管理 | 完整 | +| `remoteManagedSettings/` | 远程托管设置同步 | 完整 | +| `extractMemories/` | 从对话上下文中提取记忆 | 完整 | +| `lsp/` | Language Server Protocol 集成 | 完整 | +| `contextCollapse/` | 上下文窗口优化 | 完整 | +| `tips/` | 用户提示和指导系统 | 完整 | +| `voice.ts` | 语音集成(STT、keyterms、streaming) | 完整 | + +### 6.3 IDE 桥接 (`../src/bridge/`) + +远程控制架构: + +1. 注册环境到后端(机器名、目录、分支、仓库 URL) +2. 轮询获取 work items(要执行的会话) +3. 为每个会话创建子进程 +4. 管理会话生命周期(心跳、停止、归档) +5. 权限响应回传 +6. 支持多种生成模式(单会话、worktree、同目录) + +**核心文件:** + +- **bridgeApi.ts** — Bridge API 客户端(环境注册/注销、工作轮询/确认、会话生命周期管理) +- **types.ts** — 全面的桥接类型定义(260+ 行) +- **bridgeConfig.ts** — 桥接认证/URL 解析 +- **remoteBridgeCore.ts** — 远程桥接核心逻辑 +- **replBridgeHandle.ts** — REPL 桥接句柄实现 +- **replBridgeTransport.ts** — 桥接传输层 +- **createSession.ts** — 会话创建逻辑 +- **sessionRunner.ts** — 会话执行运行器 +- **jwtUtils.ts** — JWT token 工具 +- **peerSessions.ts** — 对等会话管理 +- **trustedDevice.ts** — 受信任设备认证 + +### 6.4 任务系统 (`../src/tasks/`) + +7 种任务类型: + +| 任务类型 | 用途 | +|---|---| +| `LocalShellTask` | 后台 Bash 命令执行 | +| `LocalAgentTask` | 本地 Agent 子进程(自主工作) | +| `RemoteAgentTask` | 远程 CCR Agent(通过 CCR API 执行) | +| `InProcessTeammateTask` | 进程内 Teammate(共享主进程) | +| `LocalWorkflowTask` | 预定义工作流脚本 | +| `MonitorMcpTask` | MCP 资源变更监控 | +| `DreamTask` | 异步/Dream 任务 | + +**任务管理特性:** +- 任务状态:pending → in_progress → completed(或 deleted) +- 后台任务:带 `isBackgrounded` 标志 +- 任务依赖:`blocks` 和 `blockedBy` 关系 +- 任务所有权:可分配给特定 Agent +- 团队任务列表:团队共享任务列表以协调工作 + +### 6.5 工具系统 (`../src/tools/`) + +40+ 工具按功能分类: + +#### 文件操作 + +| 工具 | 功能 | +|---|---| +| FileReadTool | 读取文件(支持图像、PDF、Jupyter notebook) | +| FileWriteTool | 写入文件(需先读取现有文件) | +| FileEditTool | 精确字符串替换(支持 replace_all) | +| GlobTool | 快速文件模式匹配 | +| GrepTool | 基于 ripgrep 的内容搜索 | +| NotebookEditTool | Jupyter notebook 单元格编辑 | +| SnipTool | 代码片段管理 | +| SendUserFileTool | 向用户发送文件 | + +#### Shell 和系统 + +| 工具 | 功能 | +|---|---| +| BashTool | 执行 bash 命令(支持后台执行、超时、沙盒) | +| PowerShellTool | 执行 PowerShell 命令(Windows) | +| SleepTool | 等待指定时间 | +| TerminalCaptureTool | 终端捕获 | + +#### 通信和规划 + +| 工具 | 功能 | +|---|---| +| AskUserQuestionTool | 向用户提问(支持多选、预览) | +| SendMessageTool | 向其他 Agent 发送消息 | +| EnterPlanModeTool | 进入规划模式 | +| ExitPlanModeTool | 退出规划模式 | +| BriefTool | 向用户发送消息(主通信通道) | + +#### Web 和网络 + +| 工具 | 功能 | +|---|---| +| WebFetchTool | 获取 URL 内容(HTML → markdown,15 分钟缓存) | +| WebSearchTool | Web 搜索 | +| WebBrowserTool | Web 浏览器面板 | +| RemoteTriggerTool | 管理远程 Agent 触发器 | + +#### MCP + +| 工具 | 功能 | +|---|---| +| MCPTool | 通用 MCP 工具包装器 | +| ListMcpResourcesTool | 列出 MCP 服务器资源 | +| ReadMcpResourceTool | 读取 MCP 服务器资源 | +| McpAuthTool | MCP 认证处理 | + +#### Agent 和团队 + +| 工具 | 功能 | +|---|---| +| AgentTool | 启动专门的 Agent 子进程 | +| TeamCreateTool | 创建 Agent 团队 | +| TeamDeleteTool | 删除 Agent 团队 | + +#### 任务管理 + +| 工具 | 功能 | +|---|---| +| TaskCreateTool | 创建任务列表 | +| TaskUpdateTool | 更新任务 | +| TaskGetTool | 获取任务详情 | +| TaskListTool | 列出所有任务 | +| TaskStopTool | 停止后台任务 | +| TaskOutputTool | 查看后台任务输出 | + +#### Git + +| 工具 | 功能 | +|---|---| +| EnterWorktreeTool | 创建隔离的 git worktree | +| ExitWorktreeTool | 退出 worktree | + +#### 调度 + +| 工具 | 功能 | +|---|---| +| CronCreateTool | 创建定时任务 | +| CronDeleteTool | 删除定时任务 | +| CronListTool | 列出定时任务 | + +#### 配置和发现 + +| 工具 | 功能 | +|---|---| +| ConfigTool | 获取/设置配置 | +| ToolSearchTool | 获取延迟加载工具的 schema | +| DiscoverSkillsTool | 发现可用技能 | +| LSPTool | LSP 服务器交互(goToDefinition、findReferences 等) | + +#### 验证 + +| 工具 | 功能 | +|---|---| +| VerifyPlanExecutionTool | 验证计划执行 | + +**工具延迟加载:** +- 工具可被延迟加载(不包含在初始 prompt 中)以减少上下文 +- 延迟加载的工具需要 ToolSearch 来获取 schema +- MCP 工具始终延迟加载 +- 部分工具永不延迟(Agent、Brief when KAIROS enabled) + +### 6.6 命令系统 (`../src/commands/`) + +70+ 斜杠命令: + +#### 配置和设置 + +| 命令 | 功能 | +|---|---| +| `/config` (别名: `/settings`) | 打开配置面板 | +| `/keybindings` | 打开/创建快捷键配置文件 | +| `/add-dir` | 添加新的工作目录 | +| `/memory` | 编辑记忆文件 | +| `/sandbox` | 切换沙盒模式 | +| `/vim` | 切换 Vim/普通编辑模式 | +| `/terminal-setup` | 安装 Shift+Enter 换行绑定 | +| `/color` | 设置提示栏颜色 | +| `/effort` | 设置模型努力级别 | +| `/model` | 更改模型设置 | +| `/fast` | 切换快速模式 | +| `/theme` | 更改主题 | + +#### 任务和会话管理 + +| 命令 | 功能 | +|---|---| +| `/tasks` (别名: `/bashes`) | 列出和管理后台任务 | +| `/plan` | 启用规划模式或查看当前计划 | +| `/branch` (别名: `/fork`) | 在当前点创建对话分支 | +| `/resume` (别名: `/continue`) | 恢复之前的对话 | +| `/session` | 会话管理 | +| `/compact` | 清除对话历史但保留摘要 | +| `/exit` | 退出 CLI | +| `/btw` | 快速旁问(不打断主对话) | + +#### 信息和诊断 + +| 命令 | 功能 | +|---|---| +| `/help` | 显示帮助信息 | +| `/context` | 可视化当前上下文使用情况 | +| `/cost` | 显示当前会话的总成本和时长 | +| `/usage` | 显示使用信息 | +| `/stats` | 显示统计信息 | +| `/doctor` | 诊断和验证安装 | +| `/status` | 显示状态信息 | +| `/statusline` | 状态栏管理 | +| `/release-notes` | 显示发行说明 | + +#### 功能和集成 + +| 命令 | 功能 | +|---|---| +| `/ide` | 管理 IDE 集成 | +| `/agents` | 管理 Agent 配置 | +| `/skills` | 技能管理 | +| `/plugin` | 插件管理 | +| `/mcp` | MCP 管理 | +| `/voice` | 语音模式 | +| `/remote-control` (别名: `/rc`) | 远程控制会话 | +| `/chrome` | Chrome 集成 | + +#### Git 和版本控制 + +| 命令 | 功能 | +|---|---| +| `/commit` | 创建 git 提交(技能) | +| `/commit-push-pr` | 提交、推送并创建 PR | +| `/diff` | 显示差异 | +| `/tag` | 标签管理 | + +#### 账户和认证 + +| 命令 | 功能 | +|---|---| +| `/login` | 登录账户 | +| `/logout` | 登出账户 | +| `/upgrade` | 升级到 Max | +| `/permissions` | 管理权限 | +| `/hooks` | Hooks 管理 | +| `/init` | 初始化 Claude Code | + +### 6.7 技能系统 (`../src/skills/`) + +13+ 个内置技能: + +| 技能 | 功能 | +|---|---| +| `/update-config` | 通过 settings.json 配置 hooks 和自动化行为 | +| `/simplify` | 审查代码质量(启动 3 个并行 Agent) | +| `/loop` | 定时循环执行命令 | +| `/verify` | 验证代码变更 | +| `/remember` | 审查自动记忆并提议提升到 CLAUDE.md | +| `/debug` | 调试技能 | +| `/keybindings` | 快捷键管理 | +| `/batch` | 批量操作 | +| `/stuck` | 获取解困帮助 | +| `/skillify` | 创建新技能 | +| `/claudeApi` | Claude API 文档和示例 | +| `/dream` | Dream 技能 | +| `/hunter` | 代码审查工件 | +| `/runSkillGenerator` | 运行技能生成器 | + +**技能系统架构:** + +- **注册** — `registerBundledSkill()` 在启动时注册技能 +- **定义** — 技能包含 name、description、aliases、whenToUse、allowedTools、model、hooks +- **引用文件** — 首次调用时提取到磁盘 +- **发现** — 技能出现在 Skill 工具提示中(bundled 技能永不截断) +- **调用** — 通过 Skill 工具或 `/skill-name` 斜杠命令 + +### 6.8 插件系统 (`../src/plugins/`) + +- 内置插件格式:`{name}@builtin` +- 区别于市场插件:`{name}@{marketplace}` +- 可提供 skills、hooks、MCP servers +- 通过 `/plugin` UI 管理 +- 用户设置控制启用状态(默认:`defaultEnabled ?? true`) + +--- + +## 七、React Hooks (`../src/hooks/`) + +104 个 React hooks 按类别: + +### 数据获取和状态 + +- `useTasksV2`、`useTaskListWatcher` — 任务管理 +- `useSettings`、`useSettingsChange` — 设置同步 +- `useApiKeyVerification` — API key 验证 +- `useInboxPoller` — 消息收件箱轮询 +- `useDiffData` — Diff 数据计算 + +### UI/交互 + +- `useTypeahead` — 输入自动完成 +- `useSearchInput` — 搜索功能 +- `useArrowKeyHistory` — 命令历史导航 +- `useVirtualScroll` — 虚拟滚动 +- `usePasteHandler` — 剪贴板粘贴处理 +- `useBlink` — 光标闪烁效果 + +### IDE 集成 + +- `useIdeLogging` — IDE 日志 +- `useDiffInIDE` — IDE diff 查看 +- `useIdeAtMentioned` — IDE @mention 处理 +- `useLspPluginRecommendation` — LSP 插件建议 + +### 功能特定 + +- `useVoice`、`useVoiceIntegration`、`useVoiceEnabled` — 语音功能 +- `useDirectConnect` — 直接连接模式 +- `useSSHSession` — SSH 会话管理 +- `useBackgroundTaskNavigation` — 后台任务路由 +- `useSwarmInitialization`、`useSwarmPermissionPoller` — Agent swarm 管理 + +### 工具 + +- `useTerminalSize` — 终端尺寸 +- `useMemoryUsage` — 内存监控 +- `useScheduledTasks` — 任务调度 +- `useDynamicConfig` — 动态配置加载 + +--- + +## 八、UI 组件 (`../src/components/`) + +27 个主要组件目录: + +### 核心 UI + +- **ui/** — 基础 UI 组件(按钮、输入框等) +- **design-system/** — 设计系统组件 + +### 功能组件 + +- **tasks/** — 任务列表和管理 UI +- **messages/** — 消息显示和渲染 +- **permissions/** — 权限请求对话框 +- **mcp/** — MCP 服务器管理 UI +- **diff/** — 代码差异查看 +- **shell/** — Shell/输出显示 +- **memory/** — 记忆管理 UI +- **sandbox/** — 沙盒权限 +- **agents/** — Agent 创建和管理 + +### 设置和配置 + +- **Settings/** — 设置面板 +- **ManagedSettingsSecurityDialog/** — 托管设置安全对话框 + +### 集成 + +- **groove/** — Grove 集成 +- **teams/** — 团队协作 +- **skills/** — 技能管理 +- **wizard/** — 引导向导 +- **LspRecommendation/** — LSP 插件建议 + +### 专用 + +- **TrustDialog/** — 信任/安全对话框 +- **FeedbackSurvey/** — 用户反馈 +- **Spinner/** — 加载旋转器 +- **PromptInput/** — 输入提示组件 +- **HelpV2/** — 帮助系统 +- **LogoV2/** — Logo 组件 + +--- + +## 九、构建与依赖 + +### 运行时依赖(关键) + +| 类别 | 包 | +|---|---| +| **AI SDK** | @anthropic-ai/sdk, @anthropic-ai/bedrock-sdk, @anthropic-ai/vertex-sdk, @anthropic-ai/foundry-sdk | +| **Agent SDK** | @anthropic-ai/claude-agent-sdk | +| **MCP** | @modelcontextprotocol/sdk, @anthropic-ai/mcpb | +| **终端 UI** | react, ink, chalk, cli-highlight | +| **搜索** | fuse.js, picomatch | +| **协议** | vscode-jsonrpc, vscode-languageserver-protocol | +| **可观测性** | @opentelemetry/* (完整 suite,但遥测已 stub) | +| **验证** | zod, ajv | +| **图片** | sharp | +| **网络** | axios, undici, ws, https-proxy-agent | +| **CLI** | @commander-js/extra-typings | +| **其他** | diff, marked, yaml, lodash-es, lru-cache, semver, chokidar | + +### 开发依赖 + +- `@types/bun` — Bun 类型定义 +- `typescript ^6.0.2` — TypeScript 编译器 + +--- + +## 十、Git 状态 + +当前仓库只有 **1 个 commit**: + +``` +a4deee0 因无法 fork,手动迁移代码 source: https://github.com/paoloanzn/free-code +``` + +**76 个未跟踪文件/目录**,包括许多新增的子系统文件(assistant、buddy、fork、workflows、daemon、ssh 等),对应于之前不可编译但已被手动补充代码的 feature flags。 + +**3 个已修改文件**:`bun.lock`、`package.json`、`scripts/build.ts`。 + +--- + +## 十一、架构亮点与设计模式 + +1. **编译时 Feature Flag** — `bun:bundle` 的 `feature()` 宏实现真正的死代码消除,非运行时 if/else +2. **异步生成器查询管线** — QueryEngine 用 async generator 实现流式 LLM 响应 +3. **多 Agent 协作** — Agent Swarm 支持 Team 创建、共享任务列表、跨会话消息传递 +4. **工具延迟加载** — 大型工具集通过 ToolSearch 按需加载 schema,优化 context window +5. **多提供商 API 抽象** — 统一接口适配 5 个 API 提供商 +6. **自定义状态管理** — 轻量 store + useSyncExternalStore,非 Redux +7. **IDE 桥接轮询架构** — 远程控制通过环境注册 + work polling 实现 +8. **上下文压缩** — 多级压缩策略(auto-compact、micro-compact、reactive-compact) +9. **MCP 协议完整实现** — 支持 stdio、SSE、HTTP、WebSocket、SDK 等多种传输方式 +10. **沙盒安全** — 文件/网络访问控制,支持 macOS/Linux 平台 + +--- + +## 十二、工具函数层 (`../src/utils/`) + +最大的源码目录(577 文件,178,924 行): + +### 模型管理 (`../src/utils/model/`) + +- **model.ts** — 核心模型选择逻辑、别名、规范化 +- **modelStrings.ts** — 模型名称常量和映射 +- **providers.ts** — API 提供商检测 +- **modelCapabilities.ts** — 模型能力标志 +- **modelAllowlist.ts** — 模型访问控制 +- **validateModel.ts** — 模型验证 +- **modelCost.ts** — 模型定价信息 +- **check1mAccess.ts** — 1M 上下文访问检查 + +### 设置管理 (`../src/utils/settings/`) + +- **settings.ts** — 设置加载和持久化 +- **validation.ts** — 设置验证 +- **types.ts** — 设置类型定义 +- **applySettingsChange.ts** — 设置变更应用 +- **permissionValidation.ts** — 权限验证 +- **mdm/** — 移动设备管理集成 + +### 其他重要工具模块 + +- **auth.ts** — 认证和凭证管理(67KB,1800+ 行) +- **attachments.ts** — 附件处理(127KB) +- **analyzeContext.ts** — 上下文分析(43KB) +- **ansiToPng.ts** — ANSI 转 PNG(215KB) +- **bash/** — Bash 命令执行 +- **permissions/** — 权限系统 +- **config.ts** — 配置管理 +- **todo/** — TODO 列表管理 +- **commitAttribution.ts** — Git 提交归属 + +--- + +*报告生成日期:2026-04-05* diff --git a/docs/基础设施设计/reference/原始代码映射-基础设施.md b/docs/基础设施设计/reference/原始代码映射-基础设施.md new file mode 100644 index 0000000..1b94dc3 --- /dev/null +++ b/docs/基础设施设计/reference/原始代码映射-基础设施.md @@ -0,0 +1,43 @@ +# 基础设施设计 — 原始代码映射 + +## 文档元数据 +- 项目名称: free-code +- 文档类型: 原始代码映射 +- 原始代码来源: `../../../src/` 下相关基础设施子目录 +- 交叉引用: [基础设施设计总览](../基础设施设计.md) + +## .NET 类型 → 原始 TypeScript 源文件 + +| .NET 类型 | 原始 TypeScript 源文件 | +|---|---| +| IMcpClientManager | `../../../src/services/mcp/` | +| MCPServerConnection | `../../../src/services/mcp/` | +| ScopedMcpServerConfig | `../../../src/services/mcp/` | +| StdioTransport | `../../../src/services/mcp/` | +| SseTransport | `../../../src/services/mcp/` | +| StreamableHttpTransport | `../../../src/services/mcp/` | +| InProcessTransport | `../../../src/services/mcp/` | +| McpClient | `../../../src/services/mcp/` | +| McpClientManager | `../../../src/services/mcp/` | +| McpAuthService | `../../../src/services/mcp/` | +| ILspClientManager | `../../../src/services/lsp/` | +| LspServerInstance | `../../../src/services/lsp/` | +| LspClientManager | `../../../src/services/lsp/` | +| LspDiagnosticRegistry | `../../../src/services/lsp/` | +| IBridgeService | `../../../src/bridge/` | +| BridgeConfig | `../../../src/bridge/` | +| SpawnMode | `../../../src/bridge/` | +| BridgeService | `../../../src/bridge/` | +| SessionSpawner | `../../../src/bridge/` | +| IBridgeApiClient | `../../../src/bridge/` | +| BackgroundTask | `../../../src/tasks/` | +| IBackgroundTaskManager | `../../../src/tasks/` | +| BackgroundTaskManager | `../../../src/tasks/` | +| AppState | `../../../src/state/` | +| McpState | `../../../src/state/` | +| PluginState | `../../../src/state/` | +| NotificationState | `../../../src/state/` | +| SpeculationState | `../../../src/state/` | +| IAppStateStore | `../../../src/state/` | +| AppStateStore | `../../../src/state/` | +| StateSelectors | `../../../src/state/` | diff --git a/docs/基础设施设计/基础设施设计-IDE桥接.md b/docs/基础设施设计/基础设施设计-IDE桥接.md new file mode 100644 index 0000000..9ad23f8 --- /dev/null +++ b/docs/基础设施设计/基础设施设计-IDE桥接.md @@ -0,0 +1,203 @@ +# 基础设施设计 — IDE 桥接 + +## 文档元数据 +- 项目名称: free-code +- 文档类型: 基础设施设计 +- 原始代码来源: `../../src/bridge/`(32个文件) +- 原始设计意图: 将 claude.ai 远程控制、会话启动、工作轮询和 IDE 侧事件流统一到一个可恢复的桥接服务中 +- 交叉引用: [基础设施设计总览](基础设施设计.md) | [核心模块设计-查询引擎](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md) + +## 设计目标 +IDE 桥接层负责把编辑器、运行时与后台工作流连接起来,提供会话启动、消息转发、任务轮询和工作区隔离能力。它既要支撑单会话模型,也要允许 worktree 隔离与环境注册/注销。 + +## 12.1 IBridgeService 接口 + +```csharp +/// +/// IDE 桥接服务 — 实现 claude.ai 远程控制功能 +/// 对应原始 bridgeMain.ts / replBridge.ts +/// +public interface IBridgeService +{ + /// 注册环境到 claude.ai + Task RegisterEnvironmentAsync(); + + /// 轮询获取工作项 + Task PollForWorkAsync(CancellationToken ct); + + /// 启动会话 + Task SpawnSessionAsync(SessionSpawnOptions options); + + /// 确认工作项 + Task AcknowledgeWorkAsync(string workId, string sessionToken); + + /// 发送权限响应 + Task SendPermissionResponseAsync(string sessionId, PermissionResponse response); + + /// 心跳(延长工作租约) + Task HeartbeatAsync(string workId, string sessionToken); + + /// 停止工作 + Task StopWorkAsync(string workId); + + /// 注销环境 + Task DeregisterEnvironmentAsync(); + + /// 桥接状态 + BridgeStatus Status { get; } +} +``` + +## 12.2 BridgeConfig 与 SpawnMode + +```csharp +/// +/// 桥接配置 — 对应原始 BridgeConfig +/// +public record BridgeConfig +{ + public required string Directory { get; init; } + public required string MachineName { get; init; } + public required string Branch { get; init; } + public string? GitRepoUrl { get; init; } + public int MaxSessions { get; init; } = 1; + public SpawnMode SpawnMode { get; init; } = SpawnMode.Worktree; + public bool Verbose { get; init; } + public bool Sandbox { get; init; } + public required Guid BridgeId { get; init; } + public required string WorkerType { get; init; } // "claude_code" | "claude_code_assistant" + public required Guid EnvironmentId { get; init; } + public string? ReuseEnvironmentId { get; init; } + public required string ApiBaseUrl { get; init; } + public required string SessionIngressUrl { get; init; } + public int SessionTimeoutMs { get; init; } = 24 * 60 * 60 * 1000; // 24h +} + +/// 会话生成模式 +public enum SpawnMode +{ + SingleSession, // 单次会话, 结束即销毁 + Worktree, // 持久, 每会话隔离 git worktree + SameDir // 持久, 共享工作目录 +} +``` + +## 12.3 BridgeService 实现 + +```csharp +public sealed class BridgeService : IBridgeService, IAsyncDisposable +{ + private readonly IBridgeApiClient _apiClient; + private readonly ISessionSpawner _sessionSpawner; + private readonly IBridgeLogger _logger; + private readonly BridgeConfig _config; + private readonly ConcurrentDictionary _activeSessions = new(); + private string? _environmentId; + private string? _environmentSecret; + private BridgeStatus _status = BridgeStatus.Idle; + + public async Task RegisterEnvironmentAsync() + { + var result = await _apiClient.RegisterBridgeEnvironment(_config); + _environmentId = result.EnvironmentId; + _environmentSecret = result.EnvironmentSecret; + _status = BridgeStatus.Registered; + return new BridgeEnvironment(result.EnvironmentId, result.EnvironmentSecret); + } + + /// + /// 主轮询循环 — 对应原始 bridgeMain.ts 的 pollForWork loop + /// + public async Task RunAsync(CancellationToken ct) + { + await RegisterEnvironmentAsync(); + _logger.PrintBanner(_config, _environmentId!); + + while (!ct.IsCancellationRequested) + { + try + { + var work = await PollForWorkAsync(ct); + if (work == null) continue; + + _status = BridgeStatus.Attached; + _logger.LogSessionStart(work.Id, work.Data.ToString()); + + // 解码 work secret → 获取 session token + var secret = DecodeWorkSecret(work.Secret); + await AcknowledgeWorkAsync(work.Id, secret.SessionIngressToken); + + // 生成会话 (可能使用 worktree 隔离) + var sessionDir = await PrepareSessionDirectoryAsync(work); + var handle = await _sessionSpawner.SpawnAsync(new SessionSpawnOptions + { + SessionId = work.Id, + SdkUrl = secret.ApiBaseUrl, + AccessToken = secret.SessionIngressToken, + WorkingDirectory = sessionDir, + }, ct); + + _activeSessions[work.Id] = handle; + + // 等待会话完成 + _ = MonitorSessionAsync(work.Id, handle, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError($"Poll error: {ex.Message}"); + await Task.Delay(CalculateBackoff(), ct); + } + } + } + + private async Task MonitorSessionAsync( + string sessionId, SessionHandle handle, CancellationToken ct) + { + var doneStatus = await handle.Done; + _activeSessions.TryRemove(sessionId, out _); + _logger.LogSessionComplete(sessionId, 0); + _status = _activeSessions.IsEmpty ? BridgeStatus.Registered : BridgeStatus.Attached; + } + + public async ValueTask DisposeAsync() + { + foreach (var handle in _activeSessions.Values) + handle.Kill(); + + if (_environmentId != null) + await DeregisterEnvironmentAsync(); + } +} +``` + +## 12.4 IBridgeApiClient + +```csharp +/// +/// 桥接 API 客户端 — 对应原始 bridgeApi.ts +/// +public interface IBridgeApiClient +{ + Task<(string EnvironmentId, string EnvironmentSecret)> RegisterBridgeEnvironment(BridgeConfig config); + Task PollForWork(string envId, string envSecret, CancellationToken ct); + Task AcknowledgeWork(string envId, string workId, string sessionToken); + Task StopWork(string envId, string workId); + Task DeregisterEnvironment(string envId); + Task SendPermissionResponseEvent(string sessionId, PermissionResponseEvent evt, string token); + Task ArchiveSession(string sessionId); + Task HeartbeatWork(string envId, string workId, string sessionToken); +} +``` + +## 12.5 生命周期与传输策略 + +- 环境注册先于轮询;只有拿到 `EnvironmentId` / `EnvironmentSecret` 后才开始接收工作项。 +- `RunAsync` 负责持续轮询,`SpawnSessionAsync` 负责把单个 work item 落到独立会话中。 +- `Worktree` 模式用于最强隔离,`SameDir` 用于低开销复用,`SingleSession` 用于短生命周期任务。 +- 传输层上保持 API 侧长轮询/轮询式控制,避免把 IDE 控制流绑死在单一 socket 连接上。 +- `HeartbeatAsync` 用于延长工作租约,防止长任务被服务端提前回收。 + +## 12.6 补充说明 + +- 该层的重点不是本地执行,而是把远端工作项和本地会话生命周期做稳定映射。 +- 所有状态变化最终都应回写到上层状态存储,避免桥接层形成隐式状态孤岛。 diff --git a/docs/基础设施设计/基础设施设计-LSP集成.md b/docs/基础设施设计/基础设施设计-LSP集成.md new file mode 100644 index 0000000..a17860f --- /dev/null +++ b/docs/基础设施设计/基础设施设计-LSP集成.md @@ -0,0 +1,368 @@ +# 基础设施设计 — LSP 集成 + +## 文档元数据 +- 项目名称: free-code +- 文档类型: 基础设施设计 +- 原始代码来源: `../../src/services/lsp/`(7个文件) +- 原始设计意图: 在 .NET 中封装语言服务器生命周期、文件同步与 9 种核心 LSP 操作,并支持按扩展名路由与诊断基线比较 +- 交叉引用: [基础设施设计总览](基础设施设计.md) | [核心模块设计-工具系统](../核心模块设计/核心模块设计-工具系统.md) + +## 设计目标 +LSP 层用于承载代码补全、诊断、跳转与重命名等 IDE 能力,为编辑体验提供统一协议封装。它既要兼容多语言服务器,又要支持懒启动、文件同步和跨文件诊断聚合。 + +## 11.1 ILspClientManager 接口定义 + +```csharp +/// +/// LSP 客户端管理器 — 管理 LSP 服务器实例 +/// 对应原始 LSPServerManager.ts +/// +public interface ILspClientManager +{ + /// 初始化 (加载配置, 懒启动) + Task InitializeAsync(CancellationToken ct = default); + + /// 关闭所有服务器 + Task ShutdownAsync(); + + /// 获取文件对应的 LSP 服务器 + ILspServerInstance? GetServerForFile(string filePath); + + /// 确保文件对应的 LSP 服务器已启动 + Task EnsureServerStartedAsync(string filePath); + + /// 发送请求到文件对应的 LSP 服务器 + Task SendRequestAsync(string filePath, string method, object? parameters); + + // LSP 操作 (对应原始 LSPTool 的 9 种操作) + Task GoToDefinitionAsync(string filePath, int line, int character); + Task FindReferencesAsync(string filePath, int line, int character); + Task HoverAsync(string filePath, int line, int character); + Task DocumentSymbolsAsync(string filePath); + Task WorkspaceSymbolsAsync(string query); + Task GetDiagnosticsAsync(string filePath); + Task PrepareRenameAsync(string filePath, int line, int character); + Task RenameAsync(string filePath, int line, int character, string newName); + Task GetCodeActionsAsync(string filePath, int line, int character); + + // 文件同步 + Task OpenFileAsync(string filePath, string content); + Task ChangeFileAsync(string filePath, string content); + Task SaveFileAsync(string filePath); + Task CloseFileAsync(string filePath); + + /// 所有运行中的服务器 + IReadOnlyDictionary GetAllServers(); + + /// 是否至少有一个服务器已连接 + bool IsConnected { get; } +} +``` + +## 11.2 LspServerInstance 实现 + +```csharp +/// +/// 单个 LSP 服务器实例 — 管理子进程生命周期 +/// 对应原始 LSPServerInstance.ts +/// +public interface ILspServerInstance +{ + string Name { get; } + string Command { get; } + IReadOnlyDictionary ExtensionToLanguage { get; } + LspServerState State { get; } + + Task StartAsync(); + Task StopAsync(); + Task SendRequestAsync(string method, object? parameters); + Task SendNotificationAsync(string method, object? parameters); + void OnRequest(string method, Func handler); +} + +public enum LspServerState { Stopped, Starting, Running, Error } + +/// +/// LSP 服务器实例实现 +/// +public sealed class LspServerInstance : ILspServerInstance, IAsyncDisposable +{ + private Process? _process; + private JsonRpc? _rpc; // 使用 StreamJsonRpc 库 + private readonly string _name; + private readonly ScopedLspServerConfig _config; + private readonly ILogger _logger; + private readonly Dictionary> _requestHandlers = new(); + + public LspServerState State { get; private set; } = LspServerState.Stopped; + + public async Task StartAsync() + { + if (State == LspServerState.Running) return; + State = LspServerState.Starting; + + var psi = new ProcessStartInfo + { + FileName = _config.Command, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + foreach (var arg in _config.Args) + psi.ArgumentList.Add(arg); + if (_config.Env != null) + foreach (var (k, v) in _config.Env) + psi.Environment[k] = v; + + _process = Process.Start(psi) + ?? throw new InvalidOperationException($"Failed to start LSP server {_name}"); + + // 使用 StreamJsonRpc 建立 LSP 连接 + _rpc = JsonRpc.Attach(_process.StandardInput.BaseStream, _process.StandardOutput.BaseStream); + _rpc.Disconnected += (_, _) => State = LspServerState.Stopped; + + // 发送 initialize 请求 + var initResult = await _rpc.InvokeAsync("initialize", new + { + processId = Environment.ProcessId, + rootUri = PathToFileUri(Directory.GetCurrentDirectory()), + capabilities = new + { + textDocument = new + { + definition = new { dynamicRegistration = false }, + references = new { dynamicRegistration = false }, + hover = new { dynamicRegistration = false, contentFormat = new[] { "markdown", "plaintext" } }, + publishDiagnostics = new { relatedInformation = true }, + rename = new { prepareSupport = true } + } + } + }); + + // 发送 initialized 通知 + await _rpc.NotifyAsync("initialized", new { }); + State = LspServerState.Running; + } + + public Task SendRequestAsync(string method, object? parameters) + => _rpc!.InvokeAsync(method, parameters); + + public Task SendNotificationAsync(string method, object? parameters) + => _rpc!.NotifyAsync(method, parameters); + + public async ValueTask DisposeAsync() + { + if (_rpc != null) + { + try { await _rpc.NotifyAsync("shutdown", null); } catch { } + try { await _rpc.NotifyAsync("exit", null); } catch { } + } + if (_process?.HasExited == false) + { + _process.WaitForExit(5000); + if (!_process.HasExited) _process.Kill(entireProcessTree: true); + } + State = LspServerState.Stopped; + } +} +``` + +## 11.3 LspClientManager 实现 + +```csharp +public sealed class LspClientManager : ILspClientManager +{ + private readonly ConcurrentDictionary _servers = new(); + private readonly Dictionary> _extensionMap = new(); // ext → [serverNames] + private readonly HashSet _openedFiles = new(); // URI 跟踪 + private readonly ILogger _logger; + + public bool IsConnected => _servers.Values.Any(s => s.State == LspServerState.Running); + + /// + /// 初始化: 加载 LSP 配置,构建扩展映射,不启动服务器(懒启动) + /// 对应原始 initializeLspServerManager() + /// + public async Task InitializeAsync(CancellationToken ct = default) + { + var configs = await LoadAllLspServerConfigsAsync(); + + foreach (var (name, config) in configs) + { + if (string.IsNullOrEmpty(config.Command)) + { + _logger.LogWarning("LSP server {Name} missing command, skipping", name); + continue; + } + + // 构建扩展 → 服务器映射 + foreach (var ext in config.ExtensionToLanguage.Keys) + { + var normalized = ext.ToLowerInvariant(); + if (!_extensionMap.ContainsKey(normalized)) + _extensionMap[normalized] = new(); + _extensionMap[normalized].Add(name); + } + + _servers[name] = new LspServerInstance(name, config, _logger); + } + } + + public ILspServerInstance? GetServerForFile(string filePath) + { + var ext = Path.GetExtension(filePath).ToLowerInvariant(); + var serverNames = _extensionMap.GetValueOrDefault(ext); + return serverNames?.Count > 0 ? _servers.GetValueOrDefault(serverNames[0]) : null; + } + + public async Task EnsureServerStartedAsync(string filePath) + { + var server = GetServerForFile(filePath); + if (server == null) return null; + + if (server.State is LspServerState.Stopped or LspServerState.Error) + await server.StartAsync(); + + return server; + } + + public async Task SendRequestAsync(string filePath, string method, object? parameters) + { + var server = await EnsureServerStartedAsync(filePath); + if (server == null) return default; + return await server.SendRequestAsync(method, parameters); + } + + // === LSP 操作实现 === + + public async Task GoToDefinitionAsync(string filePath, int line, int character) + => await SendRequestAsync(filePath, "textDocument/definition", + new { textDocument = new { uri = PathToFileUri(filePath) }, + position = new { line = line - 1, character } }); + + public async Task FindReferencesAsync(string filePath, int line, int character) + => await SendRequestAsync(filePath, "textDocument/references", + new { textDocument = new { uri = PathToFileUri(filePath) }, + position = new { line = line - 1, character }, + context = new { includeDeclaration = true } }) ?? []; + + public async Task HoverAsync(string filePath, int line, int character) + => await SendRequestAsync(filePath, "textDocument/hover", + new { textDocument = new { uri = PathToFileUri(filePath) }, + position = new { line = line - 1, character } }); + + public async Task DocumentSymbolsAsync(string filePath) + => await SendRequestAsync(filePath, "textDocument/documentSymbol", + new { textDocument = new { uri = PathToFileUri(filePath) } }) ?? []; + + public async Task WorkspaceSymbolsAsync(string query) + { + // 遍历所有已启动的服务器 + var results = new List(); + foreach (var server in _servers.Values.Where(s => s.State == LspServerState.Running)) + { + try + { + var symbols = await server.SendRequestAsync( + "workspace/symbol", new { query }); + if (symbols != null) results.AddRange(symbols); + } + catch { /* 继续尝试其他服务器 */ } + } + return results.ToArray(); + } + + // === 文件同步 === + + public async Task OpenFileAsync(string filePath, string content) + { + var server = await EnsureServerStartedAsync(filePath); + if (server == null) return; + + var uri = PathToFileUri(filePath); + if (_openedFiles.Contains(uri)) return; // 已打开 + + var ext = Path.GetExtension(filePath).ToLowerInvariant(); + var languageId = server.ExtensionToLanguage.GetValueOrDefault(ext) ?? "plaintext"; + + await server.SendNotificationAsync("textDocument/didOpen", new + { + textDocument = new { uri, languageId, version = 1, text = content } + }); + _openedFiles.Add(uri); + } + + public async Task ChangeFileAsync(string filePath, string content) + { + var server = GetServerForFile(filePath); + if (server?.State != LspServerState.Running) + return; // 先 OpenFile + + var uri = PathToFileUri(filePath); + if (!_openedFiles.Contains(uri)) + return; // 先 OpenFile + + await server.SendNotificationAsync("textDocument/didChange", new + { + textDocument = new { uri, version = 1 }, + contentChanges = new[] { new { text = content } } + }); + } + + public async Task ShutdownAsync() + { + var tasks = _servers.Values + .Where(s => s.State is LspServerState.Running or LspServerState.Error) + .Select(s => s.DisposeAsync().AsTask()); + await Task.WhenAll(tasks); + _servers.Clear(); + _extensionMap.Clear(); + _openedFiles.Clear(); + } + + private static string PathToFileUri(string path) => + new Uri(Path.GetFullPath(path)).AbsoluteUri; +} +``` + +## 11.4 LspDiagnosticRegistry 基线比较 + +```csharp +/// +/// LSP 诊断收集与基线对比 +/// 对应原始 LSPDiagnosticRegistry.ts +/// +public sealed class LspDiagnosticRegistry +{ + private readonly Dictionary> _baseline = new(); + private readonly Dictionary> _current = new(); + + /// 保存当前诊断为基线(编辑前快照) + public void SaveBaseline(string filePath) + { + if (_current.TryGetValue(filePath, out var diagnostics)) + _baseline[filePath] = new(diagnostics); + } + + /// 更新诊断(来自 textDocument/publishDiagnostics 通知) + public void UpdateDiagnostics(string filePath, IReadOnlyList diagnostics) + { + _current[filePath] = diagnostics.ToList(); + } + + /// 获取新增的诊断(相对于基线) + public IReadOnlyList GetNewDiagnostics(string filePath) + { + var baseline = _baseline.GetValueOrDefault(filePath) ?? []; + var current = _current.GetValueOrDefault(filePath) ?? []; + var baselineSet = new HashSet(baseline); + return current.Where(d => !baselineSet.Contains(d)).ToList(); + } + + /// 清除文件基线 + public void ClearBaseline(string filePath) => _baseline.Remove(filePath); +} +``` diff --git a/docs/基础设施设计/基础设施设计-MCP协议集成.md b/docs/基础设施设计/基础设施设计-MCP协议集成.md new file mode 100644 index 0000000..649348f --- /dev/null +++ b/docs/基础设施设计/基础设施设计-MCP协议集成.md @@ -0,0 +1,802 @@ +# 基础设施设计 — MCP 协议集成 + +## 文档元数据 +- 项目名称: free-code +- 文档类型: 基础设施设计 +- 原始代码来源: `../../src/services/mcp/`(22个文件) +- 原始设计意图: 将 MCP 服务器、工具、资源与认证统一抽象为可管理的 .NET 协议层,并支持多传输、多作用域与自动适配 +- 交叉引用: [基础设施设计总览](基础设施设计.md) | [核心模块设计-工具系统](../核心模块设计/核心模块设计-工具系统.md) | [原始代码映射](reference/原始代码映射-基础设施.md) + +## 设计目标 +MCP 协议层负责把外部工具、远程服务、认证流程与本地执行环境统一起来,向上提供稳定的 .NET 抽象。该层不仅要兼容多种 transport,还要把 MCP 工具/命令/资源适配为上层工具系统可消费的接口。 + +## 10.1 IMcpClientManager 接口定义 + +```csharp +/// +/// MCP 客户端管理器 — 管理多个 MCP 服务器连接 +/// 对应原始 useManageMCPConnections.ts +/// +public interface IMcpClientManager +{ + /// 连接所有配置的 MCP 服务器 + Task ConnectServersAsync(CancellationToken ct = default); + + /// 获取所有已连接服务器的工具(适配为 ITool) + Task> GetToolsAsync(); + + /// 获取所有已连接服务器的命令(适配为 ICommand) + Task> GetCommandsAsync(); + + /// 列出指定服务器的资源 + Task> ListResourcesAsync( + string? serverName = null, CancellationToken ct = default); + + /// 读取指定资源 + Task ReadResourceAsync( + string serverName, string resourceUri, CancellationToken ct = default); + + /// 断开指定服务器 + Task DisconnectServerAsync(string serverName); + + /// 重连指定服务器(用于断线恢复) + Task ReconnectServerAsync(string serverName); + + /// 获取所有服务器连接状态 + IReadOnlyList GetConnections(); + + /// 触发认证流程(OAuth) + Task AuthenticateServerAsync(string serverName); + + /// 重新加载所有配置并重连 + Task ReloadAsync(); +} +``` + +## 10.2 MCPServerConnection 抽象 record + +对应原始 `types.ts` 中的 union type。 + +```csharp +/// +/// MCP 服务器连接状态 — 替代原始 TypeScript 联合类型 +/// 原始: ConnectedMCPServer | FailedMCPServer | NeedsAuthMCPServer | PendingMCPServer | DisabledMCPServer +/// +public abstract record MCPServerConnection +{ + public string Name { get; init; } + public string ConnectionType { get; init; } + public ScopedMcpServerConfig Config { get; init; } + + // 类型判别(替代 TypeScript 可辨识联合) + public bool IsConnected => this is Connected; + public bool IsFailed => this is Failed; + public bool NeedsAuth => this is NeedsAuthentication; + public bool IsPending => this is Pending; + public bool IsDisabled => this is Disabled; + + public sealed record Connected( + string Name, + ScopedMcpServerConfig Config, + McpClient Client, + ServerCapabilities Capabilities, + ServerInfo? ServerInfo, + string? Instructions, + Func Cleanup + ) : MCPServerConnection { ConnectionType = "connected"; } + + public sealed record Failed( + string Name, + ScopedMcpServerConfig Config, + string? Error + ) : MCPServerConnection { ConnectionType = "failed"; } + + public sealed record NeedsAuthentication( + string Name, + ScopedMcpServerConfig Config + ) : MCPServerConnection { ConnectionType = "needs-auth"; } + + public sealed record Pending( + string Name, + ScopedMcpServerConfig Config, + int? ReconnectAttempt = null, + int? MaxReconnectAttempts = null + ) : MCPServerConnection { ConnectionType = "pending"; } + + public sealed record Disabled( + string Name, + ScopedMcpServerConfig Config + ) : MCPServerConnection { ConnectionType = "disabled"; } +} +``` + +## 10.3 ScopedMcpServerConfig 配置层级 + +对应原始 `types.ts` 中的 Zod schema 定义。 + +```csharp +/// +/// MCP 服务器配置 — 替代原始 8 种 Zod schema +/// 使用 FluentValidation 进行校验 +/// +public abstract record ScopedMcpServerConfig +{ + public required ConfigScope Scope { get; init; } + public string? PluginSource { get; init; } +} + +public record StdioServerConfig : ScopedMcpServerConfig +{ + public string Command { get; init; } = ""; + public IReadOnlyList Args { get; init; } = []; + public IReadOnlyDictionary? Env { get; init; } +} + +public record SseServerConfig : ScopedMcpServerConfig +{ + public required string Url { get; init; } + public IReadOnlyDictionary? Headers { get; init; } + public string? HeadersHelper { get; init; } + public McpOAuthConfig? OAuth { get; init; } +} + +public record SseIdeServerConfig : ScopedMcpServerConfig +{ + public required string Url { get; init; } + public required string IdeName { get; init; } + public bool IdeRunningInWindows { get; init; } +} + +public record WsIdeServerConfig : ScopedMcpServerConfig +{ + public required string Url { get; init; } + public required string IdeName { get; init; } + public string? AuthToken { get; init; } + public bool IdeRunningInWindows { get; init; } +} + +public record HttpServerConfig : ScopedMcpServerConfig +{ + public required string Url { get; init; } + public IReadOnlyDictionary? Headers { get; init; } + public string? HeadersHelper { get; init; } + public McpOAuthConfig? OAuth { get; init; } +} + +public record WebSocketServerConfig : ScopedMcpServerConfig +{ + public required string Url { get; init; } + public IReadOnlyDictionary? Headers { get; init; } + public string? HeadersHelper { get; init; } +} + +public record SdkServerConfig : ScopedMcpServerConfig +{ + public required string ServerName { get; init; } +} + +public record ClaudeAiProxyServerConfig : ScopedMcpServerConfig +{ + public required string Url { get; init; } + public required string Id { get; init; } +} + +public enum ConfigScope +{ + Local, User, Project, Dynamic, Enterprise, ClaudeAi, Managed +} + +public record McpOAuthConfig +{ + public string? ClientId { get; init; } + public int? CallbackPort { get; init; } + public string? AuthServerMetadataUrl { get; init; } + public bool Xaa { get; init; } +} +``` + +## 10.4 传输层实现 + +对应原始 `@modelcontextprotocol/sdk` 中的多种传输 + 自定义 WebSocketTransport。 + +```csharp +/// +/// MCP 传输层抽象 — JSON-RPC 2.0 over various transports +/// +public interface IMcpTransport : IAsyncDisposable +{ + Task ConnectAsync(CancellationToken ct = default); + Task SendAsync(JsonRpcMessage message, CancellationToken ct = default); + IAsyncEnumerable ListenAsync(CancellationToken ct = default); + Task CloseAsync(); + bool IsConnected { get; } +} + +/// +/// Stdio 传输 — 子进程 stdin/stdout +/// 对应原始 StdioClientTransport +/// +public sealed class StdioTransport : IMcpTransport +{ + private readonly Process _process; + private readonly Channel _incoming = Channel.CreateUnbounded(); + + public StdioTransport(StdioServerConfig config, string workingDirectory) + { + var psi = new ProcessStartInfo + { + FileName = config.Command, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = workingDirectory, + }; + + foreach (var arg in config.Args) + psi.ArgumentList.Add(arg); + + // 合并环境变量 + if (config.Env != null) + foreach (var (key, value) in config.Env) + psi.Environment[key] = value; + + _process = new Process { StartInfo = psi }; + } + + public async Task ConnectAsync(CancellationToken ct = default) + { + _process.Start(); + + // 后台读取 stdout → 解析 JSON-RPC 消息 → 写入 channel + _ = Task.Run(async () => + { + using var reader = new StreamReader(_process.StandardOutput.BaseStream, + System.Text.Encoding.UTF8); + while (!reader.EndOfStream && !ct.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(ct); + if (string.IsNullOrEmpty(line)) continue; + var message = JsonSerializer.Deserialize(line); + if (message != null) + await _incoming.Writer.WriteAsync(message, ct); + } + }, ct); + } + + public async Task SendAsync(JsonRpcMessage message, CancellationToken ct = default) + { + var json = JsonSerializer.Serialize(message); + await _process.StandardInput.WriteLineAsync(json.AsMemory(), ct); + await _process.StandardInput.FlushAsync(); + } + + public IAsyncEnumerable ListenAsync(CancellationToken ct = default) + => _incoming.Reader.ReadAllAsync(ct); + + public bool IsConnected => !_process.HasExited; + + public async ValueTask DisposeAsync() + { + if (!_process.HasExited) + { + _process.StandardInput.Close(); + if (!_process.WaitForExit(5000)) + _process.Kill(entireProcessTree: true); + } + _incoming.Writer.TryComplete(); + } +} + +/// +/// SSE 传输 — HTTP Server-Sent Events +/// 对应原始 SSEClientTransport +/// +public sealed class SseTransport : IMcpTransport +{ + private readonly HttpClient _httpClient; + private readonly string _url; + private readonly Channel _incoming = Channel.CreateUnbounded(); + private string? _messageEndpoint; // 服务器提供的 POST 端点 + + public async Task ConnectAsync(CancellationToken ct = default) + { + // GET SSE 流 → 解析 endpoint 事件 → 开始监听 + var response = await _httpClient.GetAsync(_url, HttpCompletionOption.ResponseHeadersRead, ct); + using var stream = await response.Content.ReadAsStreamAsync(ct); + using var reader = new StreamReader(stream); + + while (!reader.EndOfStream && !ct.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(ct); + if (string.IsNullOrEmpty(line)) continue; + + if (line.StartsWith("event: endpoint")) + { + var dataLine = await reader.ReadLineAsync(ct); + if (dataLine?.StartsWith("data: ") == true) + _messageEndpoint = dataLine[6..]; + } + else if (line.StartsWith("data: ")) + { + var json = line[6..]; + var message = JsonSerializer.Deserialize(json); + if (message != null) + await _incoming.Writer.WriteAsync(message, ct); + } + } + } + + public async Task SendAsync(JsonRpcMessage message, CancellationToken ct = default) + { + if (_messageEndpoint == null) + throw new InvalidOperationException("SSE transport not connected"); + + var content = JsonContent.Create(message); + await _httpClient.PostAsync(_messageEndpoint, content, ct); + } + + public IAsyncEnumerable ListenAsync(CancellationToken ct = default) + => _incoming.Reader.ReadAllAsync(ct); + + public bool IsConnected => _messageEndpoint != null; +} + +/// +/// Streamable HTTP 传输 — MCP 2025-03-26 规范 +/// 对应原始 StreamableHTTPClientTransport +/// POST 发送消息,响应可能是 JSON 或 SSE +/// +public sealed class StreamableHttpTransport : IMcpTransport +{ + private readonly HttpClient _httpClient; + private readonly string _url; + private readonly Channel _incoming = Channel.CreateUnbounded(); + private string? _sessionId; // 服务器分配的 Mcp-Session-Id + + public async Task ConnectAsync(CancellationToken ct = default) + { + // 发送 initialize JSON-RPC 请求 + var initRequest = new JsonRpcRequest + { + Id = "init-1", + Method = "initialize", + Params = new { protocolVersion = "2025-03-26", capabilities = new { }, clientInfo = new { name = "free-code", version = "1.0" } } + }; + + var response = await SendInternalAsync(initRequest, ct); + // 从响应头提取 session ID + if (response.Headers.Contains("Mcp-Session-Id")) + _sessionId = response.Headers.GetValues("Mcp-Session-Id").First(); + } + + private async Task SendInternalAsync( + JsonRpcRequest request, CancellationToken ct) + { + using var content = JsonContent.Create(request); + var httpReq = new HttpRequestMessage(HttpMethod.Post, _url) { Content = content }; + + // MCP Streamable HTTP 规范: Accept 必须包含 application/json 和 text/event-stream + httpReq.Headers.Accept.Add(new("application/json")); + httpReq.Headers.Accept.Add(new("text/event-stream")); + + if (_sessionId != null) + httpReq.Headers.Add("Mcp-Session-Id", _sessionId); + + return await _httpClient.SendAsync(httpReq, HttpCompletionOption.ResponseHeadersRead, ct); + } + + // ... SendAsync 和 ListenAsync 实现类似 SSE 但处理 JSON/SSE 双模式响应 +} + +/// +/// WebSocket 传输 — 对应原始 WebSocketTransport (自定义实现) +/// +public sealed class WebSocketTransport : IMcpTransport +{ + private readonly ClientWebSocket _webSocket; + private readonly string _url; + private readonly Channel _incoming = Channel.CreateUnbounded(); + + public async Task ConnectAsync(CancellationToken ct = default) + { + await _webSocket.ConnectAsync(new Uri(_url), ct); + _ = ReceiveLoopAsync(ct); + } + + private async Task ReceiveLoopAsync(CancellationToken ct) + { + var buffer = new byte[8192]; + while (_webSocket.State == WebSocketState.Open && !ct.IsCancellationRequested) + { + using var ms = new MemoryStream(); + WebSocketReceiveResult result; + do + { + result = await _webSocket.ReceiveAsync(buffer, ct); + await ms.WriteAsync(buffer, 0, result.Count, ct); + } while (!result.EndOfMessage); + + var json = System.Text.Encoding.UTF8.GetString(ms.ToArray()); + var message = JsonSerializer.Deserialize(json); + if (message != null) + await _incoming.Writer.WriteAsync(message, ct); + } + } + + public async Task SendAsync(JsonRpcMessage message, CancellationToken ct = default) + { + var json = JsonSerializer.Serialize(message); + var bytes = System.Text.Encoding.UTF8.GetBytes(json); + await _webSocket.SendAsync(bytes, WebSocketMessageType.Text, true, ct); + } + + public IAsyncEnumerable ListenAsync(CancellationToken ct = default) + => _incoming.Reader.ReadAllAsync(ct); + + public bool IsConnected => _webSocket.State == WebSocketState.Open; +} + +/// +/// 进程内传输 — 对应原始 InProcessTransport (linked transport pair) +/// 用于 Chrome/Computer Use MCP 服务器在同一进程运行 +/// +public sealed class InProcessTransport : IMcpTransport +{ + private readonly Channel _serverToClient = Channel.CreateUnbounded(); + private readonly Channel _clientToServer = Channel.CreateUnbounded(); + + // 创建一对连接的传输(客户端 + 服务器各一个) + public static (InProcessTransport client, InProcessTransport server) CreateLinkedPair() + { + var client = new InProcessTransport(); + var server = new InProcessTransport(); + // 交叉连接 channel: client writes → server reads, vice versa + client._outgoing = server._serverToClient.Writer; + server._outgoing = client._clientToServer.Writer; + return (client, server); + } + + private ChannelWriter? _outgoing; + public IAsyncEnumerable ListenAsync(CancellationToken ct = default) + => _serverToClient.Reader.ReadAllAsync(ct); + // ... +} +``` + +## 10.5 McpClient 核心 + +```csharp +/// +/// JSON-RPC 2.0 MCP 客户端 +/// 对应原始 @modelcontextprotocol/sdk Client +/// +public sealed class McpClient +{ + private readonly IMcpTransport _transport; + private int _requestId; + private readonly ConcurrentDictionary> _pending = new(); + + public ServerCapabilities? Capabilities { get; private set; } + public ServerInfo? ServerInfo { get; private set; } + public string? Instructions { get; private set; } + + public async Task ConnectAsync(CancellationToken ct = default) + { + await _transport.ConnectAsync(ct); + + // 发送 initialize → 接收 response + var response = await SendRequestAsync("initialize", new + { + protocolVersion = "2025-03-26", + capabilities = new { roots = new { }, elicitation = new { } }, + clientInfo = new { name = "free-code", version = "1.0" } + }, ct); + + Capabilities = DeserializeCapabilities(response); + ServerInfo = DeserializeServerInfo(response); + Instructions = DeserializeInstructions(response); + + // 发送 initialized 通知 + await SendNotificationAsync("notifications/initialized", null, ct); + } + + public async Task ListToolsAsync(CancellationToken ct = default) + { + var response = await SendRequestAsync("tools/list", new { }, ct); + return Deserialize(response); + } + + public async Task CallToolAsync( + string toolName, JsonElement? arguments = null, CancellationToken ct = default) + { + var response = await SendRequestAsync("tools/call", new + { + name = toolName, + arguments + }, ct); + return Deserialize(response); + } + + public async Task ListResourcesAsync(CancellationToken ct = default) + { + var response = await SendRequestAsync("resources/list", new { }, ct); + return Deserialize(response); + } + + public async Task ReadResourceAsync( + string uri, CancellationToken ct = default) + { + var response = await SendRequestAsync("resources/read", new { uri }, ct); + return Deserialize(response); + } + + public async Task ListPromptsAsync(CancellationToken ct = default) + { + var response = await SendRequestAsync("prompts/list", new { }, ct); + return Deserialize(response); + } + + private async Task SendRequestAsync( + string method, object? @params, CancellationToken ct) + { + var id = Interlocked.Increment(ref _requestId).ToString(); + var tcs = new TaskCompletionSource(); + _pending[id] = tcs; + + var message = new JsonRpcRequest { Id = id, Method = method, Params = @params }; + await _transport.SendAsync(message, ct); + + using var reg = ct.Register(() => tcs.TrySetCanceled(ct)); + var response = await tcs.Task; + return response.Result; + } + + private async Task SendNotificationAsync( + string method, object? @params, CancellationToken ct) + { + var message = new JsonRpcNotification { Method = method, Params = @params }; + await _transport.SendAsync(message, ct); + } +} +``` + +## 10.6 McpClientManager 实现 + +```csharp +public class McpClientManager : IMcpClientManager +{ + private readonly IServiceProvider _services; + private readonly IConfiguration _config; + private readonly IFeatureFlagService _features; + private readonly IAppStateStore _stateStore; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _connections = new(); + + /// + /// 连接所有配置的 MCP 服务器 + /// 对应原始 useManageMCPConnections.ts 的 effect + /// + public async Task ConnectServersAsync(CancellationToken ct = default) + { + var configs = await LoadAllServerConfigsAsync(); + var localServers = configs.Where(c => IsLocalServer(c.Value)).ToList(); + var remoteServers = configs.Where(c => !IsLocalServer(c.Value)).ToList(); + + // 本地服务器: 批量连接 (默认 concurrency: 3) + var localBatchSize = GetLocalBatchSize(); // MCP_SERVER_CONNECTION_BATCH_SIZE + await Parallel.ForEachAsync(localServers, + new ParallelOptions { MaxDegreeOfParallelism = localBatchSize, CancellationToken = ct }, + async (kvp, _) => + { + var connection = await ConnectToServerAsync(kvp.Key, kvp.Value, ct); + _connections[kvp.Key] = connection; + UpdateState(); + }); + + // 远程服务器: 批量连接 (默认 concurrency: 20) + var remoteBatchSize = GetRemoteBatchSize(); // MCP_REMOTE_SERVER_CONNECTION_BATCH_SIZE + await Parallel.ForEachAsync(remoteServers, + new ParallelOptions { MaxDegreeOfParallelism = remoteBatchSize, CancellationToken = ct }, + async (kvp, _) => + { + var connection = await ConnectToServerAsync(kvp.Key, kvp.Value, ct); + _connections[kvp.Key] = connection; + UpdateState(); + }); + } + + private async Task ConnectToServerAsync( + string name, ScopedMcpServerConfig config, CancellationToken ct) + { + try + { + // 1. 创建传输层 + var transport = CreateTransport(name, config); + + // 2. 创建客户端并连接 + var client = new McpClient(transport); + var timeout = GetConnectionTimeout(); // MCP_TIMEOUT 环境变量, 默认 30s + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(timeout); + + await client.ConnectAsync(timeoutCts.Token); + + // 3. 获取能力、工具、资源 + var tools = await client.ListToolsAsync(ct); + var capabilities = client.Capabilities!; + + // 4. 截断过长的 instructions (MAX_MCP_DESCRIPTION_LENGTH = 2048) + var instructions = client.Instructions; + if (instructions?.Length > 2048) + instructions = instructions[..2048] + "… [truncated]"; + + return new MCPServerConnection.Connected(name, config, client, + capabilities, client.ServerInfo, instructions, + async () => await transport.CloseAsync()); + } + catch (UnauthorizedException) + { + // SSE/HTTP/claudeai-proxy: 需要认证 + return new MCPServerConnection.NeedsAuthentication(name, config); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "MCP server {Name} connection failed", name); + return new MCPServerConnection.Failed(name, config, ex.Message); + } + } + + private IMcpTransport CreateTransport(string name, ScopedMcpServerConfig config) + { + return config switch + { + StdioServerConfig stdio => new StdioTransport(stdio, + Directory.GetCurrentDirectory()), + SseServerConfig sse => new SseTransport( + CreateHttpClient(sse.Headers), sse.Url), + SseIdeServerConfig sseIde => new SseTransport( + new HttpClient(), sseIde.Url), + HttpServerConfig http => new StreamableHttpTransport( + CreateHttpClient(http.Headers), http.Url), + WsIdeServerConfig wsIde => new WebSocketTransport(wsIde.Url, wsIde.AuthToken), + WebSocketServerConfig ws => new WebSocketTransport(ws.Url, null, ws.Headers), + ClaudeAiProxyServerConfig proxy => new StreamableHttpTransport( + CreateProxyHttpClient(), proxy.Url), + _ => throw new InvalidOperationException($"Unsupported server type: {config}") + }; + } + + /// + /// 获取所有工具并适配为 ITool 接口 + /// 对应原始 MCPTool 适配器 + /// + public async Task> GetToolsAsync() + { + var tools = new List(); + foreach (var connection in _connections.Values) + { + if (connection is MCPServerConnection.Connected connected) + { + var mcpTools = await connected.Client.ListToolsAsync(); + foreach (var tool in mcpTools.Tools) + { + tools.Add(new McpToolWrapper(connected.Name, tool, connected.Client)); + } + } + } + return tools; + } + + /// 更新 AppState 中的 MCP 状态 + private void UpdateState() + { + _stateStore.Update(state => state with + { + Mcp = state.Mcp with + { + Clients = _connections.Values.ToList(), + Tools = GetToolsFromConnections(), + } + }); + } + + private static bool IsLocalServer(ScopedMcpServerConfig config) => + config is StdioServerConfig or SdkServerConfig; +} + +/// +/// MCP 工具包装器 — 将 MCP tool 适配为 ITool 接口 +/// 对应原始 MCPTool.ts +/// +public sealed class McpToolWrapper : ITool +{ + private readonly string _serverName; + private readonly McpToolDefinition _definition; + private readonly McpClient _client; + + public string Name => $"mcp__{_serverName}__{_definition.Name}"; + public ToolCategory Category => ToolCategory.Mcp; + + public async Task> ExecuteAsync( + JsonElement input, ToolExecutionContext context, CancellationToken ct) + { + var timeout = GetMcpToolTimeout(); // MCP_TOOL_TIMEOUT, 默认 ~27.8h + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(timeout); + + try + { + var result = await _client.CallToolAsync(_definition.Name, input, timeoutCts.Token); + + // 处理二进制内容 → 持久化到文件 + if (result.HasBinaryContent) + { + var persisted = await PersistBinaryContentAsync(result); + return new ToolResult(persisted); + } + + return new ToolResult(result.Content); + } + catch (McpAuthException) + { + // 标记服务器需要认证 + throw; + } + } + + public JsonElement GetInputSchema() => _definition.InputSchema; + public bool IsReadOnly(JsonElement input) => !_definition.HasDestructiveBehavior; + public bool IsConcurrencySafe(JsonElement input) => false; +} +``` + +## 10.7 McpAuthService + +```csharp +/// +/// MCP OAuth 认证 — 对应原始 auth.ts 中的 ClaudeAuthProvider +/// +public sealed class McpAuthService +{ + private readonly IdentityModel.OidcClient _oidcClient; + private readonly ISecureTokenStorage _tokenStorage; // macOS Keychain / credential manager + + /// + /// 执行 OAuth 授权流程: 发现 → 浏览器授权 → code 交换 → token 存储 + /// + public async Task AuthorizeAsync( + string serverName, McpOAuthConfig config, CancellationToken ct) + { + // 1. 检查缓存的 token + var cached = await _tokenStorage.GetAsync($"mcp-{serverName}"); + if (cached != null && !cached.IsExpired) + return cached; + + // 2. 发现授权服务器元数据 + var metadata = await DiscoverAuthorizationServerAsync(config.AuthServerMetadataUrl!, ct); + + // 3. 启动本地 HTTP 监听器 + var callbackPort = config.CallbackPort ?? GetAvailablePort(); + using var listener = new HttpListener(); + listener.Prefixes.Add($"http://localhost:{callbackPort}/"); + listener.Start(); + + // 4. 构建授权 URL → 打开浏览器 + var authUrl = BuildAuthorizationUrl(metadata, callbackPort); + OpenBrowser(authUrl); + + // 5. 等待回调 → 提取 code + var context = await listener.GetContextAsync(); + var code = context.Request.QueryString["code"]; + + // 6. 交换 token + var tokens = await ExchangeCodeAsync(metadata, code, callbackPort, ct); + await _tokenStorage.SetAsync($"mcp-{serverName}", tokens); + + return tokens; + } +} +``` diff --git a/docs/基础设施设计/基础设施设计-后台任务管理.md b/docs/基础设施设计/基础设施设计-后台任务管理.md new file mode 100644 index 0000000..264543f --- /dev/null +++ b/docs/基础设施设计/基础设施设计-后台任务管理.md @@ -0,0 +1,321 @@ +# 基础设施设计 — 后台任务管理 + +## 文档元数据 +- 项目名称: free-code +- 文档类型: 基础设施设计 +- 原始代码来源: `../../src/tasks/`(10个文件) +- 原始设计意图: 将 Shell、Agent、远程会话、工作流、监控与记忆合并任务统一纳入后台调度和状态同步框架 +- 交叉引用: [基础设施设计总览](基础设施设计.md) | [服务子系统设计-会话记忆](../服务子系统设计/服务子系统设计-会话记忆与上下文.md) + +## 设计目标 +后台任务管理层统一承载本地 shell、agent、远程 agent、监控与工作流型任务,并保证可调度、可观测与可取消。它必须和全局状态存储、查询引擎、桥接层协同工作,且在宿主关闭时能优雅终止。 + +## 13.1 BackgroundTask 任务层次 + +```csharp +/// +/// 后台任务基类 — 所有任务类型的公共抽象 +/// 对应原始 types.ts 中的 TaskState 联合类型 +/// +public abstract record BackgroundTask +{ + public required string TaskId { get; init; } + public abstract BackgroundTaskType TaskType { get; } + public TaskStatus Status { get; set; } = TaskStatus.Pending; + public DateTime? StartedAt { get; set; } + public DateTime? CompletedAt { get; set; } + public string? ErrorMessage { get; set; } + public bool IsBackgrounded { get; set; } = true; +} + +public enum BackgroundTaskType +{ + LocalShell, + LocalAgent, + RemoteAgent, + InProcessTeammate, + LocalWorkflow, + MonitorMcp, + Dream +} + +public enum TaskStatus { Pending, Running, Completed, Failed, Stopped } + +// === 具体任务类型 === + +/// +/// 本地 Shell 任务 — 后台 bash 命令 +/// 对应原始 LocalShellTask +/// +public sealed record LocalShellTask : BackgroundTask +{ + public override BackgroundTaskType TaskType => BackgroundTaskType.LocalShell; + public required string Command { get; init; } + public ProcessStartInfo? ProcessStartInfo { get; init; } + public string? Stdout { get; set; } + public string? Stderr { get; set; } + public int? ExitCode { get; set; } +} + +/// +/// 本地 Agent 任务 — 子代理(forked process 或 worktree 隔离) +/// 对应原始 LocalAgentTask +/// +public sealed record LocalAgentTask : BackgroundTask +{ + public override BackgroundTaskType TaskType => BackgroundTaskType.LocalAgent; + public required string Prompt { get; init; } + public string? Model { get; init; } + public string? AgentType { get; init; } // explore, librarian, oracle, metis, momus + public string? WorkingDirectory { get; init; } + public List Messages { get; } = new(); +} + +/// +/// 远程 Agent 任务 — claude.ai 上的远程会话 +/// 对应原始 RemoteAgentTask +/// +public sealed record RemoteAgentTask : BackgroundTask +{ + public override BackgroundTaskType TaskType => BackgroundTaskType.RemoteAgent; + public required string SessionUrl { get; init; } + public string? Plan { get; set; } + public string? Status { get; set; } +} + +/// +/// 进程内 Teammate 任务 — 协作代理 +/// 对应原始 InProcessTeammateTask +/// +public sealed record InProcessTeammateTask : BackgroundTask +{ + public override BackgroundTaskType TaskType => BackgroundTaskType.InProcessTeammate; + public required string AgentName { get; init; } + public string? AgentType { get; init; } + public string? Color { get; init; } + public required string WorkingDirectory { get; init; } +} + +/// +/// 本地工作流任务 — 多步骤工作流 +/// 对应原始 LocalWorkflowTask +/// +public sealed record LocalWorkflowTask : BackgroundTask +{ + public override BackgroundTaskType TaskType => BackgroundTaskType.LocalWorkflow; + public required string WorkflowName { get; init; } + public required List Steps { get; init; } + public int CurrentStepIndex { get; set; } +} + +/// +/// MCP SSE 监控任务 — 监控 MCP 服务器连接状态 +/// 对应原始 MonitorMcpTask +/// +public sealed record MonitorMcpTask : BackgroundTask +{ + public override BackgroundTaskType TaskType => BackgroundTaskType.MonitorMcp; + public required string ServerName { get; init; } + public int ReconnectAttempt { get; set; } +} + +/// +/// Dream 任务 — 后台记忆合并 +/// 对应原始 DreamTask +/// +public sealed record DreamTask : BackgroundTask +{ + public override BackgroundTaskType TaskType => BackgroundTaskType.Dream; + public required string TriggerReason { get; init; } // time | session_count +} +``` + +## 13.2 IBackgroundTaskManager 接口 + +```csharp +/// +/// 后台任务管理器 — Channel-based 任务调度 +/// 对应原始 task 工具 + 后台任务 UI 指示器 +/// +public interface IBackgroundTaskManager +{ + /// 创建 Shell 任务 + Task CreateShellTaskAsync(string command, ProcessStartInfo psi); + + /// 创建 Agent 任务 + Task CreateAgentTaskAsync(string prompt, string? agentType, string? model); + + /// 创建远程 Agent 任务 + Task CreateRemoteAgentTaskAsync(string sessionUrl); + + /// 创建 Dream 任务 + Task CreateDreamTaskAsync(string triggerReason); + + /// 停止任务 + Task StopTaskAsync(string taskId); + + /// 获取任务输出 + Task GetTaskOutputAsync(string taskId); + + /// 列出所有任务 + IReadOnlyList ListTasks(); + + /// 获取指定任务 + BackgroundTask? GetTask(string taskId); + + /// 任务状态变更事件 + event EventHandler? TaskStateChanged; +} +``` + +## 13.3 BackgroundTaskManager 实现 + +```csharp +public class BackgroundTaskManager : IBackgroundTaskManager, IHostedService +{ + private readonly Channel _taskChannel = Channel.CreateUnbounded(); + private readonly ConcurrentDictionary _tasks = new(); + private readonly IAppStateStore _stateStore; + private readonly IServiceProvider _services; + private readonly ILogger _logger; + private readonly CancellationTokenSource _shutdownCts = new(); + + public event EventHandler? TaskStateChanged; + + public async Task StartAsync(CancellationToken ct) + { + // 启动任务调度循环 + _ = Task.Run(() => DispatchLoopAsync(_shutdownCts.Token), _shutdownCts.Token); + await Task.CompletedTask; + } + + /// + /// 任务调度循环 — 从 channel 读取任务并执行 + /// + private async Task DispatchLoopAsync(CancellationToken ct) + { + await foreach (var task in _taskChannel.Reader.ReadAllAsync(ct)) + { + _ = Task.Run(async () => + { + task.Status = TaskStatus.Running; + task.StartedAt = DateTime.UtcNow; + UpdateState(); + TaskStateChanged?.Invoke(this, new(task.TaskId, task.Status)); + + try + { + await ExecuteTaskAsync(task, ct); + task.Status = TaskStatus.Completed; + } + catch (OperationCanceledException) + { + task.Status = TaskStatus.Stopped; + } + catch (Exception ex) + { + task.Status = TaskStatus.Failed; + task.ErrorMessage = ex.Message; + _logger.LogError(ex, "Task {TaskId} failed", task.TaskId); + } + finally + { + task.CompletedAt = DateTime.UtcNow; + UpdateState(); + TaskStateChanged?.Invoke(this, new(task.TaskId, task.Status)); + } + }, ct); + } + } + + private Task ExecuteTaskAsync(BackgroundTask task, CancellationToken ct) => task switch + { + LocalShellTask shell => ExecuteShellTaskAsync(shell, ct), + LocalAgentTask agent => ExecuteAgentTaskAsync(agent, ct), + RemoteAgentTask remote => ExecuteRemoteAgentTaskAsync(remote, ct), + DreamTask dream => ExecuteDreamTaskAsync(dream, ct), + MonitorMcpTask monitor => ExecuteMonitorTaskAsync(monitor, ct), + LocalWorkflowTask workflow => ExecuteWorkflowTaskAsync(workflow, ct), + InProcessTeammateTask teammate => ExecuteTeammateTaskAsync(teammate, ct), + _ => Task.CompletedTask + }; + + private async Task ExecuteShellTaskAsync(LocalShellTask task, CancellationToken ct) + { + using var process = new Process { StartInfo = task.ProcessStartInfo! }; + process.Start(); + + var stdout = await process.StandardOutput.ReadToEndAsync(ct); + var stderr = await process.StandardError.ReadToEndAsync(ct); + await process.WaitForExitAsync(ct); + + task.Stdout = stdout; + task.Stderr = stderr; + task.ExitCode = process.ExitCode; + } + + private async Task ExecuteAgentTaskAsync(LocalAgentTask task, CancellationToken ct) + { + // 使用 QueryEngine 在隔离 context 中执行子代理查询 + var engine = _services.GetRequiredService(); + await foreach (var msg in engine.SubmitMessageAsync(task.Prompt, + new(Model: task.Model), ct)) + { + // 收集消息到 task.Messages + } + } + + private async Task ExecuteDreamTaskAsync(DreamTask task, CancellationToken ct) + { + var dreamService = _services.GetRequiredService(); + await dreamService.RunDreamCycleAsync(ct); + } + + public Task CreateShellTaskAsync(string command, ProcessStartInfo psi) + { + var task = new LocalShellTask + { + TaskId = Guid.NewGuid().ToString("N")[..8], + Command = command, + ProcessStartInfo = psi, + }; + _tasks[task.TaskId] = task; + _taskChannel.Writer.TryWrite(task); + UpdateState(); + return Task.FromResult(task); + } + + public Task StopTaskAsync(string taskId) + { + if (_tasks.TryGetValue(taskId, out var task) && task.Status == TaskStatus.Running) + { + task.Status = TaskStatus.Stopped; + // 进程级取消由具体执行器处理 + } + return Task.CompletedTask; + } + + public IReadOnlyList ListTasks() => _tasks.Values.ToList(); + public BackgroundTask? GetTask(string taskId) => _tasks.GetValueOrDefault(taskId); + + /// 更新 AppState 中的任务状态 + private void UpdateState() + { + _stateStore.Update(state => + { + var tasks = state.Tasks.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + foreach (var task in _tasks.Values) + tasks[task.TaskId] = task; + return state with { Tasks = tasks }; + }); + } + + public async Task StopAsync(CancellationToken ct) + { + _shutdownCts.Cancel(); + _taskChannel.Writer.TryComplete(); + foreach (var task in _tasks.Values.Where(t => t.Status == TaskStatus.Running)) + await StopTaskAsync(task.TaskId); + } +} +``` diff --git a/docs/基础设施设计/基础设施设计-状态管理.md b/docs/基础设施设计/基础设施设计-状态管理.md new file mode 100644 index 0000000..a902edf --- /dev/null +++ b/docs/基础设施设计/基础设施设计-状态管理.md @@ -0,0 +1,253 @@ +# 基础设施设计 — 状态管理 + +## 文档元数据 +- 项目名称: free-code +- 文档类型: 基础设施设计 +- 原始代码来源: `../../src/state/`(5个文件) +- 原始设计意图: 用不可变全局状态串联 MCP、插件、通知、后台任务、桥接与推理状态,并通过事件驱动方式向 UI 广播更新 +- 交叉引用: [基础设施设计总览](基础设施设计.md) | [核心模块设计-查询引擎](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md) + +## 设计目标 +状态管理层负责承载应用运行态、MCP 状态、插件状态、通知状态与推理/猜测状态,并通过不可变更新维持可预测性。它是基础设施层的共享事实来源,供 UI、协议层和后台任务共同读取。 + +## 14.1 AppState 不可变 Record + +```csharp +/// +/// 应用全局状态 — 不可变 record +/// 对应原始 AppState 类型 (AppStateStore.ts 中 ~450 行类型定义) +/// +public sealed record AppState +{ + // === 核心状态 === + public SettingsJson Settings { get; init; } = new(); + public bool Verbose { get; init; } + public ModelSetting MainLoopModel { get; init; } + public ModelSetting MainLoopModelForSession { get; init; } + public string? StatusLineText { get; init; } + public ExpandedView ExpandedView { get; init; } = ExpandedView.None; + public bool IsBriefOnly { get; init; } + + // === 权限 === + public ToolPermissionContext ToolPermissionContext { get; init; } = new(); + public string? Agent { get; init; } + public bool KairosEnabled { get; init; } + + // === UI 状态 === + public int SelectedIPAgentIndex { get; init; } = -1; + public int CoordinatorTaskIndex { get; init; } = -1; + public ViewSelectionMode ViewSelectionMode { get; init; } = ViewSelectionMode.None; + public FooterItem? FooterSelection { get; init; } + + // === 后台任务 === + public IReadOnlyDictionary Tasks { get; init; } = new Dictionary(); + public string? ForegroundedTaskId { get; init; } + public string? ViewingAgentTaskId { get; init; } + public IReadOnlyDictionary AgentNameRegistry { get; init; } = new Dictionary(); + + // === MCP === + public McpState Mcp { get; init; } = new(); + + // === 插件 === + public PluginState Plugins { get; init; } = new(); + + // === 远程/桥接 === + public string? RemoteSessionUrl { get; init; } + public RemoteConnectionStatus RemoteConnectionStatus { get; init; } = RemoteConnectionStatus.Connecting; + public int RemoteBackgroundTaskCount { get; init; } + + // === 桥接 === + public bool ReplBridgeEnabled { get; init; } + public bool ReplBridgeExplicit { get; init; } + public bool ReplBridgeOutboundOnly { get; init; } + public bool ReplBridgeConnected { get; init; } + public bool ReplBridgeSessionActive { get; init; } + public bool ReplBridgeReconnecting { get; init; } + public string? ReplBridgeConnectUrl { get; init; } + public string? ReplBridgeSessionUrl { get; init; } + public string? ReplBridgeEnvironmentId { get; init; } + public string? ReplBridgeSessionId { get; init; } + public string? ReplBridgeError { get; init; } + public string? ReplBridgeInitialName { get; init; } + public bool ShowRemoteCallout { get; init; } + + // === 同伴 === + public string? CompanionReaction { get; init; } + public long? CompanionPetAt { get; init; } + + // === 通知 === + public NotificationState Notifications { get; init; } = new(); + + // === 记忆 === + public AgentDefinitionsResult AgentDefinitions { get; init; } = new(); + + // === 文件历史 === + public FileHistoryState FileHistory { get; init; } = new(); + + // === 归属 === + public AttributionState Attribution { get; init; } = new(); + + // === TODO === + public IReadOnlyDictionary Todos { get; init; } = new Dictionary(); + + // === 推测 === + public SpeculationState Speculation { get; init; } = SpeculationState.Idle; + public long SpeculationSessionTimeSavedMs { get; init; } + + // === 其他 === + public bool? ThinkingEnabled { get; init; } + public bool PromptSuggestionEnabled { get; init; } + public EffortValue? EffortValue { get; init; } + public bool FastMode { get; init; } + public int AuthVersion { get; init; } + public IReadOnlySet ActiveOverlays { get; init; } = new HashSet(); +} + +// === 嵌套状态类型 === + +public sealed record McpState +{ + public IReadOnlyList Clients { get; init; } = []; + public IReadOnlyList Tools { get; init; } = []; + public IReadOnlyList Commands { get; init; } = []; + public IReadOnlyDictionary> Resources { get; init; } = new Dictionary>(); + public int PluginReconnectKey { get; init; } +} + +public sealed record PluginState +{ + public IReadOnlyList Enabled { get; init; } = []; + public IReadOnlyList Disabled { get; init; } = []; + public IReadOnlyList Commands { get; init; } = []; + public IReadOnlyList Errors { get; init; } = []; + public PluginInstallationStatus InstallationStatus { get; init; } = new(); + public bool NeedsRefresh { get; init; } +} + +public sealed record NotificationState +{ + public Notification? Current { get; init; } + public IReadOnlyList Queue { get; init; } = []; +} + +public record SpeculationState +{ + public static readonly SpeculationState Idle = new() { Status = "idle" }; + public string Status { get; init; } = "idle"; +} +``` + +## 14.2 IAppStateStore 接口与 AppStateStore 实现 + +```csharp +/// +/// 应用状态存储 — 事件驱动不可变状态管理 +/// 对应原始 Store + AppStateStore +/// 模式: Redux/Elm 风格 (updater 函数 + 变更通知) +/// +public interface IAppStateStore +{ + /// 获取当前状态(不可变快照) + AppState GetState(); + + /// 更新状态(通过 updater 函数) + void Update(Func updater); + + /// 订阅状态变更 + IDisposable Subscribe(Action listener); + + /// 状态变更事件 (C# event 模式) + event EventHandler? StateChanged; +} + +public sealed class AppStateStore : IAppStateStore +{ + private AppState _state; + private readonly object _lock = new(); + private readonly List> _listeners = new(); + private readonly Action? _onChangeCallback; + + public event EventHandler? StateChanged; + + public AppStateStore() + { + _state = CreateDefaultState(); + } + + public AppState GetState() + { + lock (_lock) return _state; + } + + public void Update(Func updater) + { + AppState prev, next; + lock (_lock) + { + prev = _state; + next = updater(prev); + if (ReferenceEquals(next, prev)) return; // 无变更 + _state = next; + } + + // 通知变更 + _onChangeCallback?.Invoke(next, prev); + StateChanged?.Invoke(this, new StateChangedEventArgs(prev, next)); + + // 通知订阅者 + foreach (var listener in _listeners.ToArray()) + listener(next); + } + + public IDisposable Subscribe(Action listener) + { + lock (_lock) _listeners.Add(listener); + return new Subscription(() => + { + lock (_lock) _listeners.Remove(listener); + }); + } + + private static AppState CreateDefaultState() => new AppState(); + + private sealed class Subscription : IDisposable + { + private readonly Action _unsubscribe; + public Subscription(Action unsubscribe) => _unsubscribe = unsubscribe; + public void Dispose() => _unsubscribe(); + } +} + +public sealed record StateChangedEventArgs(AppState OldState, AppState NewState); +``` + +## 14.3 StateSelectors + +```csharp +/// +/// 状态选择器 — 对应原始 selectors.ts +/// 从 AppState 中派生计算值,避免在组件中重复逻辑 +/// +public static class StateSelectors +{ + public static bool IsBridgeReady(this AppState state) => + state.ReplBridgeEnabled && state.ReplBridgeConnected; + + public static bool IsBridgeConnected(this AppState state) => + state.ReplBridgeEnabled && state.ReplBridgeSessionActive; + + public static IReadOnlyList GetBackgroundTasks(this AppState state) => + state.Tasks.Values.Where(t => t.Status is TaskStatus.Running or TaskStatus.Pending) + .Where(t => t.IsBackgrounded) + .ToList(); + + public static bool HasActiveMcpServers(this AppState state) => + state.Mcp.Clients.Any(c => c.IsConnected); + + public static int GetActiveSessionCount(this AppState state) => + state.Tasks.Values.Count(t => t.Status == TaskStatus.Running); + + public static string? GetActiveModel(this AppState state) => + state.MainLoopModelForSession ?? state.MainLoopModel; +} +``` diff --git a/docs/基础设施设计/基础设施设计.md b/docs/基础设施设计/基础设施设计.md new file mode 100644 index 0000000..e19b6a2 --- /dev/null +++ b/docs/基础设施设计/基础设施设计.md @@ -0,0 +1,83 @@ +# 基础设施设计 + +> **所属项目**: free-code .NET 10 重写 +> **文档类型**: 模块索引 +> **对应源码**: `../../src/services/mcp/`, `../../src/services/lsp/`, `../../src/bridge/`, `../../src/tasks/`, `../../src/state/` +> **配套文档**: [总体概述](../总体概述与技术选型/总体概述与技术选型.md) | [核心模块设计](../核心模块设计/核心模块设计.md) + +--- + +## 概述 + +基础设施层负责支撑整个 CLI 运行时的底层能力,包括外部协议对接、IDE 通信、任务调度和全局状态管理。这一层不直接实现业务逻辑,而是为核心模块提供可靠的基础服务。 + +原始 TypeScript 实现分散在多个目录中,总计约 76 个文件。.NET 10 重写将其整理为五个职责清晰的子模块,每个子模块通过接口向上层暴露能力,内部实现细节完全封装。 + +--- + +## 子模块列表 + +### [MCP 协议集成](基础设施设计-MCP协议集成.md) + +管理多个 MCP 服务器连接,实现 JSON-RPC 2.0 传输层(Stdio、SSE、Streamable HTTP、WebSocket、进程内),并将 MCP 工具适配为统一的 `ITool` 接口供工具系统使用。 + +- 原始源码: `../../src/services/mcp/`(22 个文件) +- 核心类型: `IMcpClientManager`、`MCPServerConnection`、`McpClient`、`McpAuthService` + +--- + +### [LSP 集成](基础设施设计-LSP集成.md) + +管理语言服务器协议(LSP)子进程的生命周期,提供跳转定义、查找引用、悬停提示、重命名、诊断等 9 种标准 LSP 操作,以及诊断基线对比能力。 + +- 原始源码: `../../src/services/lsp/`(7 个文件) +- 核心类型: `ILspClientManager`、`LspServerInstance`、`LspDiagnosticRegistry` + +--- + +### [IDE 桥接](基础设施设计-IDE桥接.md) + +实现 claude.ai 远程控制能力,通过轮询 API 获取工作项、生成隔离的会话目录(git worktree),并管理会话生命周期。支持 VS Code、JetBrains 等 IDE 的 MCP over SSE/WebSocket 接入。 + +- 原始源码: `../../src/bridge/`(32 个文件) +- 核心类型: `IBridgeService`、`BridgeConfig`、`SpawnMode`、`IBridgeApiClient` + +--- + +### [后台任务管理](基础设施设计-后台任务管理.md) + +基于 `Channel` 的任务调度系统,支持 7 种任务类型(Shell、本地代理、远程代理、进程内协作代理、工作流、MCP 监控、Dream 记忆合并),作为 `IHostedService` 随应用生命周期启停。 + +- 原始源码: `../../src/tasks/`(10 个文件) +- 核心类型: `BackgroundTask`、`IBackgroundTaskManager`、`BackgroundTaskManager` + +--- + +### [状态管理](基础设施设计-状态管理.md) + +应用全局不可变状态存储,采用 Redux/Elm 风格的 updater 函数模式。`AppState` 是一个深层嵌套的不可变 record,`AppStateStore` 通过加锁保证线程安全,并在每次更新后触发订阅通知。 + +- 原始源码: `../../src/state/`(5 个文件) +- 核心类型: `AppState`、`IAppStateStore`、`AppStateStore`、`StateSelectors` + +--- + +## 依赖关系 + +``` +核心模块(工具系统、查询引擎) + ↓ 使用 +基础设施层 + ├── MCP 协议集成 ──→ 状态管理(更新 McpState) + ├── LSP 集成 ──→ 状态管理(无直接依赖,通过工具层间接访问) + ├── IDE 桥接 ──→ 状态管理(更新桥接状态字段) + └── 后台任务管理 ──→ 状态管理(更新 Tasks 字典) +``` + +所有子模块都通过依赖注入接收 `IAppStateStore`,并在状态变更时调用 `Update()` 方法。UI 层订阅 `StateChanged` 事件实现响应式刷新。 + +--- + +## 原始代码映射 + +完整的 .NET 类型与原始 TypeScript 文件对应关系,参见 [reference/原始代码映射-基础设施.md](reference/原始代码映射-基础设施.md)。 diff --git a/docs/总体概述与技术选型/reference/.NET-10-平台介绍.md b/docs/总体概述与技术选型/reference/.NET-10-平台介绍.md new file mode 100644 index 0000000..fc67262 --- /dev/null +++ b/docs/总体概述与技术选型/reference/.NET-10-平台介绍.md @@ -0,0 +1,328 @@ +# .NET 10 平台介绍 + +> **文档版本**: 1.0 +> **日期**: 2026-04-05 +> **所属部分**: 第一部分 — 参考文档 +> **单一职责**: 说明 .NET 10 平台中与本项目直接相关的特性,不涉及具体映射细节 + +--- + +## 目录 + +- [1. AOT 编译与单文件发布](#1-aot-编译与单文件发布) +- [2. C# 13 语言特性](#2-c-13-语言特性) +- [3. System.Text.Json 与 AOT 友好序列化](#3-systemtextjson-与-aot-友好序列化) +- [4. Microsoft.Extensions.Hosting](#4-microsoftextensionshosting) +- [5. AssemblyLoadContext 插件隔离](#5-assemblyloadcontext-插件隔离) +- [6. System.Threading.Channels 异步任务管理](#6-systemthreadingchannels-异步任务管理) +- [7. 关键 NuGet 包清单](#7-关键-nuget-包清单) + +--- + +## 1. AOT 编译与单文件发布 + +### 设计意图 + +原始项目通过 Bun 将所有 TypeScript 打包为单一可执行二进制,做到零依赖安装。.NET 10 的 Native AOT(Ahead-of-Time)编译提供了对等的能力,并在启动速度和内存占用上更有优势。 + +### PublishSingleFile + +`PublishSingleFile` 将运行时和应用代码打包为一个文件,发布时无需 .NET 运行时预装。 + +```xml + + + true + true + true + osx-arm64;osx-x64;linux-x64;linux-arm64;win-x64 + +``` + +### Native AOT 的约束 + +AOT 编译会在发布时静态分析所有代码路径,任何运行时反射都可能被裁剪(trim)。这意味着: + +- `System.Text.Json` 必须使用 Source Generator 模式,不能依赖运行时反射序列化 +- 插件系统无法直接 AOT,主程序集使用 AOT,插件程序集在 `AssemblyLoadContext` 中通过 JIT 加载 +- 需要为 `[DynamicallyAccessedMembers]` 标注所有通过反射访问的类型 + +### 启动性能对比 + +| 方式 | 冷启动时间(参考值) | +|------|-----------------| +| Bun 打包 JS | ~80ms | +| .NET 10 AOT | ~15-30ms | +| .NET 10 JIT(无 AOT) | ~150-300ms | + +--- + +## 2. C# 13 语言特性 + +### 设计意图 + +TypeScript 的类型系统依赖接口、联合类型和 Zod 运行时校验。C# 13 提供了更强的编译时保证,用 record 类型替代不可变 DTO,用模式匹配替代联合类型的分支处理。 + +### Record 类型 + +Record 类型天然不可变,自动生成 `Equals`、`GetHashCode`、`ToString` 和解构支持,适合表达状态快照和配置对象。 + +```csharp +// 不可变配置模型 +public record AppSettings( + string Model, + string Theme, + bool VerboseMode, + IReadOnlyList AllowedDirectories +); + +// 带 required 修饰符的 init-only 属性 +public record FileReadInput +{ + public required string FilePath { get; init; } + public int? Offset { get; init; } + public int? Limit { get; init; } +} +``` + +### 模式匹配 + +C# 13 的模式匹配可以替代 TypeScript 中对联合类型的 `switch` + `instanceof` 处理。 + +```csharp +// 替代 TS 的 if (msg.type === 'tool_use') { ... } +var result = message switch +{ + ToolUseMessage toolUse => HandleToolUse(toolUse), + AssistantMessage assistant => HandleAssistant(assistant), + ErrorMessage { Code: >= 500 } serverError => HandleServerError(serverError), + _ => HandleUnknown(message) +}; +``` + +### required init 属性 + +`required` 关键字在编译时强制调用方必须初始化某个属性,比构造函数参数更灵活,比 Nullable 警告更明确。 + +```csharp +public class ToolExecutionContext +{ + public required string SessionId { get; init; } + public required IPermissionEngine PermissionEngine { get; init; } + public CancellationToken CancellationToken { get; init; } +} +``` + +--- + +## 3. System.Text.Json 与 AOT 友好序列化 + +### 设计意图 + +原始项目直接使用 `JSON.parse`/`JSON.stringify`,无类型安全。`System.Text.Json` 提供高性能序列化,配合 Source Generator 完全绕过运行时反射,AOT 环境下也可正常工作。 + +### Source Generator 模式 + +通过 `[JsonSerializable]` 特性,编译器在构建时生成所有序列化代码,不依赖运行时反射。 + +```csharp +// 声明需要序列化的类型 +[JsonSerializable(typeof(ApiRequest))] +[JsonSerializable(typeof(ApiResponse))] +[JsonSerializable(typeof(McpToolCall))] +[JsonSerializable(typeof(List))] +internal partial class AppJsonContext : JsonSerializerContext { } + +// 使用时传入 context +var json = JsonSerializer.Serialize(request, AppJsonContext.Default.ApiRequest); +var response = JsonSerializer.Deserialize(json, AppJsonContext.Default.ApiResponse); +``` + +### 流式 JSON 处理 + +MCP 和 API 响应都基于流式 JSON,使用 `Utf8JsonReader` 可以零拷贝解析,不需要先将整个响应体读入内存。 + +```csharp +// SSE 流式解析示例 +await foreach (var line in sseReader.ReadLinesAsync(ct)) +{ + if (!line.StartsWith("data: ")) continue; + var data = line["data: ".Length..]; + if (data == "[DONE]") break; + + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(data)); + var chunk = JsonSerializer.Deserialize(ref reader, AppJsonContext.Default.StreamChunk); + yield return chunk; +} +``` + +--- + +## 4. Microsoft.Extensions.Hosting + +### 设计意图 + +原始项目没有显式的 DI 容器,各模块通过模块级变量和闭包共享状态。.NET 的 `Microsoft.Extensions.Hosting` 提供了标准化的应用生命周期管理,包括 DI 注册、配置系统和 `BackgroundService`。 + +### Host 构建 + +```csharp +var host = Host.CreateDefaultBuilder(args) + .ConfigureAppConfiguration((ctx, cfg) => + { + cfg.AddJsonFile("appsettings.json", optional: true); + cfg.AddEnvironmentVariables("FREECODE_"); + cfg.AddCommandLine(args); + }) + .ConfigureServices((ctx, services) => + { + services.AddCoreServices() + .AddEngine() + .AddTools() + .AddCommands() + .AddApiProviders() + .AddMcp() + .AddLsp(); + }) + .ConfigureLogging(logging => + { + logging.AddConsole(); + logging.SetMinimumLevel(LogLevel.Warning); // 终端应用默认不打印 Info 日志 + }) + .Build(); +``` + +### BackgroundService + +`BackgroundService` 是 .NET 中运行后台循环任务的标准模式,替代原始项目中的自定义异步循环。 + +```csharp +public class TaskSchedulerService : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await foreach (var task in _taskChannel.Reader.ReadAllAsync(stoppingToken)) + { + _ = Task.Run(() => ExecuteTaskAsync(task, stoppingToken), stoppingToken); + } + } +} +``` + +--- + +## 5. AssemblyLoadContext 插件隔离 + +### 设计意图 + +原始项目使用 `动态 import()` 加载插件,JavaScript 的模块系统天然支持按需加载。.NET 中通过 `AssemblyLoadContext` 实现对等的运行时程序集隔离,并支持卸载(collectible context)。 + +### 基本用法 + +```csharp +public class PluginLoadContext : AssemblyLoadContext +{ + private readonly AssemblyDependencyResolver _resolver; + + public PluginLoadContext(string pluginPath) + : base(isCollectible: true) // 可卸载 + { + _resolver = new AssemblyDependencyResolver(pluginPath); + } + + protected override Assembly? Load(AssemblyName assemblyName) + { + // 优先从插件目录解析,避免与主程序集冲突 + var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName); + return assemblyPath != null ? LoadFromAssemblyPath(assemblyPath) : null; + } +} +``` + +### 卸载流程 + +```csharp +// 加载插件 +var context = new PluginLoadContext(pluginPath); +var assembly = context.LoadFromAssemblyPath(pluginPath); +var plugin = (IPlugin)Activator.CreateInstance(assembly.GetType("MyPlugin")!)!; + +// 卸载插件(释放所有强引用后 GC 自动回收) +context.Unload(); +``` + +### AOT 注意事项 + +主程序集使用 AOT 编译,但插件程序集在运行时通过 `AssemblyLoadContext` 以 JIT 模式加载。这是混合部署模式:主入口 AOT 启动快、内存小,插件保持 JIT 的灵活性。 + +--- + +## 6. System.Threading.Channels 异步任务管理 + +### 设计意图 + +原始项目的后台任务通过自定义队列和 Promise 链管理,存在竞态风险。`System.Threading.Channels` 是 .NET 内建的高性能生产者-消费者通道,线程安全,背压(backpressure)支持完善。 + +### Channel 基本模式 + +```csharp +// 创建无界通道 +var channel = Channel.CreateUnbounded(new UnboundedChannelOptions +{ + SingleWriter = false, + SingleReader = true // 单消费者简化并发模型 +}); + +// 生产者(任意线程) +await channel.Writer.WriteAsync(new LocalShellTask { Command = "npm test" }, ct); + +// 消费者(BackgroundService 中) +await foreach (var task in channel.Reader.ReadAllAsync(ct)) +{ + await ProcessTaskAsync(task, ct); +} +``` + +### 有界通道与背压 + +```csharp +// 有界通道:满了就等待(背压) +var bounded = Channel.CreateBounded(new BoundedChannelOptions(capacity: 100) +{ + FullMode = BoundedChannelFullMode.Wait +}); +``` + +--- + +## 7. 关键 NuGet 包清单 + +以下是本项目直接依赖的 NuGet 包,版本号为设计时目标版本。 + +| 包名 | 版本 | 用途 | +|------|------|------| +| `Terminal.Gui` | 2.* | 终端 TUI 框架,替代 React+Ink | +| `Spectre.Console` | 0.* | 富文本输出:表格、进度条、面板 | +| `System.CommandLine` | 2.* | CLI 参数解析,替代 Commander.js | +| `FluentValidation` | 12.* | POCO 对象校验,替代 Zod | +| `JsonSchema.Net` | 7.* | JSON Schema 生成与校验 | +| `Refit` | 8.* | 强类型 HTTP 客户端 | +| `Polly` | 8.* | 重试、熔断、超时策略 | +| `StreamJsonRpc` | 2.* | JSON-RPC 2.0 实现,用于 MCP 协议 | +| `OmniSharp.Extensions.LanguageServer.Client` | 0.* | LSP 客户端 | +| `Grpc.Net.Client` | 2.* | gRPC 客户端(部分 API 提供商) | +| `Microsoft.Identity.Client` (MSAL) | 4.* | OAuth/OIDC 认证 | +| `IdentityModel.OidcClient` | 6.* | OIDC 客户端流程 | +| `AWSSDK.BedrockRuntime` | 4.* | AWS Bedrock 流式 API | +| `Google.Apis.Auth` | 1.* | Google Cloud 认证(Vertex AI) | +| `Microsoft.AspNetCore.SignalR.Client` | 10.* | WebSocket 高层抽象 | +| `Markdig` | 0.* | Markdown 渲染 | +| `DiffPlex` | 1.* | diff 计算与展示 | +| `SixLabors.ImageSharp` | 3.* | 图片处理 | +| `PdfPig` | 0.* | PDF 文本提取 | +| `YamlDotNet` | 16.* | YAML 解析 | +| `Microsoft.Extensions.Hosting` | 10.* | DI + 生命周期管理 | +| `System.Threading.Channels` | 10.* | 生产者-消费者通道 | +| `xunit` | 2.* | 单元测试框架 | +| `NSubstitute` | 5.* | Mock 框架 | +| `FluentAssertions` | 7.* | 断言库 | diff --git a/docs/总体概述与技术选型/reference/mcp-sdk-implement.md b/docs/总体概述与技术选型/reference/mcp-sdk-implement.md new file mode 100644 index 0000000..ac4b92a --- /dev/null +++ b/docs/总体概述与技术选型/reference/mcp-sdk-implement.md @@ -0,0 +1,346 @@ +# MCP SDK 自研实现方案 + +> 关联文档:[总体概述](../总体概述与技术选型.md) | [MCP协议集成](../../基础设施设计/基础设施设计-MCP协议集成.md) + +--- + +## 1. 为什么自研 + +社区目前没有成熟的 .NET MCP SDK。检索 NuGet.org 和相关 GitHub 仓库后,现状如下: + +- 微软官方尚未发布 `Microsoft.Extensions.Mcp` 正式包(截至 2026 年初仍在 preview 阶段,API 不稳定) +- 社区的几个实验性实现覆盖场景有限,停留在 MCP 协议的早期版本,不支持 Streamable HTTP Transport +- 没有任何包支持原生 AOT 编译,大量依赖运行时反射 + +自研的核心收益: +1. **协议版本精确对齐**,与 TypeScript SDK 的行为保持一致,减少互操作问题 +2. **传输层可插拔**,在测试环境使用 InProcessTransport,生产环境使用 StdioTransport 或 SseTransport +3. **AOT 友好**,所有序列化走 `JsonSerializerContext`,无运行时反射 +4. **依赖最小化**,不引入不受控的第三方包,安全审查更简单 + +--- + +## 2. JSON-RPC 2.0 协议基础 + +MCP 基于 JSON-RPC 2.0 协议。三种消息类型构成所有交互: + +**请求 (Request)** + +客户端发起,期待服务端响应。必须携带 `id`,方法名决定语义: + +```json +{ + "jsonrpc": "2.0", + "id": "req-001", + "method": "tools/call", + "params": { + "name": "read_file", + "arguments": { "path": "/tmp/foo.txt" } + } +} +``` + +**响应 (Response)** + +服务端对请求的回复,`id` 必须与请求一致。成功用 `result`,失败用 `error`: + +```json +{ + "jsonrpc": "2.0", + "id": "req-001", + "result": { + "content": [{ "type": "text", "text": "file contents here" }] + } +} +``` + +**通知 (Notification)** + +单向消息,不携带 `id`,无需响应: + +```json +{ + "jsonrpc": "2.0", + "method": "notifications/tools/list_changed" +} +``` + +对应的 C# 模型: + +```csharp +public record JsonRpcRequest( + string JsonRpc, + string Id, + string Method, + JsonElement? Params +); + +public record JsonRpcResponse( + string JsonRpc, + string Id, + JsonElement? Result, + JsonRpcError? Error +); + +public record JsonRpcNotification( + string JsonRpc, + string Method, + JsonElement? Params +); + +public record JsonRpcError(int Code, string Message, JsonElement? Data); +``` + +--- + +## 3. 传输层实现 + +传输层负责消息的发送和接收,与协议逻辑解耦。所有传输层实现同一接口: + +```csharp +public interface ITransport : IAsyncDisposable +{ + Task ReadMessageAsync(CancellationToken ct); + Task SendMessageAsync(JsonRpcMessage message, CancellationToken ct); + Task ConnectAsync(CancellationToken ct); + Task CloseAsync(CancellationToken ct); +} +``` + +### StdioTransport + +通过子进程的 stdin/stdout 通信,是 MCP 最常见的本地工具接入方式: + +```csharp +public sealed class StdioTransport : ITransport +{ + private readonly Process _process; + private readonly Channel _inbound = + Channel.CreateUnbounded(); + + public async Task ConnectAsync(CancellationToken ct) + { + _process.Start(); + // 启动后台读取循环,将 stdout 行解析为 JsonRpcMessage 写入 Channel + _ = Task.Run(() => ReadLoopAsync(ct), ct); + } + + public async Task ReadMessageAsync(CancellationToken ct) + => await _inbound.Reader.ReadAsync(ct); + + public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken ct) + { + var json = JsonSerializer.Serialize(message, FreeCodeJsonContext.Default.JsonRpcMessage); + await _process.StandardInput.WriteLineAsync(json.AsMemory(), ct); + await _process.StandardInput.FlushAsync(ct); + } +} +``` + +每行 JSON 对应一条消息,严格遵循 MCP 规范的 newline-delimited JSON 格式。 + +### SseTransport + +服务端通过 HTTP Server-Sent Events 推送消息,客户端通过 HTTP POST 发送: + +```csharp +public sealed class SseTransport : ITransport +{ + private readonly HttpClient _http; + private readonly Uri _sseEndpoint; + private readonly Uri _postEndpoint; + + public async Task ConnectAsync(CancellationToken ct) + { + var response = await _http.GetAsync( + _sseEndpoint, + HttpCompletionOption.ResponseHeadersRead, + ct); + // 以流方式持续读取 SSE 事件行 + _ = Task.Run(() => ReadSseLoopAsync(response, ct), ct); + } +} +``` + +### StreamableHttpTransport + +MCP 协议 2025-03-26 版本引入的新传输方式,支持单次 HTTP 请求内的双向流式通信: + +```csharp +public sealed class StreamableHttpTransport : ITransport +{ + // 使用 HttpClient 发送请求,响应体作为 NDJSON 流持续读取 + // 请求体也可以是 NDJSON 流(客户端到服务端) + // 通过 Content-Type: application/jsonl 区分 +} +``` + +### WebSocketTransport + +适用于需要长连接和低延迟的场景: + +```csharp +public sealed class WebSocketTransport : ITransport +{ + private readonly ClientWebSocket _ws = new(); + + public async Task ConnectAsync(CancellationToken ct) + { + await _ws.ConnectAsync(_uri, ct); + _ = Task.Run(() => ReceiveLoopAsync(ct), ct); + } + + public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken ct) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes( + message, FreeCodeJsonContext.Default.JsonRpcMessage); + await _ws.SendAsync(bytes, WebSocketMessageType.Text, true, ct); + } +} +``` + +### InProcessTransport + +测试和嵌入场景专用,通过一对 `Channel` 模拟双向通信,无网络开销: + +```csharp +public sealed class InProcessTransport : ITransport +{ + private readonly Channel _clientToServer; + private readonly Channel _serverToClient; + + // 客户端和服务端各持有一端的 Reader/Writer + // 完全同步,deterministic,适合单元测试 +} +``` + +--- + +## 4. 客户端生命周期 + +MCP 客户端从连接到关闭经历五个阶段: + +``` +connect → initialize → initialized → [tool calls / resource reads] → shutdown +``` + +**阶段一:connect** + +传输层建立物理连接(进程启动、HTTP 连接、WebSocket 握手)。 + +**阶段二:initialize** + +客户端发送 `initialize` 请求,声明协议版本和能力: + +```json +{ + "jsonrpc": "2.0", + "id": "init-1", + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "clientInfo": { "name": "FreeCode", "version": "0.1.0" }, + "capabilities": { "roots": { "listChanged": true } } + } +} +``` + +**阶段三:initialized** + +服务端响应 `initialize`,返回其支持的能力和协议版本后,客户端发送 `notifications/initialized` 通知,握手完成。 + +**阶段四:工具调用** + +握手完成后,客户端可以发起任意 `tools/call`、`resources/read`、`prompts/get` 等请求。`McpClient` 内部维护一个 pending request 字典,通过 `id` 匹配请求和响应: + +```csharp +public async Task CallToolAsync( + string toolName, + IReadOnlyDictionary arguments, + CancellationToken ct) +{ + var id = Guid.NewGuid().ToString("N"); + var tcs = new TaskCompletionSource(); + _pending[id] = tcs; + + await _transport.SendMessageAsync(new JsonRpcRequest( + "2.0", id, "tools/call", + BuildParams(toolName, arguments)), ct); + + var response = await tcs.Task.WaitAsync(ct); + return ParseCallToolResult(response); +} +``` + +**阶段五:shutdown** + +客户端发送 `shutdown` 请求,等待服务端 `null` 响应后发送 `exit` 通知,最后关闭传输层。 + +--- + +## 5. MCP 工具适配为 ITool 接口 + +McpToolWrapper 将 MCP 服务端暴露的工具包装为 FreeCode 的 `ITool` 接口,使得 MCP 工具和本地工具在 Engine 层完全透明: + +```csharp +public sealed class McpToolWrapper : ITool +{ + private readonly McpClient _client; + private readonly McpToolDefinition _definition; + + public string Name => $"mcp_{_client.ServerId}_{_definition.Name}"; + public string Description => _definition.Description ?? string.Empty; + + public JsonElement InputSchema => _definition.InputSchema; + + public async Task ExecuteAsync( + JsonElement input, + CancellationToken ct) + { + var args = ParseArguments(input); + var result = await _client.CallToolAsync(_definition.Name, args, ct); + + return new ToolResult( + Success: !result.IsError, + Content: result.Content.Select(MapContent).ToList()); + } +} +``` + +McpManager 在启动时连接所有配置的 MCP 服务器,拉取工具列表,为每个工具创建 McpToolWrapper 并注册到 ToolRegistry。Engine 层通过 ToolRegistry 统一调度,无需感知来源。 + +--- + +## 6. 与 TypeScript SDK 对比 + +| 特性 | @modelcontextprotocol/sdk (TS) | FreeCode.Mcp (C# 自研) | +|------|-------------------------------|----------------------| +| 传输层 | Stdio、SSE、StreamableHttp | Stdio、SSE、StreamableHttp、WebSocket、InProcess | +| 序列化 | Zod 运行时验证 | System.Text.Json + Source Generator | +| AOT 支持 | 不适用 | 完全支持 | +| 异步模型 | Promise / async-await | ValueTask / Channel\ | +| 协议版本 | 跟随官方 | 对齐 2025-03-26 | +| 测试支持 | Jest mock | InProcessTransport | +| 服务端实现 | 完整 | 计划中(v0.3) | + +TypeScript SDK 使用 Zod schema 自动验证所有传入和传出消息。C# 自研版本通过 `JsonSerializerContext` 反序列化时的类型约束和 FluentValidation 检查实现等效的安全性,且无运行时反射开销。 + +--- + +## 7. 关键设计决策 + +**Channel\ 用于异步消息传递** + +传输层的读取循环与业务调用层之间通过 `Channel` 解耦。读取循环是一个长期运行的后台任务,不阻塞调用线程。`Channel` 支持背压(有界通道)和取消,比 `BlockingCollection` 更适合全异步场景。 + +**不可变 Record 表示协议状态** + +所有 JSON-RPC 消息和 MCP 协议模型都定义为 C# `record`,天然不可变。消息一旦构建就不会被修改,消除了多线程读写竞争的可能,也使测试断言更简单。 + +**id 用字符串而不是整数** + +JSON-RPC 2.0 允许 id 为字符串或整数。FreeCode.Mcp 统一使用 `Guid.NewGuid().ToString("N")` 生成字符串 id,避免高并发下的整数碰撞,也方便日志追踪。 + +**传输层不感知协议** + +`ITransport` 只负责原始 `JsonRpcMessage` 的收发,不理解 `initialize`、`tools/call` 等语义。协议逻辑完全在 `McpClient` 和 `McpServer` 中,传输层可以随时替换,不影响上层逻辑。这个边界在集成测试中尤其有价值:用 InProcessTransport 替换 StdioTransport,测试速度从秒级降到毫秒级。 diff --git a/docs/总体概述与技术选型/reference/技术栈映射说明.md b/docs/总体概述与技术选型/reference/技术栈映射说明.md new file mode 100644 index 0000000..ace617e --- /dev/null +++ b/docs/总体概述与技术选型/reference/技术栈映射说明.md @@ -0,0 +1,151 @@ +# 技术栈映射说明 + +> 关联文档:[总体概述](../总体概述与技术选型.md) + +本文档说明 FreeCode .NET 版在技术选型上的完整映射逻辑,以及每个替代方案的选择依据。 + +--- + +## 1. 映射原则 + +在将 TypeScript/Bun 技术栈迁移到 .NET 10 的过程中,我们遵循三条核心原则,优先级由高到低: + +**成熟库优先** +选用社区已经过生产环境验证的库,而不是最新但尚不稳定的方案。工具越成熟,坑越少,维护成本越低。 + +**官方优先** +微软官方或 .NET 基金会维护的包拥有更长的生命周期承诺和更一致的 API 风格。第三方包在官方方案缺失或明显不足时才纳入考虑。 + +**AOT 兼容优先** +FreeCode 最终需要编译为原生 AOT 二进制,以实现秒级启动和最小依赖。凡是依赖运行时反射、动态代码生成的库都要审慎对待,并在必要时通过 Source Generator 绕过。 + +--- + +## 2. 技术栈映射总表 + +| 层级 | 原始 (TS/Bun) | .NET 10 替代 | 版本 | 理由 | +|------|--------------|-------------|------|------| +| 运行时 | Bun 1.x | .NET 10 AOT | 10.0 | 原生 AOT 编译,秒级启动,单文件分发,内存占用更低 | +| 语言 | TypeScript 6 ESM | C# 13 | 13.0 | 静态类型、模式匹配、records、primary constructors,表达力与 TS 相当 | +| 终端 UI | React 19 + Ink 6 | Terminal.Gui v2 | 2.x | 唯一成熟的跨平台 .NET TUI 框架,响应式布局,AOT 友好 | +| 富文本输出 | Ink built-in | Spectre.Console | 0.49.x | 表格、进度条、标记语言,API 优雅,零反射,AOT 完全兼容 | +| CLI 解析 | Commander.js | System.CommandLine v2 | 2.0.0-beta | 微软官方,支持 AOT,子命令/选项/参数体系完整 | +| Schema 验证 | Zod v4 | FluentValidation + JsonSchema.Net | 11.x / 7.x | FluentValidation 负责业务规则验证,JsonSchema.Net 负责 JSON Schema 生成与校验 | +| 代码搜索 | ripgrep (内置) | ripgrep via Process | — | ripgrep 本身是独立二进制,通过 `System.Diagnostics.Process` 调用,行为完全一致 | +| LSP 客户端 | vscode-languageserver-protocol | OmniSharp.Extensions.LanguageServer | 0.19.x | OmniSharp 团队出品,协议覆盖完整,支持双向消息和自定义扩展 | +| MCP 协议 | @modelcontextprotocol/sdk | 自研 MCP SDK | — | 社区尚无成熟 .NET MCP SDK,自研可精确控制传输层和协议版本,详见 [MCP SDK 自研实现方案](mcp-sdk-implement.md) | +| HTTP 客户端 | Bun fetch | HttpClient + Refit | — / 7.x | HttpClient 是 .NET 标准组件,Refit 提供声明式接口减少样板代码,支持 AOT | +| OAuth / OIDC | 自研 | MSAL.NET + IdentityModel.OidcClient | 4.x / 5.x | MSAL.NET 覆盖 Azure/GitHub/Google,OidcClient 处理标准 OIDC 设备码流 | +| WebSocket | ws | System.Net.WebSockets + SignalR | — / 8.x | 标准库 WebSockets 用于原始帧处理,SignalR 用于需要自动重连的场景 | +| 子进程 | Bun.spawn | System.Diagnostics.Process | — | BCL 标准组件,行为跨平台一致,支持异步读写流 | +| JSON 序列化 | JSON.parse/stringify | System.Text.Json | — | BCL 标准组件,Source Generator 模式下零反射,AOT 完全兼容 | +| 后台任务 | 自研 TaskQueue | BackgroundService + Channel\ | — | BackgroundService 是 .NET 托管服务标准抽象,Channel\ 提供高性能无锁消息传递 | +| 插件系统 | dynamic import() | AssemblyLoadContext | — | .NET 官方插件隔离机制,支持热加载和独立卸载 | +| 依赖注入 | 无 | Microsoft.Extensions.DependencyInjection | 10.0 | .NET 官方 DI 容器,轻量、AOT 友好,通过 Source Generator 消除反射 | +| 日志 | console.log | Microsoft.Extensions.Logging | 10.0 | 官方日志抽象,支持结构化日志,后端可插拔(Console/File/OTLP) | + +--- + +## 3. NuGet 包完整清单 + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +--- + +## 4. AOT 兼容性说明 + +原生 AOT 编译是本项目的核心约束,它要求在编译期完成所有类型分析,运行时不允许动态反射、动态代码生成和未知类型的延迟绑定。 + +**Source Generator 策略** + +System.Text.Json 的 AOT 支持通过 `JsonSerializerContext` 实现。每个需要序列化的模型都注册到一个 partial context 类,编译器在构建期生成序列化代码,完全绕开运行时反射: + +```csharp +[JsonSerializable(typeof(ChatRequest))] +[JsonSerializable(typeof(ChatResponse))] +internal partial class FreeCodeJsonContext : JsonSerializerContext { } +``` + +`System.CommandLine` v2 的 AOT 支持已在 beta 阶段引入,通过 Source Generator 生成命令绑定代码,替代早期版本的反射绑定。 + +Microsoft.Extensions.DependencyInjection 从 .NET 8 起提供 `ServiceProviderOptions.ValidateScopes` 的 AOT 路径,通过 `[ServiceDescriptor]` 属性消除反射注册扫描。 + +**裁剪 (Trimming) 配置** + +```xml +true +TrimmerRoots.xml +``` + +Terminal.Gui v2 和 Spectre.Console 均已标注 `[RequiresUnreferencedCode]` 警告可抑制区域,实际裁剪测试证明两者在启用 `TrimmerRootDescriptor` 白名单后正常工作。 + +**不兼容项处理** + +AssemblyLoadContext 用于插件加载,插件程序集本身不参与 AOT 编译,以动态加载的 JIT 模式运行。主程序 AOT 编译不受影响,两者通过接口合约隔离。 + +--- + +## 5. 关键选型设计说明 + +**Terminal.Gui v2 vs. 其他 TUI 方案** + +Spectre.Console 的 Live 组件可以渲染动态布局,但它本质上是顺序输出,不支持真正的交互式控件(文本框、列表框、焦点切换)。FreeCode 的 REPL 界面需要多面板、快捷键绑定和滚动视图,Terminal.Gui v2 是目前唯一满足条件的成熟方案。 + +**FluentValidation + JsonSchema.Net vs. 单一方案** + +Zod 同时承担运行时验证和 Schema 生成两个职责。.NET 生态里没有一个库同时做好两件事,因此分开处理:FluentValidation 的 Fluent API 在业务规则验证上表达力强,JsonSchema.Net 处理 JSON Schema 的生成和校验(主要用于工具参数描述)。 + +**Refit vs. 纯 HttpClient** + +Refit 将 HTTP 接口声明为 C# interface,通过 Source Generator(v7+)生成实现代码,不依赖运行时代理,AOT 兼容。对于结构化的 LLM API 调用来说,减少样板代码的收益明显。 + +**Channel\ vs. BlockingCollection/Queue** + +`Channel` 是 .NET 的高性能异步消息原语,支持有界/无界背压,完全基于 `ValueTask` 避免内存分配,与 `BackgroundService` 的异步生命周期天然契合。`BlockingCollection` 是同步阻塞模型,在异步场景下会占用线程。 diff --git a/docs/总体概述与技术选型/reference/解决方案结构说明.md b/docs/总体概述与技术选型/reference/解决方案结构说明.md new file mode 100644 index 0000000..2e50f29 --- /dev/null +++ b/docs/总体概述与技术选型/reference/解决方案结构说明.md @@ -0,0 +1,199 @@ +# 解决方案结构说明 + +> 关联文档:[总体概述](../总体概述与技术选型.md) + +本文档描述 FreeCode .NET 解决方案的项目划分方式、各项目职责和相互依赖关系,帮助开发者快速定位代码归属和贡献边界。 + +--- + +## 1. FreeCode.sln 概览 + +解决方案包含 **17 个源码项目** 和 **9 个测试项目**,合计 26 个项目。整体采用垂直切分策略:每个项目对应一个关注点,通过接口而非直接引用来解耦。 + +``` +FreeCode.sln +├── 源码项目 (17) +│ ├── FreeCode # 主可执行入口 +│ ├── FreeCode.Core # 核心接口与模型 +│ ├── FreeCode.Engine # 查询引擎 +│ ├── FreeCode.Tools # 工具实现 +│ ├── FreeCode.Commands # 命令实现 +│ ├── FreeCode.ApiProviders # LLM API 适配 +│ ├── FreeCode.Mcp # MCP 协议 +│ ├── FreeCode.Lsp # LSP 集成 +│ ├── FreeCode.Bridge # IDE 桥接 +│ ├── FreeCode.Services # 业务服务 +│ ├── FreeCode.Tasks # 后台任务 +│ ├── FreeCode.Skills # 技能系统 +│ ├── FreeCode.Plugins # 插件系统 +│ ├── FreeCode.Features # 特性开关 +│ ├── FreeCode.State # 状态管理 +│ └── FreeCode.TerminalUI # 终端 UI 组件 +└── 测试项目 (9) + ├── FreeCode.Core.Tests + ├── FreeCode.Engine.Tests + ├── FreeCode.Tools.Tests + ├── FreeCode.Commands.Tests + ├── FreeCode.ApiProviders.Tests + ├── FreeCode.Mcp.Tests + ├── FreeCode.Services.Tests + ├── FreeCode.Tasks.Tests + └── FreeCode.Integration.Tests +``` + +--- + +## 2. 各项目职责说明 + +| 项目名 | 职责 | 关键类型 | 依赖项目 | +|--------|------|----------|----------| +| **FreeCode** | 主可执行 Console App,入口点、DI 容器组装、命令行解析 | `Program`, `RootCommand`, `Startup` | 全部项目 | +| **FreeCode.Core** | 核心接口与模型,25+ 接口、20+ 模型,无外部依赖,是整个解决方案的契约层 | `ITool`, `ICommand`, `IApiProvider`, `ChatMessage`, `ToolResult`, `AppConfig` | 无 | +| **FreeCode.Engine** | QueryEngine 协调消息流与工具调用,PromptBuilder 构建系统提示词 | `QueryEngine`, `PromptBuilder`, `ToolDispatcher`, `ConversationHistory` | Core | +| **FreeCode.Tools** | 50+ 工具实现,按子目录分组:FileSystem、Shell、Agent、Web、Mcp、UserInteraction、PlanMode、Swarms、Worktree | `BashTool`, `ReadFileTool`, `GlobTool`, `GrepTool`, `AgentTool`, `BrowserTool`, `McpTool`, `PlanModeTool`, `WorktreeTool` | Core, Services, Mcp | +| **FreeCode.Commands** | 70+ 斜杠命令实现,每个命令对应一个类 | `ClearCommand`, `CompactCommand`, `ReviewWorkCommand`, `InitDeepCommand`, `RalphLoopCommand` | Core, Engine, State, Skills | +| **FreeCode.ApiProviders** | 5 个 LLM 提供商适配:Anthropic、OpenAI、Gemini、GitHub Copilot、Bedrock | `ClaudeProvider`, `OpenAiProvider`, `GeminiProvider`, `CopilotProvider`, `BedrockProvider` | Core | +| **FreeCode.Mcp** | MCP 协议客户端与服务端,传输层实现,工具适配器 | `McpClient`, `McpServer`, `StdioTransport`, `SseTransport`, `McpToolWrapper` | Core | +| **FreeCode.Lsp** | LSP 客户端集成,支持 goto-definition、find-references、diagnostics、rename | `LspClient`, `LspDiagnosticsProvider`, `LspWorkspaceManager` | Core | +| **FreeCode.Bridge** | IDE 桥接层,与 VS Code / JetBrains 等 IDE 扩展通信 | `BridgeServer`, `BridgeMessage`, `IdeEventHandler` | Core, Services | +| **FreeCode.Services** | 业务服务层:文件系统抽象、配置管理、权限管理、搜索服务 | `FileService`, `ConfigService`, `PermissionService`, `RipgrepService`, `AstGrepService` | Core | +| **FreeCode.Tasks** | 后台任务管理:任务队列、任务状态跟踪、MCP 监控任务、Workflow 任务 | `BackgroundTaskManager`, `TaskQueue`, `MonitorMcpTask`, `LocalWorkflowTask` | Core, Services | +| **FreeCode.Skills** | 技能系统:技能加载、索引、搜索和执行 | `SkillLoader`, `SkillIndex`, `SkillSearchService`, `SkillExecutor` | Core, Services | +| **FreeCode.Plugins** | 插件系统:通过 AssemblyLoadContext 动态加载插件,插件发现和生命周期管理 | `PluginLoader`, `PluginHost`, `PluginRegistry` | Core | +| **FreeCode.Features** | 特性开关系统,控制实验性功能的启用状态,支持编译期和运行期两种模式 | `FeatureFlags`, `FeatureRegistry`, `IFeatureFlag` | Core | +| **FreeCode.State** | 应用状态管理,线程安全的状态读写,状态订阅 | `AppStateStore`, `ConversationState`, `SessionState` | Core | +| **FreeCode.TerminalUI** | Terminal.Gui v2 组件封装,REPL 界面、输出渲染、进度显示 | `ReplView`, `OutputPane`, `StatusBar`, `ThemeManager` | Core, State | + +--- + +## 3. 项目依赖关系图 + +``` + ┌─────────────────┐ + │ FreeCode.Core │ + │ (接口 + 模型) │ + └────────┬────────┘ + │ 被所有项目引用 + ┌─────────────────────┼──────────────────────────┐ + │ │ │ + ┌──────▼──────┐ ┌─────────▼─────────┐ ┌──────────▼────────┐ + │FreeCode. │ │FreeCode.ApiProviders│ │FreeCode.Services │ + │Features │ │(LLM 适配层) │ │(文件/搜索/配置) │ + └──────┬──────┘ └─────────┬─────────┘ └──────────┬────────┘ + │ │ │ + ┌──────▼──────┐ ┌─────────▼─────────┐ ┌──────────▼────────┐ + │FreeCode. │ │ FreeCode.Engine │ │ FreeCode.Mcp │ + │State │ │ (QueryEngine) │ │ (MCP 协议) │ + └──────┬──────┘ └─────────┬─────────┘ └──────────┬────────┘ + │ │ │ + ┌──────▼─────────────────────▼──────────────────────────▼────────┐ + │ FreeCode.Tools │ + │ (50+ 工具, 依赖 Services + Mcp) │ + └──────────────────────────┬─────────────────────────────────────┘ + │ + ┌────────────────────┼────────────────────┐ + │ │ │ + ┌──────▼──────┐ ┌────────▼───────┐ ┌───────▼────────┐ + │FreeCode. │ │FreeCode.Skills │ │FreeCode.Tasks │ + │Commands │ │(技能系统) │ │(后台任务) │ + └──────┬──────┘ └────────┬───────┘ └───────┬────────┘ + │ │ │ + └────────────────────┼────────────────────┘ + │ + ┌──────────▼─────────┐ + │ FreeCode.TerminalUI│ + │ (Terminal.Gui 组件)│ + └──────────┬─────────┘ + │ + ┌───────────────▼────────────────┐ + │ FreeCode (主入口) │ + │ DI 组装 + CLI + 程序生命周期 │ + └────────────────────────────────┘ + + 独立模块 (通过接口与主流程交互): + ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ + │FreeCode.Lsp │ │FreeCode.Bridge│ │FreeCode.Plugins│ + │(LSP 客户端) │ │(IDE 桥接) │ │(动态插件) │ + └──────────────┘ └──────────────┘ └──────────────┘ +``` + +--- + +## 4. Directory.Build.props 共享配置 + +所有项目共享一个根级别的 `Directory.Build.props`,统一配置编译选项,避免在每个 `.csproj` 中重复: + +```xml + + + + 13.0 + enable + enable + + + net10.0 + + + false + false + true + + + true + + CS1591 + + + true + latest-recommended + + + 0.1.0 + 0.1.0.0 + + + + + true + false + + +``` + +主项目 `FreeCode.csproj` 在此基础上覆盖 AOT 相关设置: + +```xml + + Exe + true + true + true + true + +``` + +--- + +## 5. 测试项目结构 + +9 个测试项目覆盖核心业务逻辑,遵循一对一映射原则(一个源码项目对应一个测试项目),加一个集成测试项目。 + +| 测试项目 | 对应源码项目 | 测试重点 | +|----------|-------------|----------| +| FreeCode.Core.Tests | FreeCode.Core | 模型验证、接口约定、序列化正确性 | +| FreeCode.Engine.Tests | FreeCode.Engine | QueryEngine 消息流、工具调度逻辑、PromptBuilder 输出 | +| FreeCode.Tools.Tests | FreeCode.Tools | 各工具的单元行为,沙箱文件系统,进程调用 mock | +| FreeCode.Commands.Tests | FreeCode.Commands | 命令解析、执行逻辑、输出格式 | +| FreeCode.ApiProviders.Tests | FreeCode.ApiProviders | 请求构建、响应解析、错误处理、流式解析 | +| FreeCode.Mcp.Tests | FreeCode.Mcp | JSON-RPC 消息编解码、传输层、客户端生命周期 | +| FreeCode.Services.Tests | FreeCode.Services | 文件服务、配置加载、权限检查、搜索结果解析 | +| FreeCode.Tasks.Tests | FreeCode.Tasks | 任务队列背压、取消、状态转换 | +| FreeCode.Integration.Tests | 全部项目 | 端到端命令执行,真实 API(可选,通过环境变量控制) | + +测试框架统一使用 **xUnit 2.x** + **FluentAssertions 6.x** + **Moq 4.x**。集成测试通过 `[Trait("Category", "Integration")]` 标注,默认 CI 跳过,本地按需运行: + +```bash +dotnet test --filter "Category!=Integration" +dotnet test --filter "Category=Integration" # 需要设置 ANTHROPIC_API_KEY +``` diff --git a/docs/总体概述与技术选型/总体概述与技术选型.md b/docs/总体概述与技术选型/总体概述与技术选型.md new file mode 100644 index 0000000..0e10939 --- /dev/null +++ b/docs/总体概述与技术选型/总体概述与技术选型.md @@ -0,0 +1,252 @@ +# 总体概述与技术选型 + +> **文档版本**: 1.0 +> **日期**: 2026-04-05 +> **所属部分**: 第一部分 +> **原始设计来源**: `DESIGN-NET10.md` 第一部分(第 1-4 节及附录) + +--- + +## 目录 + +- [1. 项目概述](#1-项目概述) +- [2. 技术栈映射](#2-技术栈映射) +- [3. 顶层架构设计](#3-顶层架构设计) +- [4. 解决方案结构](#4-解决方案结构) +- [5. 关键设计决策](#5-关键设计决策) +- [模块覆盖对照表](#模块覆盖对照表) +- [参考文档](#参考文档) + +--- + +## 1. 项目概述 + +free-code 是 Anthropic Claude Code CLI 的社区 fork,一个终端原生 AI 编码代理。本次重写目标是将整个项目从 TypeScript/Bun/React+Ink 技术栈完整迁移至 .NET 10,同时保持 100% 功能对等。 + +**原始版本规模**: v2.1.87,包含 200+ 个 TS/TSX 源文件、50+ 个 Agent 工具、70+ 个 Slash 命令、5 个 LLM 提供商接入。 + +### 核心功能清单 + +- **交互式 REPL**: 终端 AI 对话界面,流式响应、工具调用可视化、权限审批 +- **50+ Agent 工具**: 文件读写编辑、Bash 执行、代码搜索、Web 访问、子代理生成 +- **70+ Slash 命令**: 配置、会话管理、OAuth、插件、主题等 +- **5 个 LLM 提供商**: Anthropic、OpenAI Codex、AWS Bedrock、Google Vertex AI、Anthropic Foundry +- **MCP/LSP 协议**: 外部工具集成 + 语言服务器 +- **IDE 桥接**: VS Code/JetBrains 远程会话控制 +- **技能/插件系统**: 可扩展 prompt 模板 + 功能包 +- **后台任务、会话记忆、语音输入、远程会话、同伴系统** + +### 重写目标 + +| 目标 | 描述 | +|------|------| +| 功能对等 | 100% 覆盖所有功能 | +| 性能提升 | .NET 10 AOT 编译,启动速度与内存双优化 | +| 类型安全 | C# 13 强类型替代 TypeScript 运行时校验 | +| 原生集成 | 更深度的 OS 集成(进程、文件系统、沙箱) | +| 可维护性 | 清晰模块边界、DI、接口驱动设计 | + +> .NET 10 平台特性详见: [./reference/.NET-10-平台介绍.md](reference/.NET-10-平台介绍.md) + +--- + +## 2. 技术栈映射 + +将 TypeScript 生态的每个技术点一一对应至 .NET 10 生态,选型原则是成熟度优先、官方方案优先、AOT 兼容性优先。 + +| 层级 | 原始 (TS/Bun) | .NET 10 替代 | 理由 | +|------|-------------|-------------|------| +| 运行时 | Bun >= 1.3.11 | .NET 10 + AOT | 单一二进制,跨平台 | +| 语言 | TypeScript 6 ESM | C# 13 | record types, pattern matching | +| 终端 UI | React 19 + Ink 6 | **Terminal.Gui v2** | 唯一成熟 .NET TUI,组件模型接近 React | +| 输出渲染 | Ink 内置 | **Spectre.Console** | 表格/面板/进度条/富文本 | +| CLI 解析 | Commander.js | **System.CommandLine v2** | 官方框架,子命令/选项/补全 | +| Schema 校验 | Zod v4 | **FluentValidation** + **JsonSchema.Net** | POCO 校验 + JSON Schema | +| 代码搜索 | ripgrep (bundled) | ripgrep via Process wrapper | 保持 ripgrep 性能 | +| LSP 客户端 | vscode-languageserver-protocol | **OmniSharp.Extensions.LanguageServer** | 最成熟 .NET LSP 库 | +| MCP 协议 | @modelcontextprotocol/sdk | **自研 MCP SDK** | 社区无成熟方案 | +| HTTP | Bun fetch | **HttpClient + Refit** | 强类型 REST,流式支持 | +| OAuth | 自定义 client | **MSAL.NET + IdentityModel.OidcClient** | 成熟 OAuth/OIDC | +| WebSocket | ws (npm) | **System.Net.WebSockets + SignalR** | 内建 + 高层抽象 | +| 进程管理 | Bun spawn | **System.Diagnostics.Process** | 内建跨平台 | +| JSON | JSON.parse/stringify | **System.Text.Json** | 高性能 AOT 友好 | +| 后台任务 | 自定义 | **BackgroundService + Channels** | .NET 标准模式 | +| 插件加载 | 动态 import() | **AssemblyLoadContext** | 运行时程序集隔离 | +| DI | 无 | **Microsoft.Extensions.DependencyInjection** | 标准 DI | +| 日志 | console | **Microsoft.Extensions.Logging** | 结构化日志 | + +> 每个映射条目的详细说明,以及完整 NuGet 包版本清单,见: [./reference/技术栈映射说明.md](reference/技术栈映射说明.md) + +--- + +## 3. 顶层架构设计 + +系统分五层,从下往上依次为基础层、基础设施层、核心服务层、应用层、表现层。 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Presentation Layer │ +│ Terminal.Gui v2 (REPL) │ System.CommandLine (CLI) │ +├─────────────────────────────────────────────────────────────────┤ +│ Application Layer │ +│ REPL Controller │ Command Dispatcher │ QueryEngine │ +├─────────────────────────────────────────────────────────────────┤ +│ Core Services Layer │ +│ ToolSystem(50+) │ PermissionEngine │ FeatureFlags │ +│ AgentSpawner │ Memory/Context │ PromptBuilder │ +├─────────────────────────────────────────────────────────────────┤ +│ Infrastructure Layer │ +│ ApiProviderRouter │ McpClientManager │ LspClientManager │ +│ OAuthService │ BackgroundTasks │ IDE Bridge │ +│ SkillLoader │ PluginLoader │ RemoteSessions │ +│ RateLimitService │ NotificationSvc │ VoiceService │ +├─────────────────────────────────────────────────────────────────┤ +│ Foundation Layer │ +│ AppState Store │ Config System │ Logging │ DI Container │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**核心数据流**: 用户输入 → CLI/REPL → QueryEngine → PromptBuilder → ApiProvider → LLM 响应 → ToolUse 循环 → 渲染输出 + +每一层仅依赖其下层,不允许跨层向上依赖。基础设施层中的 MCP SDK 是自研实现,因为社区目前没有成熟的 .NET 方案,详见: [./reference/mcp-sdk-implement.md](reference/mcp-sdk-implement.md) + +--- + +## 4. 解决方案结构 + +整个项目组织为一个 .sln 文件管理 17 个 C# 项目,加上 9 个测试项目。 + +``` +FreeCode.sln +├── src/ +│ ├── FreeCode/ # 主可执行 (Console App, AOT) +│ ├── FreeCode.Core/ # 核心接口 + 模型 (25+ 接口, 20+ 模型) +│ ├── FreeCode.Engine/ # QueryEngine + PromptBuilder +│ ├── FreeCode.Tools/ # 50+ Agent 工具 +│ │ ├── FileSystem/ (Read, Edit, Write, Glob, Grep, NotebookEdit) +│ │ ├── Shell/ (Bash, PowerShell) +│ │ ├── Agent/ (Agent, Skill, Task* tools) +│ │ ├── Web/ (WebFetch, WebSearch) +│ │ ├── Mcp/ (ListMcpResources, ReadMcpResource, ToolSearch) +│ │ ├── UserInteraction/ (AskUserQuestion, Brief) +│ │ ├── PlanMode/ (EnterPlan, ExitPlan) +│ │ ├── AgentSwarms/ (SendMessage, TeamCreate, TeamDelete) +│ │ └── Worktree/ (EnterWorktree, ExitWorktree) +│ ├── FreeCode.Commands/ # 70+ Slash 命令 +│ ├── FreeCode.ApiProviders/ # 5 LLM 提供商 +│ ├── FreeCode.Mcp/ # MCP 协议 (Transport + Protocol + ToolWrapper) +│ ├── FreeCode.Lsp/ # LSP 集成 +│ ├── FreeCode.Bridge/ # IDE 桥接 +│ ├── FreeCode.Services/ # 业务服务 (Auth/Memory/Voice/Remote/Notification/...) +│ ├── FreeCode.Tasks/ # 后台任务管理 +│ ├── FreeCode.Skills/ # 技能系统 +│ ├── FreeCode.Plugins/ # 插件系统 (AssemblyLoadContext) +│ ├── FreeCode.Features/ # 特性开关 +│ ├── FreeCode.State/ # 状态管理 (AppState record) +│ └── FreeCode.TerminalUI/ # Terminal.Gui 组件 (REPL/Components/Theme/Keybindings) +├── tests/ # 9 个测试项目 +└── scripts/ # build.ps1, build.sh, publish.sh +``` + +> 每个项目的职责边界、依赖关系图和 `Directory.Build.props` 共享配置,详见: [./reference/解决方案结构说明.md](reference/解决方案结构说明.md) + +--- + +## 5. 关键设计决策 + +这些决策在整个设计方案中一以贯之,影响所有模块的实现方式。 + +| 决策点 | 选择 | 理由 | +|--------|------|------| +| 终端 UI 框架 | Terminal.Gui v2 | .NET 生态内唯一成熟的 TUI 方案 | +| CLI 解析框架 | System.CommandLine v2 | 官方库,v2 已稳定,子命令/选项/自动补全齐备 | +| MCP 协议客户端 | 自研 SDK | 社区目前没有成熟的 .NET 实现 | +| 插件隔离方式 | AssemblyLoadContext | .NET 原生支持,程序集可卸载 | +| 状态管理模式 | Record + Event | 不可变状态 + 事件驱动,类似 Redux/Elm | +| AOT 兼容策略 | Source Generator 优先 | 尽量避免反射,确保 AOT 编译不裁剪 | +| 代码搜索 | ripgrep 进程包装 | 保持原有 ripgrep 的搜索性能 | + +--- + +## 模块覆盖对照表 + +> 说明: +> - ✅ 已覆盖:已有独立设计文档 +> - ↗️ 归属:作为其他文档的小节或附属内容覆盖 +> - 📋 空目录/stub:当前快照为空目录、占位文件或仅剩 stub,不单独出文档 + +| 原始 ../../src/ 目录 | 文件数 | docs/ 覆盖文档 | 状态 | +|------|------:|------|------| +| `entrypoints` | 11 | [核心模块设计 — CLI启动与解析](../核心模块设计/核心模块设计-CLI启动与解析.md) | ✅ 已覆盖 | +| `screens` | 3 | [核心模块设计 — CLI启动与解析](../核心模块设计/核心模块设计-CLI启动与解析.md) | ↗️ 归属 | +| `QueryEngine.ts` | 1 | [核心模块设计 — 查询引擎 (QueryEngine)](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md) | ✅ 已覆盖 | +| `tools.ts`、`tools/` | 210 | [核心模块设计 — 工具系统](../核心模块设计/核心模块设计-工具系统.md) | ✅ 已覆盖 | +| `commands.ts`、`commands/` | 218 | [核心模块设计 — 命令系统](../核心模块设计/核心模块设计-命令系统.md) | ✅ 已覆盖 | +| `services/api/` | 39 | [核心模块设计 — API 多提供商路由](../核心模块设计/核心模块设计-API提供商路由.md) | ✅ 已覆盖 | +| `services/lsp/` | 7 | [基础设施设计 — LSP集成](../基础设施设计/基础设施设计-LSP集成.md) | ✅ 已覆盖 | +| `services/mcp/` | 22 | [基础设施设计 — MCP协议集成](../基础设施设计/基础设施设计-MCP协议集成.md) | ✅ 已覆盖 | +| `services/oauth/` | 7 | [服务子系统设计 — 认证与OAuth](../服务子系统设计/服务子系统设计-认证与OAuth.md) | ✅ 已覆盖 | +| `services/SessionMemory/` | 3 | [服务子系统设计 — 会话记忆与上下文](../服务子系统设计/服务子系统设计-会话记忆与上下文.md) | ✅ 已覆盖 | +| `services/contextCollapse/` | 3 | [服务子系统设计 — 会话记忆与上下文](../服务子系统设计/服务子系统设计-会话记忆与上下文.md) | ↗️ 归属 | +| `services/extractMemories/` | 2 | [服务子系统设计 — 会话记忆与上下文](../服务子系统设计/服务子系统设计-会话记忆与上下文.md) | ↗️ 归属 | +| `services/teamMemorySync/` | 5 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 | +| `services/autoDream/` | 4 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 | +| `services/plugins/` | 3 | [UI与扩展设计 — 插件系统](../UI与扩展设计/UI与扩展设计-插件系统.md) | ✅ 已覆盖 | +| `services/PromptSuggestion/` | 2 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 | +| `services/policyLimits/` | 2 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 | +| `services/tips/` | 3 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 | +| `services/analytics/` | 8 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 | +| `services/voice*` | 3 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 | +| `services/settingsSync/` | 2 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 | +| `services/bootstrap/` | 1 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 | +| `bridge/` | 32 | [基础设施设计 — IDE桥接](../基础设施设计/基础设施设计-IDE桥接.md) | ✅ 已覆盖 | +| `tasks/` | 14 | [基础设施设计 — 后台任务管理](../基础设施设计/基础设施设计-后台任务管理.md) | ✅ 已覆盖 | +| `state/` | 6 | [基础设施设计 — 状态管理](../基础设施设计/基础设施设计-状态管理.md) | ✅ 已覆盖 | +| `skills/` | 50 | [UI与扩展设计 — 技能系统](../UI与扩展设计/UI与扩展设计-技能系统.md) | ✅ 已覆盖 | +| `plugins/` | 2 | [UI与扩展设计 — 插件系统](../UI与扩展设计/UI与扩展设计-插件系统.md) | ✅ 已覆盖 | +| `coordinator/` | 2 | [核心模块设计 — 多代理协调器 (Coordinator)](../核心模块设计/核心模块设计-多代理协调.md) | ✅ 已覆盖 | +| `assistant/` | 5 | [核心模块设计 — 多代理协调器 (Coordinator)](../核心模块设计/核心模块设计-多代理协调.md) | ↗️ 归属 | +| `context/` | 9 | [基础设施设计 — 状态管理](../基础设施设计/基础设施设计-状态管理.md) | ↗️ 归属 | +| `hooks/` | 104 | [UI与扩展设计 — Terminal-Gui 终端UI](../UI与扩展设计/UI与扩展设计-Terminal-Gui终端UI.md) | ↗️ 归属 | +| `components/` | 400 | [UI与扩展设计 — Terminal-Gui 终端UI](../UI与扩展设计/UI与扩展设计-Terminal-Gui终端UI.md) | ↗️ 归属 | +| `ink/` | 98 | [UI与扩展设计 — Terminal-Gui 终端UI](../UI与扩展设计/UI与扩展设计-Terminal-Gui终端UI.md) | ↗️ 归属 | +| `ink.ts` | 1 | [UI与扩展设计 — Terminal-Gui 终端UI](../UI与扩展设计/UI与扩展设计-Terminal-Gui终端UI.md) | ↗️ 归属 | +| `keybindings/` | 14 | [UI与扩展设计 — Terminal-Gui 终端UI](../UI与扩展设计/UI与扩展设计-Terminal-Gui终端UI.md) | ↗️ 归属 | +| `constants/` | 22 | [总体概述与技术选型](总体概述与技术选型.md) / 各子模块引用 | ↗️ 归属 | +| `utils/` | 577 | 各模块与基础设施文档的公共支撑 | ↗️ 归属 | +| `types/` | 12 | 各模块与基础设施文档的公共支撑 | ↗️ 归属 | +| `remote/` | 4 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 | +| `daemon/` | 2 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 | +| `proactive/` | 2 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 | +| `server/` | 11 | [基础设施设计 — 其他基础设施子系统](reference/解决方案结构说明.md) | ↗️ 归属 | +| `vim/` | 5 | [UI与扩展设计 — Terminal-Gui 终端UI](../UI与扩展设计/UI与扩展设计-Terminal-Gui终端UI.md) | ↗️ 归属 | +| `memdir/` | 9 | [服务子系统设计 — 会话记忆与上下文](../服务子系统设计/服务子系统设计-会话记忆与上下文.md) | ↗️ 归属 | +| `migrations/` | 11 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | 📋 空目录/stub | +| `bootstrap/` | 1 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | 📋 空目录/stub | +| `buddy/` | 6 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | 📋 空目录/stub | +| `cli/` | 21 | [核心模块设计 — CLI启动与解析](../核心模块设计/核心模块设计-CLI启动与解析.md) | ↗️ 归属 | +| `query/` | 4 | [核心模块设计 — 查询引擎 (QueryEngine)](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md) | ↗️ 归属 | +| `self-hosted-runner/` | 1 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | 📋 空目录/stub | +| `ssh/` | 1 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | 📋 空目录/stub | +| `native-ts/` | 4 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | 📋 空目录/stub | +| `upstreamproxy/` | 2 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | 📋 空目录/stub | +| `environment-runner/` | 1 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | 📋 空目录/stub | +| `jobs/` | 1 | [基础设施设计 — 后台任务管理](../基础设施设计/基础设施设计-后台任务管理.md) | ↗️ 归属 | +| `moreright/` | 1 | [UI与扩展设计 — 插件系统](../UI与扩展设计/UI与扩展设计-插件系统.md) | 📋 空目录/stub | + +### 备注 + +- `services/*` 采用“核心子系统 + 其他服务子系统”组合覆盖,避免为每个轻量子目录单独出文档。 +- `constants/`、`types/`、`utils/` 属于横切支撑层,通常作为被引用内容而非独立模块文档。 +- `migrations/`、`bootstrap/`、`buddy/`、`daemon/`、`proactive/`、`remote/` 等目录在当前重写文档体系中主要作为其他模块的实现细节归属,不单独展开。 + +--- + +## 参考文档 + +| 文档 | 内容 | +|------|------| +| [.NET 10 平台介绍](reference/.NET-10-平台介绍.md) | AOT、C# 13、核心 API 特性 | +| [技术栈映射说明](reference/技术栈映射说明.md) | TS → .NET 完整映射 + NuGet 清单 | +| [解决方案结构说明](reference/解决方案结构说明.md) | 17 个项目职责与依赖关系 | +| [MCP SDK 实现](reference/mcp-sdk-implement.md) | 自研 MCP SDK 协议与传输层设计 | diff --git a/docs/服务子系统设计/reference/原始代码映射-服务子系统.md b/docs/服务子系统设计/reference/原始代码映射-服务子系统.md new file mode 100644 index 0000000..a699878 --- /dev/null +++ b/docs/服务子系统设计/reference/原始代码映射-服务子系统.md @@ -0,0 +1,22 @@ +# 服务子系统设计 — 原始代码映射 + +## 元数据 +- 项目名称: free-code +- 文档类型: 代码映射 +- 原始代码来源: `../../../src/services/` 与 `../../../src/utils/memory/` +- 关联总览: [服务子系统设计总览](../服务子系统设计.md) + +## 映射表 + +| 文档模块 | 原始代码路径 | 说明 | +|---|---|---| +| 会话记忆与上下文 | `../../../src/utils/memory/` | 会话记忆、梦境摘要、团队同步逻辑 | +| 远程会话管理 | `../../../src/services/remote/` | 远程连接、事件流、状态机 | +| 语音输入 | `../../../src/services/voice/` | 语音采集、识别与授权门控 | +| 通知服务 | `../../../src/services/notification/` | 终端通知与平台探测 | +| 限流服务 | `../../../src/services/rate-limit/` | 头部解析、重试策略、退避控制 | +| 伙伴系统 | `../../../src/services/companion/` | 确定性角色生成与外观枚举 | + +## 说明 +- 该映射仅记录服务子系统相关文件夹,不展开到单个实现文件,便于与设计文档保持一一对应。 +- 所有条目遵循 .NET 命名风格,接口以 `I` 开头,类型使用 PascalCase。 diff --git a/docs/服务子系统设计/服务子系统设计-会话记忆与上下文.md b/docs/服务子系统设计/服务子系统设计-会话记忆与上下文.md new file mode 100644 index 0000000..1b2a942 --- /dev/null +++ b/docs/服务子系统设计/服务子系统设计-会话记忆与上下文.md @@ -0,0 +1,170 @@ +# 服务子系统设计 — 会话记忆与上下文 + +## 元数据 +- 项目名称: free-code +- 文档类型: 服务子系统设计 +- 原始代码来源: `../../src/utils/memory/` +- 关联总览: [服务子系统设计总览](服务子系统设计.md) +- 原始映射: [原始代码映射](reference/原始代码映射-服务子系统.md) +- 相关模块: [核心模块设计 — 查询引擎](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md) +- 相关模块: [基础设施设计 — 后台任务管理](../基础设施设计/基础设施设计-后台任务管理.md) +- 相关模块: [基础设施设计 — 特性开关](../UI与扩展设计/UI与扩展设计-特性开关系统.md) + +## 1. 设计目标 +会话记忆与上下文子系统负责把长会话中的关键内容压缩、沉淀、同步,并在需要时为查询引擎提供可控、低成本的上下文补充。该子系统强调阈值触发、轻量模型处理、以及在写入团队知识库前的安全校验。 + +## 2. 核心数据结构 + +```csharp +public enum MemoryType +{ + Session, + Dream, + Team +} + +public sealed record MemoryEntry( + string Id, + MemoryType Type, + string SessionId, + string Content, + DateTimeOffset CreatedAt, + IReadOnlyList Tags); +``` + +`MemoryType` 用于区分会话记忆、自动梦境摘要与团队共享记忆。`MemoryEntry` 记录统一承载记忆内容、来源会话、创建时间和标签,便于检索与同步。 + +## 3. 会话记忆服务 + +```csharp +public interface ISessionMemoryService +{ + Task ExtractAsync( + string sessionId, + int tokenCount, + int toolCallCount, + CancellationToken cancellationToken = default); + + Task SaveAsync(MemoryEntry entry, CancellationToken cancellationToken = default); + + Task> GetRecentAsync( + string sessionId, + CancellationToken cancellationToken = default); +} +``` + +`ISessionMemoryService` 使用阈值触发提取:当 token 数或 tool call 数超过阈值时,触发一次记忆抽取,生成摘要型 `MemoryEntry`。 + +```csharp +public sealed class SessionMemoryService : ISessionMemoryService +{ + private readonly int _tokenThreshold = 12000; + private readonly int _toolCallThreshold = 24; + + public async Task ExtractAsync( + string sessionId, + int tokenCount, + int toolCallCount, + CancellationToken cancellationToken = default) + { + var shouldExtract = tokenCount >= _tokenThreshold || toolCallCount >= _toolCallThreshold; + if (!shouldExtract) + { + return null; + } + + return await Task.FromResult(new MemoryEntry( + Id: Guid.NewGuid().ToString("N"), + Type: MemoryType.Session, + SessionId: sessionId, + Content: "基于会话上下文生成的压缩记忆。", + CreatedAt: DateTimeOffset.UtcNow, + Tags: new[] { "session", "summary" })); + } + + public Task SaveAsync(MemoryEntry entry, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task> GetRecentAsync(string sessionId, CancellationToken cancellationToken = default) + => Task.FromResult>(Array.Empty()); +} +``` + +## 4. 自动梦境服务 + +```csharp +public interface IAutoDreamService +{ + Task GenerateAsync( + string sessionId, + TimeSpan idleDuration, + int sessionCount, + CancellationToken cancellationToken = default); +} +``` + +`IAutoDreamService` 在两类条件触发:会话空闲超过 24 小时,或会话累计次数达到设定阈值。它用于生成更高层级的长期偏好、主题趋势和待办线索。 + +```csharp +public sealed class AutoDreamService : IAutoDreamService +{ + private readonly TimeSpan _idleThreshold = TimeSpan.FromHours(24); + private readonly int _sessionCountThreshold = 10; + + public async Task GenerateAsync( + string sessionId, + TimeSpan idleDuration, + int sessionCount, + CancellationToken cancellationToken = default) + { + if (idleDuration < _idleThreshold && sessionCount < _sessionCountThreshold) + { + return null; + } + + return await Task.FromResult(new MemoryEntry( + Id: Guid.NewGuid().ToString("N"), + Type: MemoryType.Dream, + SessionId: sessionId, + Content: "基于空闲周期与会话积累生成的自动梦境摘要。", + CreatedAt: DateTimeOffset.UtcNow, + Tags: new[] { "dream", "long-term" })); + } +} +``` + +## 5. 团队记忆同步服务 + +```csharp +public interface ITeamMemorySyncService +{ + Task PushAsync(MemoryEntry entry, CancellationToken cancellationToken = default); + Task PullAsync(string memoryId, CancellationToken cancellationToken = default); +} +``` + +`ITeamMemorySyncService` 支持推送与拉取团队记忆。推送前必须执行 secret scanning,避免把敏感信息写入共享知识库。 + +```csharp +public sealed class TeamMemorySyncService : ITeamMemorySyncService +{ + public Task PushAsync(MemoryEntry entry, CancellationToken cancellationToken = default) + { + var hasSecret = false; + if (hasSecret) + { + throw new InvalidOperationException("检测到敏感信息,禁止推送团队记忆。"); + } + + return Task.CompletedTask; + } + + public Task PullAsync(string memoryId, CancellationToken cancellationToken = default) + => Task.FromResult(null); +} +``` + +## 6. 设计说明 +- 记忆相关操作使用轻量模型 `claude-haiku-4-5`,优先保证低延迟与低成本。 +- 记忆抽取优先走阈值触发,避免每轮上下文都进行重处理。 +- 团队记忆同步前必须进行 secret scanning,防止把密钥、令牌或隐私数据外发。 +- 该子系统与查询引擎联动,用于补充局部上下文,而不是替代主对话历史。 diff --git a/docs/服务子系统设计/服务子系统设计-其他服务子系统.md b/docs/服务子系统设计/服务子系统设计-其他服务子系统.md new file mode 100644 index 0000000..04b7a15 --- /dev/null +++ b/docs/服务子系统设计/服务子系统设计-其他服务子系统.md @@ -0,0 +1,166 @@ +# 服务子系统设计 — 其他服务子系统 + +## 元数据 +- 项目名称: free-code +- 文档类型: 服务子系统设计 +- 原始代码来源: `../../src/services/` +- 关联总览: [服务子系统设计总览](服务子系统设计.md) +- 原始映射: [原始代码映射](reference/原始代码映射-服务子系统.md) +- 相关模块: [基础设施设计 — 后台任务管理](../基础设施设计/基础设施设计-后台任务管理.md) +- 相关模块: [UI与扩展设计 — 特性开关系统](../UI与扩展设计/UI与扩展设计-特性开关系统.md) + +## 21.1 远程会话 + +```csharp +public interface IRemoteSessionManager +{ + Task ConnectAsync(string endpoint, CancellationToken cancellationToken = default); + Task DisconnectAsync(CancellationToken cancellationToken = default); + IAsyncEnumerable ReadEventsAsync(CancellationToken cancellationToken = default); +} + +public abstract record RemoteEvent(DateTimeOffset Timestamp); +public sealed record RemoteConnectedEvent(DateTimeOffset Timestamp, string SessionId) : RemoteEvent(Timestamp); +public sealed record RemoteDisconnectedEvent(DateTimeOffset Timestamp, string Reason) : RemoteEvent(Timestamp); +public sealed record RemoteMessageEvent(DateTimeOffset Timestamp, string Message) : RemoteEvent(Timestamp); + +public enum RemoteConnectionStatus +{ + Disconnected, + Connecting, + Connected, + Reconnecting, + Failed +} +``` + +远程会话管理用于把本地交互扩展到远端主机或托管环境,并通过事件流表达连接状态、消息和断连原因。 + +## 21.2 语音输入 + +```csharp +public interface IVoiceService +{ + Task StartAsync(CancellationToken cancellationToken = default); + Task StopAsync(CancellationToken cancellationToken = default); + Task RecognizeAsync(CancellationToken cancellationToken = default); +} + +public sealed class VoiceService : IVoiceService +{ + public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task RecognizeAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); +} +``` + +语音输入采用特性开关与 OAuth 门控:只有启用对应功能且完成授权后,语音能力才会暴露给会话流程。 + +## 21.3 通知服务 + +```csharp +public interface INotificationService +{ + Task NotifyAsync(string title, string message, CancellationToken cancellationToken = default); + bool IsSupportedTerminal(); +} + +public sealed class NotificationService : INotificationService +{ + public Task NotifyAsync(string title, string message, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public bool IsSupportedTerminal() + { + var terminal = Environment.GetEnvironmentVariable("TERM_PROGRAM") ?? string.Empty; + return terminal is "iTerm2" or "Kitty" or "Ghostty"; + } +} +``` + +通知服务依赖终端类型检测,在 iTerm2、Kitty、Ghostty 等环境中可提供更稳定的本地通知体验。 + +## 21.4 限流服务 + +```csharp +public interface IRateLimitService +{ + bool CanProceed(IDictionary headers); + TimeSpan? GetRetryAfter(IDictionary headers); +} + +public sealed class RateLimitService : IRateLimitService +{ + public bool CanProceed(IDictionary headers) + => !GetRetryAfter(headers).HasValue; + + public TimeSpan? GetRetryAfter(IDictionary headers) + { + foreach (var pair in headers) + { + if (pair.Key.StartsWith("anthropic-ratelimit-tokens-", StringComparison.OrdinalIgnoreCase)) + { + return TimeSpan.FromSeconds(30); + } + } + + return null; + } +} +``` + +限流服务通过解析 `anthropic-ratelimit-tokens-*` 头部推断令牌配额状态,并用于动态退避与重试控制。 + +## 21.5 伙伴系统 + +```csharp +public interface ICompanionService +{ + Companion Create(string seed); +} + +public sealed class CompanionService : ICompanionService +{ + public Companion Create(string seed) + { + var prng = new Mulberry32(seed.GetHashCode()); + return new Companion( + Species: Species.Cat, + Eye: Eye.Blue, + Hat: Hat.Bowler, + Rarity: Rarity.Rare, + Name: $"Companion-{prng.NextInt(1000)}"); + } +} + +public sealed class Mulberry32 +{ + private uint _state; + public Mulberry32(int seed) => _state = (uint)seed; + public int NextInt(int max) => (int)(NextUInt() % (uint)max); + private uint NextUInt() + { + _state += 0x6D2B79F5; + var t = _state; + t = (t ^ (t >> 15)) * (t | 1); + t ^= t + (t ^ (t >> 7)) * (t | 61); + return t ^ (t >> 14); + } +} + +public sealed record Companion(Species Species, Eye Eye, Hat Hat, Rarity Rarity, string Name); + +public enum Species { Cat, Dog, Fox, Owl } +public enum Eye { Blue, Green, Gold, Red } +public enum Hat { None, Bowler, Cap, Crown } +public enum Rarity { Common, Uncommon, Rare, Epic, Legendary } +``` + +伙伴系统使用确定性生成逻辑:相同种子输入会产生相同角色结果,便于同步、复现和测试。 + +## 21.6 交叉引用 +- [服务子系统设计总览](服务子系统设计.md) +- [原始代码映射](reference/原始代码映射-服务子系统.md) +- [核心模块设计 — 查询引擎](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md) +- [基础设施设计 — 后台任务管理](../基础设施设计/基础设施设计-后台任务管理.md) +- [UI与扩展设计 — 特性开关系统](../UI与扩展设计/UI与扩展设计-特性开关系统.md) diff --git a/docs/服务子系统设计/服务子系统设计-认证与OAuth.md b/docs/服务子系统设计/服务子系统设计-认证与OAuth.md new file mode 100644 index 0000000..8835f30 --- /dev/null +++ b/docs/服务子系统设计/服务子系统设计-认证与OAuth.md @@ -0,0 +1,304 @@ +# 服务子系统设计 — 认证与 OAuth + +> **所属项目**: free-code .NET 10 重写 +> **文档类型**: 子模块设计 +> **原始源码**: `../../src/services/oauth/` +> **配套文档**: [服务子系统设计总览](服务子系统设计.md) | [参考映射](reference/原始代码映射-服务子系统.md) + +--- + +## 概述 + +认证模块负责管理用户身份凭证的完整生命周期,涵盖 OAuth 授权流程启动、回调处理、token 交换、安全存储和登出。原始 TypeScript 实现包含两条独立的 OAuth 流程:Anthropic 控制台 OAuth 和 OpenAI Codex OAuth,均通过本地 HTTP 监听器完成浏览器回调拦截。 + +.NET 10 重写将认证能力抽象为 `IAuthService` 接口,安全存储单独抽象为 `ISecureTokenStorage`,允许在不同平台(macOS Keychain、Windows DPAPI、Linux Secret Service)下替换存储后端。 + +--- + +## 19.1 IAuthService 接口 + +```csharp +/// +/// 认证服务接口 — 管理 OAuth 登录、登出和 token 获取 +/// 对应原始 ../../src/services/oauth/ 目录的整体能力 +/// +public interface IAuthService +{ + /// 当前是否已通过任意提供商完成认证 + bool IsAuthenticated { get; } + + /// 是否为 Claude.ai 用户(通过 claudeai_token 判断) + bool IsClaudeAiUser { get; } + + /// 是否为 Anthropic 内部用户 + bool IsInternalUser { get; } + + /// + /// 启动 OAuth 授权流程 + /// + /// 提供商名称,支持 "anthropic" 和 "codex" + Task LoginAsync(string provider = "anthropic"); + + /// 清除所有本地存储的 token,退出登录 + Task LogoutAsync(); + + /// 获取当前有效的 OAuth access token,未登录时返回 null + Task GetOAuthTokenAsync(); + + /// 认证状态发生变化时触发(登录或登出后) + event EventHandler? AuthStateChanged; +} +``` + +--- + +## 19.2 AuthService 实现 + +```csharp +/// +/// 认证服务实现 — Anthropic OAuth + Codex OAuth 双流程 +/// 对应原始 ../../src/services/oauth/anthropic.ts 和 openai.ts +/// +public sealed class AuthService : IAuthService +{ + private readonly ISecureTokenStorage _tokenStorage; + private readonly ILogger _logger; + + public bool IsAuthenticated => _tokenStorage.Get("oauth_token") != null; + public bool IsClaudeAiUser => _tokenStorage.Get("claudeai_token") != null; + public bool IsInternalUser => false; // Anthropic 内部用户标记,外部构建始终为 false + + public event EventHandler? AuthStateChanged; + + public AuthService(ISecureTokenStorage tokenStorage, ILogger logger) + { + _tokenStorage = tokenStorage; + _logger = logger; + } + + /// + /// Anthropic OAuth 流程 + /// 1. 启动本地 HTTP 监听器(拦截浏览器回调) + /// 2. 构建授权 URL 并打开浏览器 + /// 3. 等待回调获取 authorization code + /// 4. 用 code 交换 access token + refresh token + /// 5. 写入安全存储 + /// + public async Task LoginAsync(string provider = "anthropic") + { + if (provider == "anthropic") + { + // 1. 启动本地 HTTP 监听器(获取 OAuth 回调) + var callbackPort = GetAvailablePort(); + using var listener = new HttpListener(); + listener.Prefixes.Add($"http://localhost:{callbackPort}/"); + listener.Start(); + + // 2. 构建 OAuth 授权 URL + var authUrl = $"https://console.anthropic.com/oauth/authorize" + + $"?client_id=free-code-cli" + + $"&redirect_uri=http://localhost:{callbackPort}/" + + $"&response_type=code" + + $"&scope=openid profile email"; + + // 3. 打开浏览器 + OpenBrowser(authUrl); + + // 4. 等待回调 → 获取 code + var context = await listener.GetContextAsync(); + var code = context.Request.QueryString["code"]; + + // 5. 交换 token + using var httpClient = new HttpClient(); + var tokenResponse = await httpClient.PostAsync( + "https://console.anthropic.com/oauth/token", + new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = code ?? "", + ["redirect_uri"] = $"http://localhost:{callbackPort}/", + ["client_id"] = "free-code-cli", + })); + + var tokens = await tokenResponse.Content.ReadFromJsonAsync(); + _tokenStorage.Set("oauth_token", tokens?.AccessToken); + _tokenStorage.Set("oauth_refresh_token", tokens?.RefreshToken); + } + else if (provider == "codex") + { + // OpenAI Codex OAuth — 不同端点,流程相同 + var authUrl = "https://auth.openai.com/authorize" + + "?client_id=codex-cli" + + "&response_type=code" + + "&scope=openid profile email"; + // 后续 code exchange 流程与 Anthropic 流程对称 + } + + AuthStateChanged?.Invoke(this, EventArgs.Empty); + } + + public Task LogoutAsync() + { + _tokenStorage.Remove("oauth_token"); + _tokenStorage.Remove("oauth_refresh_token"); + _tokenStorage.Remove("claudeai_token"); + AuthStateChanged?.Invoke(this, EventArgs.Empty); + return Task.CompletedTask; + } + + public Task GetOAuthTokenAsync() => + Task.FromResult(_tokenStorage.Get("oauth_token")); + + private static int GetAvailablePort() + { + using var socket = new System.Net.Sockets.TcpListener( + System.Net.IPAddress.Loopback, 0); + socket.Start(); + var port = ((System.Net.IPEndPoint)socket.LocalEndpoint).Port; + socket.Stop(); + return port; + } + + private static void OpenBrowser(string url) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + Process.Start("open", url); + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + Process.Start("xdg-open", url); + else + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } +} + +/// +/// OAuth token 响应的反序列化模型 +/// +internal sealed record OAuthTokens +{ + [JsonPropertyName("access_token")] + public string? AccessToken { get; init; } + + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; init; } + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; init; } +} +``` + +--- + +## 19.3 ISecureTokenStorage 接口 + +```csharp +/// +/// 安全 token 存储接口 — 平台无关 +/// macOS: Keychain / Windows: DPAPI / Linux: Secret Service +/// +public interface ISecureTokenStorage +{ + /// 读取指定键的 token,不存在时返回 null + string? Get(string key); + + /// 写入指定键的 token,value 为 null 时等同于 Remove + void Set(string key, string? value); + + /// 删除指定键的 token + void Remove(string key); +} +``` + +--- + +## 19.4 KeychainTokenStorage — macOS Keychain 集成 + +```csharp +/// +/// macOS Keychain 安全存储实现 +/// 对应原始代码中对系统 Keychain 的直接访问 +/// 通过 security CLI 工具与 macOS Keychain Services 通信 +/// +public sealed class KeychainTokenStorage : ISecureTokenStorage +{ + /// + /// 读取 Keychain 中的 token + /// 等效命令: security find-generic-password -s free-code-{key} -w + /// + public string? Get(string key) + { + var psi = new ProcessStartInfo + { + FileName = "security", + Arguments = $"find-generic-password -s free-code-{key} -w", + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + using var process = Process.Start(psi); + var output = process?.StandardOutput.ReadToEnd().Trim(); + return string.IsNullOrEmpty(output) ? null : output; + } + + /// + /// 写入 token 到 Keychain(-U 标志: 不存在则创建,存在则更新) + /// 等效命令: security add-generic-password -U -s free-code-{key} -p {value} + /// + public void Set(string key, string? value) + { + if (value == null) { Remove(key); return; } + var psi = new ProcessStartInfo + { + FileName = "security", + Arguments = $"add-generic-password -U -s free-code-{key} -p {value}", + CreateNoWindow = true, + }; + Process.Start(psi)?.WaitForExit(); + } + + /// + /// 从 Keychain 删除 token + /// 等效命令: security delete-generic-password -s free-code-{key} + /// + public void Remove(string key) + { + var psi = new ProcessStartInfo + { + FileName = "security", + Arguments = $"delete-generic-password -s free-code-{key}", + CreateNoWindow = true, + }; + Process.Start(psi)?.WaitForExit(); + } +} +``` + +--- + +## 设计说明 + +**本地 HTTP 监听器模式** + +OAuth 授权码流程要求一个回调 URI。命令行工具无法注册自定义 URL Scheme(不同于桌面应用),因此采用本地 HTTP 监听器方案:随机选取可用端口,启动监听后打开浏览器,用户授权后浏览器重定向到 `localhost:{port}/?code=...`,CLI 截获 code 完成交换。这是命令行 OAuth 的通行做法,与 GitHub CLI、Azure CLI 的实现模式一致。 + +**Keychain 使用 CLI 而非 P/Invoke** + +原始 TypeScript 代码通过 Node.js 调用 `security` 命令行工具,.NET 重写保持相同策略。使用 `security` CLI 而非直接调用 Keychain Services C API,避免了 macOS 10.15+ 的 hardened runtime 代码签名限制,也无需 entitlements 配置。代价是每次读写有子进程启动开销,但认证操作频率极低,可以接受。 + +**跨平台存储抽象** + +`ISecureTokenStorage` 接口的引入使 DI 容器可以按运行平台注入不同实现。注册时可这样处理: + +```csharp +if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + services.AddSingleton(); +else + services.AddSingleton(); +``` + +--- + +## 参考资料 + +- [服务子系统设计总览](服务子系统设计.md) +- [原始代码映射 — 服务子系统](reference/原始代码映射-服务子系统.md) +- [核心模块设计 — API 提供商路由](../核心模块设计/核心模块设计-API提供商路由.md) diff --git a/docs/服务子系统设计/服务子系统设计.md b/docs/服务子系统设计/服务子系统设计.md new file mode 100644 index 0000000..a10fd59 --- /dev/null +++ b/docs/服务子系统设计/服务子系统设计.md @@ -0,0 +1,93 @@ +# 服务子系统设计 — 总览 + +> **所属项目**: free-code .NET 10 重写 +> **文档类型**: 模块索引 +> **对应源码**: `../../src/services/oauth/`, `../../src/utils/memory/`, `../../src/voice/` +> **配套文档**: [总体概述](../总体概述与技术选型/总体概述与技术选型.md) | [核心模块设计](../核心模块设计/核心模块设计.md) | [基础设施设计](../基础设施设计/基础设施设计.md) + +--- + +## 概述 + +服务子系统层封装了 free-code 运行时所需的各类横切关注点,包括身份认证、会话记忆、语音输入、通知推送、速率限制跟踪和同伴系统。这一层不直接参与 LLM 查询主路径,而是为核心模块和 UI 层提供基础能力。 + +原始 TypeScript 实现中,这些服务分散于 `../../src/services/oauth/`、`../../src/utils/memory/`、`../../src/voice/`、`../../src/buddy/` 等多个目录。.NET 10 重写将其整理为三个职责清晰的文档,每个服务通过接口向上层暴露,内部实现完全封装。 + +--- + +## 架构概览 + +``` +服务子系统 + │ + ├── IAuthService 认证与 OAuth(Anthropic + Codex) + │ └── ISecureTokenStorage Keychain / 跨平台安全存储 + │ + ├── ISessionMemoryService 会话记忆提取(阈值触发) + │ └── IAutoDreamService 后台记忆合并(24h / 会话数触发) + │ └── ITeamMemorySyncService 团队记忆 Git 同步 + │ + └── 其他服务 + ├── IRemoteSessionManager 远程会话管理 + ├── IVoiceService 语音输入(push-to-talk) + ├── INotificationService 终端通知(iTerm2 / Kitty / Ghostty) + ├── IRateLimitService API 速率限制跟踪 + └── ICompanionService 同伴系统(确定性 ASCII 宠物) +``` + +--- + +## 子模块列表 + +### [认证与 OAuth](服务子系统设计-认证与OAuth.md) + +覆盖 `IAuthService` 接口、`AuthService` 实现(Anthropic 和 Codex OAuth 流程),以及 `ISecureTokenStorage` 与 macOS Keychain 的集成。 + +- 原始源码: `../../src/services/oauth/` +- 核心类型: `IAuthService`、`AuthService`、`ISecureTokenStorage`、`KeychainTokenStorage` + +--- + +### [会话记忆与上下文](服务子系统设计-会话记忆与上下文.md) + +覆盖 `ISessionMemoryService` 的阈值触发提取机制、`IAutoDreamService` 的后台记忆合并循环,以及 `ITeamMemorySyncService` 的 Git 推拉与秘密扫描流程。 + +- 原始源码: `../../src/utils/memory/` +- 核心类型: `ISessionMemoryService`、`SessionMemoryService`、`IAutoDreamService`、`AutoDreamService`、`ITeamMemorySyncService`、`TeamMemorySyncService` + +--- + +### [其他服务子系统](服务子系统设计-其他服务子系统.md) + +覆盖远程会话、语音输入、终端通知、API 速率限制跟踪和同伴(Buddy)系统的完整接口与实现设计。 + +- 原始源码: `../../src/voice/`、`../../src/buddy/`、notification 系统、rate limit 头解析 +- 核心类型: `IRemoteSessionManager`、`IVoiceService`、`INotificationService`、`IRateLimitService`、`ICompanionService` + +--- + +## 关键设计决策 + +**接口驱动的安全存储** + +原始 TypeScript 代码直接调用 Keychain 命令行工具,与平台深度耦合。.NET 重写通过 `ISecureTokenStorage` 接口隔离平台细节,macOS 使用 `KeychainTokenStorage`,其他平台可替换为 DPAPI 或 Secret Service 实现,不影响上层逻辑。 + +**记忆提取采用轻量模型** + +`SessionMemoryService` 和 `AutoDreamService` 均使用 `claude-haiku-4-5` 执行提取和合并,而非主会话使用的 Opus/Sonnet 模型。这样既控制了成本,又不阻塞主查询路径(记忆操作在后台异步完成)。 + +**团队记忆的秘密扫描门控** + +`TeamMemorySyncService.PushAsync` 在提交到 Git 之前强制扫描变更内容,任何检测到 API key 或密码模式的内容都会中止推送并抛出异常,防止意外泄露敏感信息。 + +**同伴生成的确定性保证** + +`CompanionService` 使用 `HashString(userId + "friend-2026-401")` 作为种子,通过 Mulberry32 PRNG 生成同伴属性。相同的 userId 永远产生相同的同伴,确保用户在不同设备或重装后看到同一只宠物。 + +--- + +## 参考资料 + +- [原始代码映射 — 服务子系统](reference/原始代码映射-服务子系统.md) +- [核心模块设计 — 查询引擎](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md) +- [基础设施设计 — 后台任务管理](../基础设施设计/基础设施设计-后台任务管理.md) diff --git a/docs/核心模块设计/reference/原始代码映射-核心模块.md b/docs/核心模块设计/reference/原始代码映射-核心模块.md new file mode 100644 index 0000000..aa4e643 --- /dev/null +++ b/docs/核心模块设计/reference/原始代码映射-核心模块.md @@ -0,0 +1,115 @@ +# 原始代码映射 — 核心模块 + +> 所属项目: free-code .NET 10 重写 +> 文档类型: 参考映射表 +> 上级文档: [核心模块设计总览](../核心模块设计.md) + +--- + +## 说明 + +本文档建立 .NET 10 类型与原始 TypeScript 源文件之间的映射关系。每一行说明对应 .NET 类型重写了哪个 TS 文件的哪部分逻辑,以及重写时做出的关键设计决策。 + +--- + +## 核心模块映射表 + +### 启动与 CLI 解析 + +| .NET 类型 | 原始 TS 文件 | 对应逻辑 | 设计变化 | +|-----------|-------------|---------|---------| +| `Program` | `../../../src/entrypoints/cli.tsx` | 模块顶层的入口逻辑和条件分支 | 拆分为四阶段,引入 `IHostBuilder` 管理生命周期 | +| `QuickPathHandler` | `../../../src/entrypoints/cli.tsx` | 文件顶部散布的 `process.argv.includes()` 快速路径检查 | 集中到静态类,Phase 1 执行,无 DI 开销 | +| `CliCommandBuilder` | `../../../src/entrypoints/cli.tsx` | Commander.js 的 `program.option(...).action(...)` 配置 | 映射到 `System.CommandLine` 的 `RootCommand` + `Option` | +| `OneShotMode` | `../../../src/entrypoints/cli.tsx` | `-p` 参数触发的非交互式执行路径 | 独立类,与 REPL 模式分离 | +| `REPLMode` | `../../../src/screens/REPL.tsx` | React/Ink 交互式 UI 主循环 | 替换为基于 Spectre.Console 或原生终端 API 的 .NET UI | + +--- + +### 查询引擎 + +| .NET 类型 | 原始 TS 文件 | 对应逻辑 | 设计变化 | +|-----------|-------------|---------|---------| +| `IQueryEngine` | `../../../src/QueryEngine.ts` | 隐式的模块导出函数签名 | 显式接口,便于测试替换 | +| `QueryEngine` | `../../../src/QueryEngine.ts` | 核心消息循环(约 800 行)| 提取 System Prompt 构建为独立类;`IAsyncEnumerable` 替换回调式流 | +| `SubmitMessageOptions` | `../../../src/QueryEngine.ts` | `query()` 函数的参数对象 | `record` 类型,不可变,编译期类型安全 | +| `SystemPromptBuilder` | `../../../src/QueryEngine.ts` | 内联的 `getSystemPrompt()` 函数(约 200 行)| 提取为独立接口 `IPromptBuilder`,支持 DI 替换 | +| `SDKMessage`(联合类型)| `../../../src/types/message.ts` | `SDKMessage` TypeScript 联合类型 | C# discriminated union 通过继承体系实现 | +| `TokenUsage` | `../../../src/QueryEngine.ts` | 内联的 token 统计逻辑 | 独立 record,从 API 响应头提取 | + +--- + +### 工具系统 + +| .NET 类型 | 原始 TS 文件 | 对应逻辑 | 设计变化 | +|-----------|-------------|---------|---------| +| `ITool` | `../../../src/Tool.ts` | 工具基础接口定义(约 40 行)| 拆分为非泛型 `ITool` 和泛型 `ITool` 两层 | +| `ITool` | `../../../src/Tool.ts` | 工具执行方法签名 | 泛型化,输入/输出类型在编译期检查 | +| `ToolBase` | `../../../src/Tool.ts` | 工具基类的默认实现 | 抽象类,集成 FluentValidation 验证点 | +| `ToolExecutionContext` | `../../../src/Tool.ts` | 工具执行时接收的上下文对象 | `record` 类型,不可变,避免工具意外修改上下文 | +| `ToolCategory` | `../../../src/Tool.ts` | 工具分类枚举 | 直接映射,枚举成员一一对应 | +| `BashTool` | `../../../src/tools/BashTool.tsx` | Bash 工具完整实现 | `child_process.spawn` → `Process`;`Promise.race` → `.WaitAsync(timeout)` | +| `BashToolInput` | `../../../src/tools/BashTool.tsx` | Bash 工具输入类型 | TypeScript 接口 → C# `record`,不可变 | +| `BashToolOutput` | `../../../src/tools/BashTool.tsx` | Bash 工具输出类型 | 同上 | +| `ToolRegistry` | `../../../src/tools.ts` | `getTools()` 函数和工具数组 | 注册表类,懒加载缓存,稳定排序保证 prompt cache | +| `CommandClassifier` | `../../../src/tools/BashTool.tsx` | 命令只读性判断逻辑 | 提取为独立工具类 | + +--- + +### 命令系统 + +| .NET 类型 | 原始 TS 文件 | 对应逻辑 | 设计变化 | +|-----------|-------------|---------|---------| +| `ICommand` | `../../../src/commands.ts` | 命令对象的隐式接口约定 | 显式接口,`CommandCategory` 和 `CommandAvailability` 枚举化 | +| `CommandContext` | `../../../src/commands.ts` | 命令执行时传入的上下文 | 独立 record,包含会话状态和 UI 接口 | +| `CommandResult` | `../../../src/commands.ts` | 命令执行返回值 | `record(bool Success, string? Output)` | +| `CommandRegistry` | `../../../src/commands.ts` | `COMMAND_LIST` 数组及导出函数 | 注册表类,按认证状态和 feature flag 动态过滤 | +| `CommandCategory` | `../../../src/commands.ts` | `type: 'prompt' \| 'local' \| 'local-jsx'` | C# `enum`,JSX 对话框模式映射为 `LocalDialog` | +| `CommandAvailability` | `../../../src/commands.ts` | `availability: string` 字段 | C# `enum`,强类型替换字符串约定 | +| 各具体命令类 | `../../../src/commands/` | 各 `*Command.ts` 文件 | 一一对应,每个文件对应一个实现类 | + +--- + +### API 多提供商路由 + +| .NET 类型 | 原始 TS 文件 | 对应逻辑 | 设计变化 | +|-----------|-------------|---------|---------| +| `IApiProvider` | `../../../src/services/api/` | 各提供商模块的导出函数签名 | 统一抽象接口,所有提供商实现同一接口 | +| `ApiProviderRouter` | `../../../src/services/api/` | `process.env` 条件判断选择客户端 | 路由器类,构造时确定提供商,运行时不再判断 | +| `ApiProviderType` | `../../../src/services/api/` | 字符串常量区分提供商 | C# `enum`,编译期安全 | +| `ApiRequest` | `../../../src/services/api/` | API 调用参数对象 | `record`,不可变 | +| `AnthropicProvider` | `../../../src/services/api/claude.ts` | Anthropic SDK 流式调用(约 2000 行)| 移除 SDK 依赖,直接 `HttpClient` + 手动 SSE 解析 | +| `CodexProvider` | `../../../src/services/api/` | OpenAI Codex 客户端 | 对应原始 Codex fetch adapter | +| `BedrockProvider` | `../../../src/services/api/` | AWS Bedrock 客户端 | 使用 AWS SDK for .NET | +| `VertexProvider` | `../../../src/services/api/` | Google Vertex 客户端 | 使用 Google Cloud SDK for .NET | +| `FoundryProvider` | `../../../src/services/api/` | Anthropic Foundry 客户端 | 基于 `AnthropicProvider` 适配自定义端点 | + +--- + +## 未直接映射的原始文件 + +以下 `../../../src/` 文件的逻辑分散到了基础设施设计或服务子系统文档中: + +| 原始文件 | 重写位置 | +|----------|---------| +| `../../../src/state/` | [基础设施设计 — 状态管理](../../基础设施设计/基础设施设计-状态管理.md) | +| `../../../src/services/mcp*` | [基础设施设计 — MCP 协议集成](../../基础设施设计/基础设施设计-MCP协议集成.md) | +| `../../../src/services/oauth/` | [服务子系统设计 — 认证](../../服务子系统设计/) | +| `../../../src/skills/` | [服务子系统设计 — 技能系统](../../服务子系统设计/) | +| `../../../src/plugins/` | [服务子系统设计 — 插件系统](../../服务子系统设计/) | +| `../../../src/bridge/` | [服务子系统设计 — IDE 桥接](../../服务子系统设计/) | +| `../../../src/tasks/` | [服务子系统设计 — 后台任务](../../服务子系统设计/) | +| `../../../src/voice/` | [服务子系统设计 — 语音输入](../../服务子系统设计/) | +| `../../../src/components/` | [UI 与扩展设计](../../UI与扩展设计/) | +| `../../../src/hooks/` | [UI 与扩展设计](../../UI与扩展设计/) | + +--- + +## 参考资料 + +- [核心模块设计总览](../核心模块设计.md) +- [CLI 启动与解析](../核心模块设计-CLI启动与解析.md) +- [查询引擎 (QueryEngine)](../核心模块设计-查询引擎-QueryEngine.md) +- [工具系统](../核心模块设计-工具系统.md) +- [命令系统](../核心模块设计-命令系统.md) +- [API 多提供商路由](../核心模块设计-API提供商路由.md) diff --git a/docs/核心模块设计/核心模块设计-API提供商路由.md b/docs/核心模块设计/核心模块设计-API提供商路由.md new file mode 100644 index 0000000..8f9a91c --- /dev/null +++ b/docs/核心模块设计/核心模块设计-API提供商路由.md @@ -0,0 +1,200 @@ +# 核心模块设计 — API 多提供商路由 + +> 所属项目: free-code .NET 10 重写 +> 原始代码来源: `../../src/services/api/` +> 原始设计意图: 支持五种 API 提供商(Anthropic、OpenAI Codex、AWS Bedrock、Google Vertex、Anthropic Foundry)的统一路由,通过环境变量自动检测活跃提供商,并为每个提供商实现 SSE 流式解析 +> 上级文档: [核心模块设计总览](核心模块设计.md) + +--- + +## 概述 + +API 多提供商路由模块是 free-code 多后端能力的核心。它将具体 API 调用细节从 `QueryEngine` 中解耦出来,使查询引擎无需关心当前使用的是哪家服务商的 API。 + +原始 TypeScript 实现通过 `../../src/services/api/` 目录下的多个文件分别实现各提供商的客户端。.NET 重写引入 `IApiProvider` 接口统一抽象,通过 `ApiProviderRouter` 在运行时选择具体实现。 + +--- + +## 9.1 提供商枚举 + +```csharp +public enum ApiProviderType +{ + Anthropic, // 默认,直连 Anthropic API + OpenAICodex, // CLAUDE_CODE_USE_OPENAI=1 + AwsBedrock, // CLAUDE_CODE_USE_BEDROCK=1 + GoogleVertex, // CLAUDE_CODE_USE_VERTEX=1 + AnthropicFoundry // CLAUDE_CODE_USE_FOUNDRY=1 +} +``` + +--- + +## 9.2 IApiProvider 接口 + +```csharp +/// API提供商抽象接口 +public interface IApiProvider +{ + /// 发起流式 API 请求,返回 SSE 消息流 + IAsyncEnumerable StreamAsync( + ApiRequest request, + CancellationToken ct = default); +} + +public record ApiRequest( + string SystemPrompt, + IReadOnlyList Messages, + IReadOnlyList Tools, + string? Model = null +); +``` + +--- + +## 9.3 ApiProviderRouter — 路由器 + +`ApiProviderRouter` 在构造时检测环境变量,确定活跃提供商,后续每次调用 `GetActiveProvider()` 都返回对应的实例。 + +**原始设计意图:** 原始代码通过 `process.env` 检查选择不同的 API 客户端模块。.NET 版本将这一逻辑集中在路由器的构造函数中,避免散布在代码各处的条件判断。 + +```csharp +public class ApiProviderRouter : IApiProviderRouter +{ + private readonly IServiceProvider _services; + private ApiProviderType _activeProvider; + + public ApiProviderRouter(IServiceProvider services) + { + _services = services; + _activeProvider = DetectProvider(); // 从环境变量自动检测 + } + + private static ApiProviderType DetectProvider() + { + if (Env("CLAUDE_CODE_USE_OPENAI") == "1") return ApiProviderType.OpenAICodex; + if (Env("CLAUDE_CODE_USE_BEDROCK") == "1") return ApiProviderType.AwsBedrock; + if (Env("CLAUDE_CODE_USE_VERTEX") == "1") return ApiProviderType.GoogleVertex; + if (Env("CLAUDE_CODE_USE_FOUNDRY") == "1") return ApiProviderType.AnthropicFoundry; + return ApiProviderType.Anthropic; + } + + public IApiProvider GetActiveProvider() => _activeProvider switch + { + ApiProviderType.Anthropic => _services.GetRequiredService(), + ApiProviderType.OpenAICodex => _services.GetRequiredService(), + ApiProviderType.AwsBedrock => _services.GetRequiredService(), + ApiProviderType.GoogleVertex => _services.GetRequiredService(), + ApiProviderType.AnthropicFoundry => _services.GetRequiredService(), + _ => throw new InvalidOperationException() + }; + + private static string? Env(string name) => Environment.GetEnvironmentVariable(name); +} +``` + +检测优先级为:OpenAI > Bedrock > Vertex > Foundry > Anthropic(默认)。若同时设置多个环境变量,优先级靠前的生效。 + +--- + +## 9.4 AnthropicProvider — SSE 流式解析 + +`AnthropicProvider` 实现直连 Anthropic Messages API 的 SSE 流式解析,是五个提供商中最核心的实现。 + +**原始设计意图:** 原始 `claude.ts` 使用 Anthropic TypeScript SDK 处理流式响应。.NET 版本直接使用 `HttpClient` 发送 HTTP 请求,手动解析 SSE 事件行,避免引入额外 SDK 依赖。 + +```csharp +public class AnthropicProvider : IApiProvider +{ + private readonly HttpClient _httpClient; + + public async IAsyncEnumerable StreamAsync( + ApiRequest request, [EnumeratorCancellation] CancellationToken ct = default) + { + var payload = new { + model = request.Model ?? "claude-sonnet-4-6", + max_tokens = 16384, + stream = true, + system = request.SystemPrompt, + messages = request.Messages, + tools = request.Tools + }; + + var httpReq = new HttpRequestMessage(HttpMethod.Post, + $"{GetBaseUrl()}/v1/messages") + { + Content = JsonContent.Create(payload) + }; + httpReq.Headers.Add("x-api-key", GetApiKey()); + httpReq.Headers.Add("anthropic-version", "2023-06-01"); + + using var response = await _httpClient.SendAsync( + httpReq, HttpCompletionOption.ResponseHeadersRead, ct); + response.EnsureSuccessStatusCode(); + + // SSE 流式解析 + using var stream = await response.Content.ReadAsStreamAsync(ct); + using var reader = new StreamReader(stream); + + while (!reader.EndOfStream && !ct.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(ct); + if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue; + + var json = line[6..]; + var doc = JsonDocument.Parse(json); + var type = doc.RootElement.GetProperty("type").GetString(); + + switch (type) + { + case "content_block_delta": + yield return new SDKMessage.StreamingDelta( + doc.RootElement.GetProperty("delta") + .GetProperty("text").GetString()!); + break; + case "content_block_start": + var block = doc.RootElement.GetProperty("content_block"); + if (block.GetProperty("type").GetString() == "tool_use") + yield return new SDKMessage.ToolUseStart( + block.GetProperty("id").GetString()!, + block.GetProperty("name").GetString()!, + block.GetProperty("input")); + break; + case "message_stop": + yield break; + } + } + } +} +``` + +### SSE 事件类型映射 + +| SSE `type` | 映射到 `SDKMessage` 子类 | 说明 | +|------------|--------------------------|------| +| `content_block_delta` | `SDKMessage.StreamingDelta` | 文本流式增量 | +| `content_block_start` (tool_use) | `SDKMessage.ToolUseStart` | 工具调用开始 | +| `message_stop` | `yield break` | 流结束 | +| 其他 | 忽略 | `ping`、`message_start` 等元数据事件 | + +`HttpCompletionOption.ResponseHeadersRead` 确保在响应头返回后立即开始读取流,而不是等待整个响应体下载完成,这是 SSE 流式解析的关键设置。 + +--- + +## 9.5 提供商配置汇总 + +| 提供商 | 环境变量 | 认证方式 | 端点 | +|--------|----------|----------|------| +| Anthropic | 默认 | `ANTHROPIC_API_KEY` 或 OAuth | `https://api.anthropic.com` | +| OpenAI Codex | `CLAUDE_CODE_USE_OPENAI=1` | OAuth via OpenAI | `https://api.openai.com` | +| AWS Bedrock | `CLAUDE_CODE_USE_BEDROCK=1` | AWS 标准凭证链 | `AWS_REGION` 对应端点 | +| Google Vertex | `CLAUDE_CODE_USE_VERTEX=1` | GCP ADC | GCP 项目对应端点 | +| Anthropic Foundry | `CLAUDE_CODE_USE_FOUNDRY=1` | `ANTHROPIC_FOUNDRY_API_KEY` | 自定义部署端点 | + +--- + +## 参考资料 + +- [核心模块设计总览](核心模块设计.md) +- [查询引擎 (QueryEngine)](核心模块设计-查询引擎-QueryEngine.md) +- [原始代码映射 — 核心模块](reference/原始代码映射-核心模块.md) diff --git a/docs/核心模块设计/核心模块设计-CLI启动与解析.md b/docs/核心模块设计/核心模块设计-CLI启动与解析.md new file mode 100644 index 0000000..e23f278 --- /dev/null +++ b/docs/核心模块设计/核心模块设计-CLI启动与解析.md @@ -0,0 +1,245 @@ +# 核心模块设计 — CLI 启动与解析 + +> 所属项目: free-code .NET 10 重写 +> 原始代码来源: `../../src/entrypoints/cli.tsx`, `../../src/screens/REPL.tsx` +> 原始设计意图: 解析命令行参数,区分交互式 REPL 模式和一次性 prompt 模式,并处理 daemon/bridge 等特殊运行模式 +> 上级文档: [核心模块设计总览](核心模块设计.md) +> 交叉参考: [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md) + +--- + +## 概述 + +CLI 启动模块是整个应用的入口,对应原始 TypeScript 项目的 `cli.tsx`。它的核心职责是在加载任何业务逻辑之前,先判断这次调用属于哪种运行模式,并将控制权交给对应的处理器。 + +.NET 重写将原始代码中混合在一个文件里的逻辑拆分为三个独立的类: + +- `Program` — 四阶段启动协调器 +- `QuickPathHandler` — 无需 DI 容器的快速路径处理 +- `CliCommandBuilder` — 基于 `System.CommandLine` 的命令行定义 + +--- + +## 5.1 入口点 Program.cs + +`Program.cs` 组织为四个顺序执行的阶段,每一阶段都有明确的失败边界。 + +**原始设计意图:** 原始 `cli.tsx` 在模块顶层直接调用 Commander.js 解析参数,然后根据 flag 条件决定走哪条路径。.NET 版本将这一隐式流程显式化为四个阶段,并统一使用 `Microsoft.Extensions.Hosting` 管理生命周期。 + +```csharp +public static class Program +{ + public static async Task Main(string[] args) + { + // Phase 1: 快速路径(不加载DI容器,零开销) + if (QuickPathHandler.TryHandle(args, out var exitCode)) + return exitCode; + + // Phase 2: 构建Host + var host = Host.CreateDefaultBuilder(args) + .ConfigureAppConfiguration(cfg => cfg + .AddJsonFile("appsettings.json", optional: true) + .AddJsonFile(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".free-code", "settings.json"), optional: true, reloadOnChange: true)) + .ConfigureServices((ctx, services) => + { + services.AddCoreServices(); + services.AddEngine(); + services.AddTools(); + services.AddCommands(); + services.AddApiProviders(); + services.AddMcp(); + services.AddLsp(); + services.AddBridge(); + services.AddBusinessServices(); + services.AddTasks(); + services.AddSkills(); + services.AddPlugins(); + services.AddFeatures(); + services.AddState(); + services.AddTerminalUI(); + }) + .ConfigureLogging(logging => logging + .AddConsole() + .SetMinimumLevel(LogLevel.Information)) + .Build(); + + // Phase 3: 初始化核心服务 + await host.Services.GetRequiredService().InitializeAsync(); + + // Phase 4: 启动REPL或执行一次性命令 + var runner = host.Services.GetRequiredService(); + return await runner.RunAsync(args); + } +} +``` + +### 四阶段说明 + +| 阶段 | 职责 | 失败处理 | +|------|------|----------| +| Phase 1 | 快速路径检测,无 DI 开销 | 匹配则直接返回,不进入后续阶段 | +| Phase 2 | 构建 Host,注册所有服务 | `HostBuilder` 抛出则进程退出 | +| Phase 3 | 异步初始化(OAuth 刷新、MCP 连接等) | `InitializeAsync` 可抛出 `AppInitializationException` | +| Phase 4 | 启动 REPL 或执行一次性 prompt | `RunAsync` 返回退出码 | + +`reloadOnChange: true` 设置在 `~/.free-code/settings.json` 上,允许用户在 REPL 运行期间修改配置后立即生效,无需重启。 + +--- + +## 5.2 快速路径处理器 + +`QuickPathHandler` 对应原始 `cli.tsx` 中散布在文件顶部的 fast-path 条件判断。 + +**原始设计意图:** 原始代码在 Commander.js 解析之前,用一系列 `process.argv.includes()` 检查来处理特殊 flag,避免触发 React/Ink 的初始化开销。.NET 版本将这些检查集中到一个静态类,并且同样在 DI 容器构建之前执行。 + +```csharp +public static class QuickPathHandler +{ + public static bool TryHandle(string[] args, out int exitCode) + { + exitCode = 0; + + // --version + if (args.Contains("--version")) + { + var ver = Assembly.GetEntryAssembly()! + .GetCustomAttribute()! + .InformationalVersion; + Console.WriteLine($"free-code {ver}"); + return true; + } + + // --dump-system-prompt + if (args.Contains("--dump-system-prompt")) + { + var builder = new SystemPromptBuilder(/* minimal config */); + Console.WriteLine(builder.BuildDefaultPrompt()); + return true; + } + + // MCP daemon mode + if (args.Contains("--mcp-daemon")) + { + exitCode = McpDaemon.Run(args).GetAwaiter().GetResult(); + return true; + } + + // Bridge mode + if (args.Contains("--bridge")) + { + exitCode = BridgeMain.Run(args).GetAwaiter().GetResult(); + return true; + } + + // Background session mode + if (args.Contains("--background-session")) + { + exitCode = BackgroundSessionRunner.Run(args).GetAwaiter().GetResult(); + return true; + } + + // Template mode + if (args.Contains("--template")) + { + exitCode = TemplateRunner.Run(args).GetAwaiter().GetResult(); + return true; + } + + return false; + } +} +``` + +### 快速路径 flag 列表 + +| Flag | 处理器 | 说明 | +|------|--------|------| +| `--version` | 直接打印 | 从程序集元数据读取版本号 | +| `--dump-system-prompt` | `SystemPromptBuilder` | 打印默认 System Prompt,用于调试 | +| `--mcp-daemon` | `McpDaemon.Run` | 以 MCP 服务器模式运行,供 IDE 插件调用 | +| `--bridge` | `BridgeMain.Run` | IDE 远程控制桥接模式 | +| `--background-session` | `BackgroundSessionRunner.Run` | 后台会话模式,供 Agent 工具启动子进程 | +| `--template` | `TemplateRunner.Run` | 模板执行模式 | + +注意 `--mcp-daemon` 和 `--bridge` 的处理器使用 `.GetAwaiter().GetResult()` 同步等待,因为此时还没有 `async` 上下文可用(`Main` 的 `await` 路径已被 Phase 1 短路)。 + +--- + +## 5.3 CLI 命令定义 (CliCommandBuilder) + +`CliCommandBuilder` 对应原始 `cli.tsx` 中的 Commander.js 配置,负责定义全局选项和根命令的处理逻辑。 + +**原始设计意图:** Commander.js 的 `program.option(...).action(...)` 链式 API 被映射到 `System.CommandLine` 的 `RootCommand` + `Option` 模式。两者在概念上完全对应,只是 API 风格不同。 + +```csharp +public class CliCommandBuilder +{ + public RootCommand Build() + { + var root = new RootCommand("free-code - The free build of Claude Code"); + + // 全局选项 + var modelOption = new Option("--model", "Override default model"); + var verboseOption = new Option("--verbose", "Verbose output"); + var resumeOption = new Option("--resume", "Resume session ID"); + root.AddGlobalOption(modelOption); + root.AddGlobalOption(verboseOption); + root.AddGlobalOption(resumeOption); + + // 一次性 prompt 模式 (-p) + var promptOption = new Option("-p", "One-shot prompt (non-interactive)"); + root.AddOption(promptOption); + + root.SetHandler(async (string? prompt, string? model, bool verbose, string? resume) => + { + if (prompt != null) + return await OneShotMode.ExecuteAsync(prompt, model); + return await REPLMode.StartAsync(model, verbose, resume); + }, promptOption, modelOption, verboseOption, resumeOption); + + return root; + } +} +``` + +### 选项说明 + +| 选项 | 类型 | 作用域 | 说明 | +|------|------|--------|------| +| `--model` | `string?` | 全局 | 覆盖配置文件中的默认模型 | +| `--verbose` | `bool` | 全局 | 开启详细日志输出 | +| `--resume` | `string?` | 全局 | 恢复指定 session ID 的对话历史 | +| `-p` | `string?` | 根命令 | 非交互式一次性 prompt 模式 | + +`--model`、`--verbose`、`--resume` 作为全局选项注册,子命令也可以访问。`-p` 仅在根命令级别有效,因为子命令有各自的参数结构。 + +当 `-p` 存在时,走 `OneShotMode.ExecuteAsync` 路径(非交互式,执行完毕后退出)。否则走 `REPLMode.StartAsync` 启动交互式终端 UI。 + +--- + +## 模块间依赖 + +``` +Program.cs + ├── QuickPathHandler (静态,无依赖) + │ ├── SystemPromptBuilder (仅 --dump-system-prompt 路径) + │ ├── McpDaemon + │ ├── BridgeMain + │ ├── BackgroundSessionRunner + │ └── TemplateRunner + │ + └── CliCommandBuilder (由 IAppRunner 使用) + ├── OneShotMode + └── REPLMode +``` + +--- + +## 参考资料 + +- [核心模块设计总览](核心模块设计.md) +- [查询引擎 (QueryEngine)](核心模块设计-查询引擎-QueryEngine.md) +- [原始代码映射 — 核心模块](reference/原始代码映射-核心模块.md) +- [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md) diff --git a/docs/核心模块设计/核心模块设计-命令系统.md b/docs/核心模块设计/核心模块设计-命令系统.md new file mode 100644 index 0000000..3d7f96d --- /dev/null +++ b/docs/核心模块设计/核心模块设计-命令系统.md @@ -0,0 +1,191 @@ +# 核心模块设计 — 命令系统 + +> 所属项目: free-code .NET 10 重写 +> 原始代码来源: `../../src/commands.ts`, `../../src/commands/` +> 原始设计意图: 定义并注册所有斜杠命令(/command),提供会话管理、配置变更、认证操作等用户可交互的命令集合 +> 上级文档: [核心模块设计总览](核心模块设计.md) + +--- + +## 概述 + +命令系统管理用户在 REPL 中输入的斜杠命令(如 `/clear`、`/model`、`/help`)。它与工具系统平行,但面向的是用户直接操作而非 Agent 的自主调用。 + +原始 TypeScript 实现在 `commands.ts` 中以数组形式注册约 80 个命令对象,每个对象实现固定的接口字段。.NET 重写引入 `ICommand` 接口和 `CommandRegistry` 注册表,通过 DI 容器管理命令生命周期,并支持基于认证状态和 feature flag 的动态可用性控制。 + +--- + +## 8.1 ICommand 接口 + +```csharp +public interface ICommand +{ + string Name { get; } + string[]? Aliases { get; } + string Description { get; } + CommandCategory Category { get; } // Prompt, Local, LocalDialog + CommandAvailability Availability { get; } // Always, RequiresAuth, ClaudeAiOnly, InternalOnly + bool IsEnabled(); + Task ExecuteAsync( + CommandContext context, string? args = null, CancellationToken ct = default); +} + +public enum CommandCategory { Prompt, Local, LocalDialog } +public enum CommandAvailability { Always, RequiresAuth, ClaudeAiOnly, InternalOnly } +public record CommandResult(bool Success, string? Output = null); +``` + +### 命令分类说明 + +`CommandCategory` 决定命令的执行方式: + +| 类别 | 说明 | +|------|------| +| `Prompt` | 命令内容作为 prompt 发送给 LLM(如 `/commit` 生成提交信息) | +| `Local` | 在本地直接执行,不经过 LLM(如 `/clear` 清空历史) | +| `LocalDialog` | 在本地执行并打开对话框(如 `/config` 弹出配置界面) | + +`CommandAvailability` 控制命令的可见性: + +| 可用性 | 显示条件 | +|--------|---------| +| `Always` | 始终可用 | +| `RequiresAuth` | 需要已登录任意账号 | +| `ClaudeAiOnly` | 仅 Claude.ai 账号用户可见 | +| `InternalOnly` | 仅 Anthropic 内部用户可见 | + +--- + +## 8.2 CommandRegistry 命令注册表 + +`CommandRegistry` 完整注册 70+ 个命令,并提供基于认证状态的过滤能力。 + +```csharp +public class CommandRegistry : ICommandRegistry +{ + private readonly IServiceProvider _services; + private readonly IFeatureFlagService _features; + private readonly IAuthService _authService; + private List? _cachedCommands; + + public async Task> GetCommandsAsync() + { + if (_cachedCommands != null) return _cachedCommands; + _cachedCommands = new List(); + + // === 核心命令 (~65个) === + // 会话管理 + Add(); Add(); Add(); + Add(); Add(); + // 配置 + Add(); Add(); Add(); + Add(); Add(); Add(); + // 认证 + Add(); Add(); + // 状态与统计 + Add(); Add(); Add(); Add(); + // 工具与诊断 + Add(); Add(); Add(); + Add(); Add(); + // Git 集成 + Add(); Add(); Add(); + // MCP / LSP / Hooks + Add(); Add(); Add(); + // 扩展系统 + Add(); Add(); + // 杂项 + Add(); Add(); Add(); + Add(); Add(); Add(); + Add(); Add(); Add(); + Add(); Add(); + Add(); Add(); + Add(); Add(); Add(); + Add(); Add(); Add(); + Add(); Add(); + Add(); Add(); + Add(); Add(); + Add(); Add(); Add(); + Add(); Add(); Add(); + Add(); Add(); Add(); + Add(); Add(); + + // === 条件命令 === + if (_features.IsEnabled(FeatureFlags.Buddy)) + Add(); + if (_features.IsEnabled(FeatureFlags.Ultraplan)) + Add(); + Add(); Add(); + + // === 内部命令 (~15个) === + Add(); Add(); + Add(); Add(); + Add(); Add(); + Add(); Add(); + Add(); Add(); + Add(); Add(); + Add(); Add(); + Add(); + + return _cachedCommands; + } + + public async Task> GetEnabledCommandsAsync() + { + var all = await GetCommandsAsync(); + return all.Where(c => c.IsEnabled() && MeetsAvailability(c)).ToList(); + } + + private bool MeetsAvailability(ICommand cmd) => cmd.Availability switch + { + CommandAvailability.Always => true, + CommandAvailability.RequiresAuth => _authService.IsAuthenticated, + CommandAvailability.ClaudeAiOnly => _authService.IsClaudeAiUser, + CommandAvailability.InternalOnly => _authService.IsInternalUser, + _ => false + }; + + private void Add() where T : ICommand => + _cachedCommands!.Add(_services.GetRequiredService()); +} +``` + +--- + +## 8.3 命令分组统计 + +| 分组 | 命令数量 | 说明 | +|------|----------|------| +| 会话管理 | 5 | `session`、`resume`、`rename`、`clear`、`export` | +| 配置 | 6 | `config`、`model`、`theme`、`color`、`output-style`、`keybindings` | +| 认证 | 2 | `login`、`logout` | +| 状态与统计 | 4 | `status`、`cost`、`stats`、`extra-usage` | +| 工具与诊断 | 5 | `diff`、`copy`、`doctor`、`memory`、`agents` | +| Git 集成 | 3 | `commit`、`commit-push-pr`、`branch` | +| MCP/LSP/Hooks | 3 | `mcp`、`hooks`、`files` | +| 扩展系统 | 2 | `skills`、`plugin` | +| 杂项(核心) | ~30 | `help`、`exit`、`version`、`add-dir` 等 | +| 条件命令 | 2-4 | `btw`(BUDDY)、`ultraplan`(ULTRAPLAN)、`thinkback` | +| 内部命令 | 15 | `heapdump`、`mock-limits`、`bughunter` 等 | + +--- + +## 8.4 与工具系统的区别 + +命令系统和工具系统都是 free-code 的扩展点,但职责不同: + +| 维度 | 命令系统 | 工具系统 | +|------|----------|----------| +| 触发方 | 用户(输入 `/command`) | Agent 自主决策 | +| 输入格式 | 斜杠 + 可选文本参数 | 结构化 JSON(JSON Schema 验证) | +| 出现在 System Prompt | 是(命令描述段) | 是(工具描述段) | +| 权限控制粒度 | `CommandAvailability` 枚举 | `PermissionEngine` + `ToolPermissionContext` | +| 执行上下文 | `CommandContext` | `ToolExecutionContext` | + +--- + +## 参考资料 + +- [核心模块设计总览](核心模块设计.md) +- [工具系统](核心模块设计-工具系统.md) +- [查询引擎 (QueryEngine)](核心模块设计-查询引擎-QueryEngine.md) +- [原始代码映射 — 核心模块](reference/原始代码映射-核心模块.md) diff --git a/docs/核心模块设计/核心模块设计-多代理协调.md b/docs/核心模块设计/核心模块设计-多代理协调.md new file mode 100644 index 0000000..253c9b9 --- /dev/null +++ b/docs/核心模块设计/核心模块设计-多代理协调.md @@ -0,0 +1,301 @@ +# 核心模块设计 — 多代理协调器 (Coordinator) + +> 所属项目: free-code .NET 10 重写 +> 文档类型: 核心模块设计 +> 原始代码来源: `../../src/coordinator/`、`../../src/assistant/` +> 原始设计意图: 将单轮 LLM 交互扩展为多代理编排,负责 worker 生成、消息路由、团队管理与协调模式切换 +> 上级文档: [核心模块设计总览](核心模块设计.md) +> 交叉参考: [工具系统](核心模块设计-工具系统.md) | [后台任务管理](../基础设施设计/基础设施设计-后台任务管理.md) + +--- + +## 概述 + +多代理协调器是 free-code 中用于“把工作拆给多个 worker”的编排层。它不是简单的工具调用器,而是面向复杂任务的调度中枢:当用户问题可被拆分为研究、实现、验证等多个并行分支时,协调器负责创建 worker、下发上下文、接收回传结果,并在需要时继续同一 worker 的上下文。 + +原始源码中,`../../src/coordinator/coordinatorMode.ts` 承担了本模块的核心语义:定义 coordinator mode 的开关、worker 可用工具集合、系统提示词、session mode 匹配逻辑,以及 worker 结果的消息协议。`../../src/assistant/` 则提供与会话历史、会话筛选和辅助选择相关的配套能力,可视为协调器在“恢复旧会话”和“读取历史事件”场景下的支撑模块。 + +在 .NET 10 重写中,本模块应被抽象为独立服务层,避免与 QueryEngine、工具系统、后台任务管理直接耦合,同时保留原有行为: + +- 允许 coordinator 生成 worker 并并发分发任务 +- 允许 worker 通过消息队列继续执行 +- 允许在会话恢复时自动匹配 coordinator/normal 模式 +- 允许读取会话历史以重建协作上下文 + +--- + +## 设计职责 + +### 1. 多代理任务编排 + +协调器负责将一个高层目标拆解为多个子任务,并为每个子任务创建独立 worker。worker 适合执行以下类型工作: + +- 代码库研究 +- 定向实现 +- 验证与测试 +- 失败修复 + +### 2. worker 生命周期管理 + +协调器需掌握 worker 的创建、继续、停止与结果归档。worker 不是一次性调用;其价值在于保留已加载上下文后继续推进。 + +### 3. 代理间消息路由 + +worker 的结果以结构化任务通知返回,协调器再据此向用户总结,或把后续指令发回同一个 worker。 + +### 4. 团队协作管理 + +协调器支持 team create / delete / send-message 这类“代理团队”操作,用于将多个 worker 视为一个协作单元管理。 + +### 5. 会话模式同步 + +当用户恢复旧会话时,协调器需要根据保存的 session mode 自动切换当前环境变量,确保继续运行时处于正确的 coordinator/normal 模式。 + +--- + +## 原始实现要点 + +### `../../src/coordinator/coordinatorMode.ts` + +该文件是本模块的语义核心,包含以下能力: + +#### coordinator 模式开关 + +`isCoordinatorMode()` 通过 feature gate `COORDINATOR_MODE` 和环境变量 `CLAUDE_CODE_COORDINATOR_MODE` 判断当前是否进入协调模式。 + +#### 会话模式匹配 + +`matchSessionMode(sessionMode)` 会比较当前模式与已恢复会话记录的模式: + +- 若一致,直接返回 +- 若不一致,自动翻转环境变量 +- 同时记录 analytics 事件 + +这说明 coordinator 是“会话状态敏感”的,而不是一个纯工具注册器。 + +#### worker 工具上下文 + +`getCoordinatorUserContext(mcpClients, scratchpadDir)` 会为 worker 注入可用工具说明: + +- 简化模式下仅暴露 Bash / Read / Edit +- 常规模式下暴露 async agent 允许工具集 +- 追加 MCP 服务名 +- 在 scratchpad gate 开启时暴露 scratchpad 目录 + +#### coordinator 系统提示词 + +`getCoordinatorSystemPrompt()` 描述了 coordinator 的核心职责: + +- 直接回答能答的问题 +- 用 worker 处理适合并行化的工作 +- 只能把新信息总结给用户,不能把 worker 当作对话对象 +- 通过 `Agent`、`SendMessage`、`TaskStop` 管理 worker 生命周期 + +同时它还明确了 worker 结果的 `` 协议格式。 + +### `../../src/coordinator/workerAgent.ts` + +当前快照中该文件为占位实现,但从模块命名可知,它原本承载 worker agent 的启动入口或 worker 代理适配层。在 .NET 重写里,这一职责应由 worker 启动器与任务执行器拆分承接。 + +### `../../src/assistant/*` + +`../../src/assistant/sessionHistory.ts` 提供了会话事件分页读取: + +- 先构造 OAuth 请求上下文 +- 再按 `anchor_to_latest` / `before_id` 拉取分页事件 +- 返回历史事件、游标与是否还有更旧内容 + +`sessionDiscovery.ts`、`gate.ts`、`index.ts`、`AssistantSessionChooser.tsx` 在本快照中多为占位或轻量 UI 适配,但它们体现出 assistant 模块的定位:用于发现、筛选、恢复与展示历史会话。 + +--- + +## .NET 10 接口设计 + +### ICoordinatorService + +```csharp +public interface ICoordinatorService +{ + bool IsCoordinatorMode { get; } + + string? MatchSessionMode(SessionMode? sessionMode); + + string BuildCoordinatorSystemPrompt(CoordinatorPromptContext context); + + CoordinatorUserContext BuildWorkerContext( + IReadOnlyList mcpClients, + string? scratchpadDirectory = null); + + Task SpawnWorkerAsync( + SpawnWorkerRequest request, + CancellationToken cancellationToken = default); + + Task SendMessageAsync( + string workerId, + string message, + CancellationToken cancellationToken = default); + + Task StopWorkerAsync( + string workerId, + CancellationToken cancellationToken = default); + + Task GetWorkerResultAsync(string workerId); + + Task CreateTeamAsync(CreateTeamRequest request, + CancellationToken cancellationToken = default); + + Task DeleteTeamAsync(string teamId, + CancellationToken cancellationToken = default); + + Task SendTeamMessageAsync(string teamId, string message, + CancellationToken cancellationToken = default); +} +``` + +### IAgentWorker + +```csharp +public interface IAgentWorker +{ + string WorkerId { get; } + string Description { get; } + WorkerStatus Status { get; } + + Task StartAsync(CancellationToken cancellationToken); + Task ContinueAsync(string message, CancellationToken cancellationToken); + Task StopAsync(CancellationToken cancellationToken); + Task CaptureSnapshotAsync(); +} +``` + +### 支撑模型 + +```csharp +public enum SessionMode { Normal, Coordinator } + +public sealed record CoordinatorPromptContext( + string WorkingDirectory, + string? ScratchpadDirectory, + IReadOnlyList AllowedTools, + IReadOnlyList McpServerNames); + +public sealed record CoordinatorUserContext( + string WorkerToolsContext, + string SystemPrompt); + +public sealed record SpawnWorkerRequest( + string Description, + string Prompt, + string WorkerType, + string? Model = null, + string? TeamId = null); +``` + +这些类型遵循 .NET 命名习惯,使用 PascalCase,并把“提示词构建”和“worker 运行实例”分离,避免协调器直接依赖底层 LLM 调用细节。 + +--- + +## Worker 生成与生命周期 + +### 生成流程 + +1. 协调器接收高层目标 +2. 判断是否适合并行拆分 +3. 为每个分支构造自包含 prompt +4. 通过 worker 工厂创建 `IAgentWorker` +5. 记录 worker 到会话/任务存储 + +### 生命周期阶段 + +| 阶段 | 说明 | +|------|------| +| Created | worker 已创建但未启动 | +| Running | worker 正在执行 | +| Waiting | worker 暂停等待继续指令 | +| Completed | worker 已完成任务 | +| Failed | worker 执行失败 | +| Stopped | worker 被协调器主动停止 | + +### 生命周期原则 + +- worker 应保留自己的上下文快照 +- 继续 worker 时优先复用旧上下文,而不是重新启动新 worker +- 停止 worker 应保留最后状态,供后续诊断或继续执行 + +--- + +## 消息路由 + +### 结果回传协议 + +原始实现定义了 `` XML 作为 worker 结果消息格式。.NET 重写可保留相同语义,但建议使用结构化模型解析后再渲染给终端: + +```csharp +public sealed record WorkerNotification( + string TaskId, + string Status, + string Summary, + string? Result, + WorkerUsage? Usage); +``` + +### 路由规则 + +- worker → coordinator:上报任务完成、失败、暂停、进度 +- coordinator → worker:发送续作指令、修正指令、停止指令 +- coordinator → 用户:仅输出总结,不暴露内部通知原文 + +### 设计约束 + +- 不能用一个 worker 检查另一个 worker +- 不能把低价值文件内容回读任务交给 worker +- 继续 worker 时必须使用其原始 task id + +--- + +## Agent Team 管理 + +team 机制用于把多个 worker 归入一个协作单元,常见场景是“研究组 + 实现组 + 验证组”。 + +### TeamCreate + +创建 team 时应生成团队 ID,并维护成员 worker、目标、状态与消息历史。 + +### TeamDelete + +删除 team 应清理团队元数据,但不能无条件销毁已完成 worker 的结果记录。 + +### SendMessage + +team message 应广播到 team 成员,或按策略路由给主 worker。 .NET 设计应把广播规则显式化,避免隐式分支。 + +--- + +## 关联 `../../src/assistant/` 的 .NET 设计 + +`../../src/assistant/` 更适合映射为“会话访问与恢复辅助服务”,建议拆为: + +- `IAssistantSessionHistoryService`:读取会话事件分页 +- `IAssistantSessionDiscoveryService`:发现可恢复会话 +- `IAssistantSessionChooser`:会话选择 UI 适配层 + +其中 `sessionHistory.ts` 的分页逻辑适合直接迁移为可测试的基础设施服务,而非放进协调器主服务中。 + +--- + +## 设计要点 + +- coordinator 是编排层,不是 worker 执行层 +- worker 适合做局部自治,coordinator 负责全局收敛 +- 会话模式必须可恢复、可切换、可记录 +- worker prompt 必须自包含,不能依赖隐式上下文 +- 协调器输出面向用户,内部通知面向系统 + +--- + +## 参考资料 + +- [核心模块设计总览](核心模块设计.md) +- [工具系统](核心模块设计-工具系统.md) +- [后台任务管理](../基础设施设计/基础设施设计-后台任务管理.md) +- [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md) diff --git a/docs/核心模块设计/核心模块设计-工具系统.md b/docs/核心模块设计/核心模块设计-工具系统.md new file mode 100644 index 0000000..ff09492 --- /dev/null +++ b/docs/核心模块设计/核心模块设计-工具系统.md @@ -0,0 +1,359 @@ +# 核心模块设计 — 工具系统 + +> 所属项目: free-code .NET 10 重写 +> 原始代码来源: `../../src/tools.ts`, `../../src/tools/` +> 原始设计意图: 定义 Agent 可调用工具的接口体系,提供工具基类复用逻辑,并统一管理内置工具与 MCP 工具的组装与去重 +> 上级文档: [核心模块设计总览](核心模块设计.md) +> 交叉参考: [查询引擎 (QueryEngine)](核心模块设计-查询引擎-QueryEngine.md) | [基础设施设计 — MCP 协议集成](../基础设施设计/基础设施设计-MCP协议集成.md) + +--- + +## 概述 + +工具系统是 Agent 行为能力的载体。每一个工具对应 Agent 可以执行的一类操作,从执行 shell 命令(`BashTool`)到读写文件、搜索代码、访问网络,再到调用外部 MCP 服务。 + +原始 TypeScript 实现在 `tools.ts` 注册工具,在 `../../src/tools/` 目录下实现每个工具,工具本身是对象字面量或类实例。.NET 重写引入了完整的接口层次和抽象基类,使工具实现更标准化,并通过 DI 容器统一管理生命周期。 + +--- + +## 7.1 核心接口 + +### ITool — 工具基础接口 + +```csharp +/// Agent工具基础接口 +public interface ITool +{ + string Name { get; } + string[]? Aliases { get; } + string? SearchHint { get; } + ToolCategory Category { get; } + bool IsEnabled(); + JsonElement GetInputSchema(); + Task GetDescriptionAsync(object? input = null); + bool IsConcurrencySafe(object input); + bool IsReadOnly(object input); +} +``` + +### ITool\ — 泛型工具接口 + +```csharp +/// 泛型工具接口 +public interface ITool : ITool where TInput : class +{ + Task> ExecuteAsync( + TInput input, ToolExecutionContext context, CancellationToken ct = default); + Task ValidateInputAsync(TInput input); + Task CheckPermissionAsync(TInput input, ToolExecutionContext context); +} +``` + +### 支撑类型 + +```csharp +public record ToolResult( + T Data, + bool IsError = false, + string? ErrorMessage = null, + List? SideMessages = null +); + +public record ToolExecutionContext( + string WorkingDirectory, + PermissionMode PermissionMode, + IReadOnlyList AdditionalWorkingDirectories, + IPermissionEngine PermissionEngine, + ILspClientManager LspManager, + IBackgroundTaskManager TaskManager, + IServiceProvider Services +); + +public enum ToolCategory +{ + FileSystem, Shell, Agent, Web, Lsp, Mcp, + UserInteraction, Todo, Task, PlanMode, + AgentSwarm, Worktree, Config +} +``` + +`ToolExecutionContext` 是执行时上下文,包含工具执行所需的所有环境信息。工具实现通过 `context` 参数访问当前工作目录、权限引擎、LSP 客户端等,而不是直接依赖全局状态。 + +--- + +## 7.2 工具基类 ToolBase\ + +`ToolBase` 为工具实现提供默认行为,减少每个具体工具需要编写的样板代码。 + +**原始设计意图:** 原始 TypeScript 工具通过对象字面量共享一些约定,但没有强制继承关系。.NET 的抽象基类在编译期保证所有工具遵循统一接口,并为验证逻辑提供可选的 FluentValidation 集成点。 + +```csharp +public abstract class ToolBase : ITool + where TInput : class +{ + public abstract string Name { get; } + public virtual string[]? Aliases => null; + public virtual string? SearchHint => null; + public abstract ToolCategory Category { get; } + public virtual bool IsEnabled() => true; + public abstract JsonElement GetInputSchema(); + + public virtual Task GetDescriptionAsync(object? input = null) + => Task.FromResult($"Execute {Name}"); + + public abstract bool IsConcurrencySafe(TInput input); + public abstract bool IsReadOnly(TInput input); + public abstract Task> ExecuteAsync( + TInput input, ToolExecutionContext context, CancellationToken ct); + + public virtual Task ValidateInputAsync(TInput input) + { + var validator = GetValidator(); + if (validator != null) + { + var result = validator.Validate(input); + return Task.FromResult(result.IsValid + ? ValidationResult.Success() + : ValidationResult.Failure(result.Errors.Select(e => e.ErrorMessage))); + } + return Task.FromResult(ValidationResult.Success()); + } + + public virtual Task CheckPermissionAsync( + TInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + protected virtual IValidator? GetValidator() => null; +} +``` + +子类只需实现 `Name`、`Category`、`GetInputSchema`、`IsConcurrencySafe`、`IsReadOnly` 和 `ExecuteAsync`。验证逻辑通过重写 `GetValidator()` 返回一个 FluentValidation 的 `IValidator` 实例来接入。 + +--- + +## 7.3 BashTool 完整实现 + +`BashTool` 是最核心也最复杂的工具,展示了工具系统的完整实现模式。 + +**原始设计意图:** 原始 `BashTool.tsx` 使用 Node.js 的 `child_process.spawn` 执行命令,通过 Promise 管理超时,并通过 `IBackgroundTaskManager` 支持后台执行。.NET 版本使用 `Process` 和 `WaitForExitAsync` + `.WaitAsync(timeout)` 实现相同语义。 + +```csharp +public class BashTool : ToolBase +{ + public override string Name => "Bash"; + public override ToolCategory Category => ToolCategory.Shell; + + private readonly IBackgroundTaskManager _taskManager; + private readonly IFeatureFlagService _features; + + public override async Task> ExecuteAsync( + BashToolInput input, ToolExecutionContext context, CancellationToken ct) + { + var psi = new ProcessStartInfo + { + FileName = "/bin/bash", + Arguments = $"-c \"{input.Command.Replace("\"", "\\\"")}\"", + WorkingDirectory = context.WorkingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + // 沙箱 + if (!input.DangerouslyDisableSandbox) + ApplySandboxRestrictions(psi, context); + + // 后台执行 + if (input.RunInBackground) + return await RunInBackgroundAsync(input, psi, ct); + + // 前台执行 + using var process = new Process { StartInfo = psi }; + process.Start(); + + var timeoutMs = input.Timeout ?? 120_000; + try + { + await process.WaitForExitAsync(ct) + .WaitAsync(TimeSpan.FromMilliseconds(timeoutMs), ct); + } + catch (TimeoutException) + { + process.Kill(entireProcessTree: true); + return new ToolResult(new BashToolOutput + { + Stdout = await process.StandardOutput.ReadToEndAsync(ct), + Stderr = await process.StandardError.ReadToEndAsync(ct), + ExitCode = -1, + Interrupted = true + }); + } + + var stdout = await process.StandardOutput.ReadToEndAsync(ct); + var stderr = await process.StandardError.ReadToEndAsync(ct); + + return new ToolResult(new BashToolOutput + { + Stdout = stdout, Stderr = stderr, + ExitCode = process.ExitCode, Interrupted = false + }); + } + + private async Task> RunInBackgroundAsync( + BashToolInput input, ProcessStartInfo psi, CancellationToken ct) + { + var task = await _taskManager.CreateShellTaskAsync(input.Command, psi); + return new ToolResult(new BashToolOutput + { + Stdout = $"Background task started: {task.TaskId}", + BackgroundTaskId = task.TaskId + }); + } + + public override bool IsConcurrencySafe(BashToolInput input) => false; + public override bool IsReadOnly(BashToolInput input) => + CommandClassifier.IsReadCommand(input.Command); +} +``` + +### BashTool 输入/输出类型 + +```csharp +public record BashToolInput +{ + public string Command { get; init; } = ""; + public int? Timeout { get; init; } + public string? Description { get; init; } + public bool RunInBackground { get; init; } + public bool DangerouslyDisableSandbox { get; init; } +} + +public record BashToolOutput +{ + public string Stdout { get; init; } = ""; + public string Stderr { get; init; } = ""; + public int ExitCode { get; init; } + public bool Interrupted { get; init; } + public string? BackgroundTaskId { get; init; } +} +``` + +`IsConcurrencySafe` 返回 `false` 表示 Bash 命令不能并发执行(防止状态污染)。`IsReadOnly` 委托给 `CommandClassifier.IsReadCommand`,该工具通过命令前缀分析判断是否为只读操作(如 `ls`、`cat`、`grep` 等),只读命令在某些权限模式下可以跳过确认。 + +--- + +## 7.4 ToolRegistry — 工具池组装与 MCP 去重 + +`ToolRegistry` 负责将内置工具和 MCP 工具组装为统一的工具池,并应用去重和权限过滤规则。 + +```csharp +public class ToolRegistry : IToolRegistry +{ + private readonly IServiceProvider _services; + private readonly IFeatureFlagService _features; + private readonly IMcpClientManager _mcpManager; + private List? _cachedBaseTools; + + public async Task> GetToolsAsync( + ToolPermissionContext? permissionContext = null) + { + var baseTools = GetBaseTools(); + var mcpTools = await _mcpManager.GetToolsAsync(); + var pool = AssembleToolPool(baseTools, mcpTools); + + if (permissionContext != null) + pool = FilterByDenyRules(pool, permissionContext); + + return pool; + } + + private List GetBaseTools() + { + if (_cachedBaseTools != null) return _cachedBaseTools; + _cachedBaseTools = new List(); + + // 核心20个工具 (始终可用) + _cachedBaseTools.AddRange(new ITool[] { + Get(), Get(), Get(), + Get(), Get(), Get(), + Get(), Get(), Get(), + Get(), Get(), Get(), + Get(), Get(), Get(), + Get(), Get(), + Get(), Get(), Get(), + }); + + // 条件工具 (feature-flagged) + if (_features.IsEnabled(FeatureFlags.AgentTriggers)) + { + _cachedBaseTools.Add(Get()); + _cachedBaseTools.Add(Get()); + } + + if (_features.IsEnabled(FeatureFlags.V2Todo)) + { + _cachedBaseTools.AddRange(new ITool[] { + Get(), Get(), Get(), + Get(), Get(), + }); + } + + // Swarm + PlanMode + Worktree (始终注册) + _cachedBaseTools.AddRange(new ITool[] { + Get(), Get(), Get(), + Get(), Get(), + Get(), Get(), + }); + + // 稳定排序 (prompt cache一致性) + _cachedBaseTools.Sort((a, b) => + string.Compare(a.Name, b.Name, StringComparison.Ordinal)); + + return _cachedBaseTools; + } + + /// 组装工具池: 内置优先, MCP去重 + private List AssembleToolPool( + List baseTools, IReadOnlyList mcpTools) + { + var pool = new List(baseTools); + var baseNames = baseTools.Select(t => t.Name).ToHashSet(); + + foreach (var mcpTool in mcpTools) + { + if (!baseNames.Contains(mcpTool.Name)) + pool.Add(mcpTool); + } + + return pool; + } + + private T Get() where T : ITool => _services.GetRequiredService(); +} +``` + +### 工具分类 + +| 类别 | 数量 | 说明 | +|------|------|------| +| 核心工具 | 20 个 | 始终可用,不受 feature flag 影响 | +| 条件工具 | 7 个 | 由 `AgentTriggers`、`V2Todo` 等 feature flag 控制 | +| Swarm/Plan/Worktree | 7 个 | 始终注册,但某些工具在特定模式外会返回错误 | +| MCP 工具 | 动态 | 从已连接的 MCP 服务器获取,同名工具被内置工具覆盖 | + +### 去重策略说明 + +内置工具名称集合构建为 `HashSet` 后,MCP 工具逐一检查。若名称已存在于集合中,则该 MCP 工具被静默跳过。这一策略确保: + +1. 外部 MCP 服务器无法意外替换 `Bash`、`FileRead` 等核心工具 +2. 用户可以通过 MCP 扩展新工具,但不能降低现有工具的可靠性 + +--- + +## 参考资料 + +- [核心模块设计总览](核心模块设计.md) +- [查询引擎 (QueryEngine)](核心模块设计-查询引擎-QueryEngine.md) +- [基础设施设计 — MCP 协议集成](../基础设施设计/基础设施设计-MCP协议集成.md) +- [原始代码映射 — 核心模块](reference/原始代码映射-核心模块.md) diff --git a/docs/核心模块设计/核心模块设计-查询引擎-QueryEngine.md b/docs/核心模块设计/核心模块设计-查询引擎-QueryEngine.md new file mode 100644 index 0000000..9a73c4a --- /dev/null +++ b/docs/核心模块设计/核心模块设计-查询引擎-QueryEngine.md @@ -0,0 +1,301 @@ +# 核心模块设计 — 查询引擎 (QueryEngine) + +> 所属项目: free-code .NET 10 重写 +> 原始代码来源: `../../src/QueryEngine.ts` +> 原始设计意图: 协调 LLM 消息流转、工具调用循环和 System Prompt 构建,是整个 Agent 行为的核心管道 +> 上级文档: [核心模块设计总览](核心模块设计.md) +> 交叉参考: [CLI 启动与解析](核心模块设计-CLI启动与解析.md) | [基础设施设计 — 状态管理](../基础设施设计/基础设施设计-状态管理.md) + +--- + +## 概述 + +`QueryEngine` 是 free-code 的核心消息处理管道。每当用户提交一条消息,`IQueryEngine.SubmitMessageAsync` 便接管控制权,驱动一个"助手回复 → 工具调用 → 执行结果 → 继续"的循环,直到助手不再请求工具调用或用户取消。 + +原始 `QueryEngine.ts` 是一个约 1200 行的 TypeScript 文件,包含消息历史管理、System Prompt 构建、工具执行调度、上下文压缩判断、记忆提取等所有逻辑。.NET 重写将其中的 System Prompt 构建拆分为独立的 `SystemPromptBuilder`(实现 `IPromptBuilder`),其余核心循环保留在 `QueryEngine` 中。 + +--- + +## 6.1 IQueryEngine 接口 + +```csharp +/// +/// LLM查询引擎 - 核心消息处理管道 +/// 对应原始 ../../src/QueryEngine.ts +/// +public interface IQueryEngine +{ + /// 提交用户消息并返回流式响应 + IAsyncEnumerable SubmitMessageAsync( + string content, + SubmitMessageOptions? options = null, + CancellationToken ct = default); + + /// 取消当前查询 + Task CancelAsync(); + + /// 获取消息历史 + IReadOnlyList GetMessages(); + + /// 获取当前token使用量 + TokenUsage GetCurrentUsage(); +} + +public record SubmitMessageOptions( + string? Model = null, + ToolPermissionContext? PermissionContext = null, + string? QuerySource = null, + bool IsSpeculation = false +); +``` + +`IAsyncEnumerable` 返回类型让调用方(终端 UI 层)可以在消息流抵达时即时渲染,无需等待整个响应完成。`SDKMessage` 是一个联合类型(discriminated union),涵盖流式文本增量、工具调用开始、工具执行结果等所有消息类型。 + +--- + +## 6.2 QueryEngine 实现 + +### 依赖关系 + +`QueryEngine` 通过构造函数注入以下依赖,每一项都有对应的原始 TypeScript 概念: + +| .NET 依赖 | 对应原始概念 | +|-----------|-------------| +| `IApiProviderRouter` | `claude.ts` 的 API 调用函数 | +| `IToolRegistry` | `tools.ts` 的 `getTools()` | +| `IPermissionEngine` | `permission.ts` 的权限检查逻辑 | +| `IPromptBuilder` | `getSystemPrompt()` 内联函数 | +| `ISessionMemoryService` | `memory.ts` 相关逻辑 | +| `IFeatureFlagService` | `isFeatureEnabled()` | + +### 流式循环实现 + +```csharp +public class QueryEngine : IQueryEngine +{ + private readonly IApiProviderRouter _providerRouter; + private readonly IToolRegistry _toolRegistry; + private readonly IPermissionEngine _permissionEngine; + private readonly IPromptBuilder _promptBuilder; + private readonly ISessionMemoryService _memoryService; + private readonly IFeatureFlagService _featureFlags; + private readonly ILogger _logger; + private readonly List _messages = new(); + private CancellationTokenSource? _activeCts; + + public async IAsyncEnumerable SubmitMessageAsync( + string content, + SubmitMessageOptions? options = null, + [EnumeratorCancellation] CancellationToken ct = default) + { + _activeCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + options ??= new SubmitMessageOptions(); + + // 1. 构建用户消息 + var userMessage = new Message + { + Role = MessageRole.User, + Content = new UserContent(content), + Timestamp = DateTime.UtcNow, + MessageId = Guid.NewGuid().ToString() + }; + _messages.Add(userMessage); + yield return new SDKMessage.UserMessage(userMessage); + + // 2. 查询循环 (Assistant → ToolUse → Execute → Continue) + var shouldContinue = true; + while (shouldContinue && !_activeCts.Token.IsCancellationRequested) + { + // 2a. 构建System Prompt + var systemPrompt = await _promptBuilder.BuildAsync( + _messages, options.PermissionContext, options); + + // 2b. 获取可用工具 + var tools = await _toolRegistry.GetToolsAsync(options.PermissionContext); + + // 2c. 调用API Provider + var provider = _providerRouter.GetActiveProvider(); + var apiRequest = new ApiRequest + { + SystemPrompt = systemPrompt, + Messages = BuildApiMessages(_messages), + Tools = tools, + Model = options.Model + }; + + await foreach (var response in provider.StreamAsync( + apiRequest, _activeCts.Token)) + { + switch (response) + { + case SDKMessage.AssistantMessage am: + _messages.Add(am.ToMessage()); + yield return am; + break; + + case SDKMessage.StreamingDelta sd: + yield return sd; + break; + + case SDKMessage.ToolUseStart tus: + yield return tus; + break; + + case SDKMessage.ToolUseResult tur: + shouldContinue = tur.ShouldContinue; + _messages.Add(tur.ToMessage()); + yield return tur; + break; + + case SDKMessage.CompactBoundary cb: + yield return cb; + break; + } + } + } + + // 3. 后处理 + await PostQueryProcessingAsync(options); + } + + private async Task PostQueryProcessingAsync(SubmitMessageOptions options) + { + // 记忆提取 (EXTRACT_MEMORIES flag) + if (_featureFlags.IsEnabled(FeatureFlags.ExtractMemories)) + { + _ = _memoryService.TryExtractAsync(_messages); // fire-and-forget + } + + // Prompt建议 + // 上下文压缩检查 + // 统计更新 + } + + public Task CancelAsync() + { + _activeCts?.Cancel(); + return Task.CompletedTask; + } + + public IReadOnlyList GetMessages() => _messages.AsReadOnly(); + public TokenUsage GetCurrentUsage() => /* 从最近API响应头提取 */; +} +``` + +### 循环流程图 + +``` +用户提交消息 + │ + ▼ +构建 System Prompt ◄────────────────────┐ + │ │ + ▼ │ +获取工具列表 │ + │ │ + ▼ │ +调用 API Provider (流式) │ + │ │ + ├── AssistantMessage → 存入历史 │ + ├── StreamingDelta → 直接 yield │ + ├── ToolUseStart → 直接 yield │ + └── ToolUseResult ────────────────────┤ + │ ShouldContinue = true │ + └────────────────────────────►┘ + │ ShouldContinue = false + ▼ + 后处理(记忆提取等) + │ + ▼ + 结束 +``` + +--- + +## 6.3 System Prompt Builder + +`SystemPromptBuilder` 对应原始 `QueryEngine.ts` 中内联的 `getSystemPrompt()` 函数,负责将多个信息源组装为最终发送给 API 的 System Prompt。 + +**原始设计意图:** 原始代码将系统指令、工具描述、命令描述、记忆、上下文信息拼接在一个函数中,每次查询都重新计算。.NET 重写将其提取为独立接口 `IPromptBuilder`,便于测试和替换。 + +```csharp +public class SystemPromptBuilder : IPromptBuilder +{ + private readonly IToolRegistry _toolRegistry; + private readonly ICommandRegistry _commandRegistry; + private readonly ISessionMemoryService _memoryService; + private readonly IFeatureFlagService _features; + private readonly ICompanionService? _companionService; + + public async Task BuildAsync( + IReadOnlyList messages, + ToolPermissionContext? permissionContext, + SubmitMessageOptions options) + { + var sb = new StringBuilder(); + + // 1. 基础系统指令 (CLAUDE.md 等价物) + sb.AppendLine(await BuildBaseInstructionsAsync()); + + // 2. 工具描述 (JSON Schema) + var tools = await _toolRegistry.GetToolsAsync(permissionContext); + sb.AppendLine(BuildToolDescriptions(tools)); + + // 3. 命令描述 + var commands = await _commandRegistry.GetEnabledCommandsAsync(); + sb.AppendLine(BuildCommandDescriptions(commands)); + + // 4. 会话记忆 + var memory = await _memoryService.GetCurrentMemoryAsync(); + if (memory != null) + sb.AppendLine($"\n{memory.Content}\n"); + + // 5. 上下文信息 (工作目录、Git状态、环境) + sb.AppendLine(await BuildContextInfoAsync()); + + // 6. 同伴介绍 (BUDDY flag) + if (_features.IsEnabled(FeatureFlags.Buddy) && _companionService != null) + { + var companion = await _companionService.GetCompanionAsync(); + if (companion != null) + sb.AppendLine(CompanionPromptBuilder.BuildIntro(companion)); + } + + return sb.ToString(); + } +} +``` + +### 六段构建逻辑说明 + +| 段落 | 内容来源 | 条件 | +|------|----------|------| +| 1. 基础指令 | `BuildBaseInstructionsAsync()` — 读取内置指令文本 | 始终包含 | +| 2. 工具描述 | `IToolRegistry.GetToolsAsync()` — 当前可用工具的 JSON Schema | 始终包含 | +| 3. 命令描述 | `ICommandRegistry.GetEnabledCommandsAsync()` — 斜杠命令列表 | 始终包含 | +| 4. 会话记忆 | `ISessionMemoryService.GetCurrentMemoryAsync()` | 存在记忆时包含 | +| 5. 上下文信息 | 工作目录、Git 状态、环境变量摘要 | 始终包含 | +| 6. 同伴介绍 | `ICompanionService.GetCompanionAsync()` | `BUDDY` feature flag 开启时包含 | + +工具描述(段落 2)的稳定性直接影响 Anthropic 的 prompt cache 命中率。`ToolRegistry` 对工具列表进行字典序排序(见[工具系统](核心模块设计-工具系统.md))正是为了保证这一段的内容在相同工具集下保持不变。 + +--- + +## 取消机制 + +`CancelAsync()` 通过 `CancellationTokenSource.Cancel()` 触发取消。`SubmitMessageAsync` 内的 `[EnumeratorCancellation]` 特性确保当外部 `CancellationToken` 被取消时,异步枚举自动停止。 + +两个取消来源通过 `CreateLinkedTokenSource` 合并: + +- 用户调用 `CancelAsync()`(例如按 Ctrl+C) +- 外部传入的 `ct` 参数(例如超时或应用关闭) + +--- + +## 参考资料 + +- [核心模块设计总览](核心模块设计.md) +- [CLI 启动与解析](核心模块设计-CLI启动与解析.md) +- [工具系统](核心模块设计-工具系统.md) +- [基础设施设计 — 状态管理](../基础设施设计/基础设施设计-状态管理.md) +- [原始代码映射 — 核心模块](reference/原始代码映射-核心模块.md) diff --git a/docs/核心模块设计/核心模块设计.md b/docs/核心模块设计/核心模块设计.md new file mode 100644 index 0000000..c95c9d4 --- /dev/null +++ b/docs/核心模块设计/核心模块设计.md @@ -0,0 +1,118 @@ +# 核心模块设计 — 总览 + +> 所属项目: free-code .NET 10 重写 +> 配套文档: [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md) | [基础设施设计](../基础设施设计/) | [服务子系统设计](../服务子系统设计/) | [参考映射](reference/原始代码映射-核心模块.md) + +--- + +## 概述 + +核心模块是整个 free-code .NET 10 重写的骨架,直接对应原始 TypeScript 项目中最关键的几个文件。本组文档覆盖从进程启动到 LLM 调用的完整主路径,包含五个相互协作的子系统。 + +原始 TypeScript 实现将大量逻辑集中在少数几个大文件里(`cli.tsx`、`QueryEngine.ts`、`tools.ts`、`commands.ts`)。.NET 重写将这些职责拆分为更清晰的接口、抽象类和注册表,并通过依赖注入在运行时组装。 + +--- + +## 架构概览 + +``` +Program.cs (入口) + │ + ├── QuickPathHandler 快速路径:零 DI 开销,直接处理 flag 参数 + │ + └── IAppRunner (REPL/OnShot) + │ + └── IQueryEngine 消息处理主管道 + │ + ├── IApiProviderRouter → 路由到 Anthropic / Bedrock / Vertex / Codex / Foundry + ├── IToolRegistry → 核心工具 + MCP 工具去重组装 + ├── ICommandRegistry → 70+ 斜杠命令 + └── IPromptBuilder → 6 段式 System Prompt 构建 +``` + +一次用户提交的消息从 `IQueryEngine.SubmitMessageAsync` 进入,经历以下流程: + +1. 构建 System Prompt(`SystemPromptBuilder`) +2. 获取可用工具列表(`ToolRegistry`) +3. 通过 `ApiProviderRouter` 选择活跃的 API 提供商并发起流式请求 +4. 在 `while` 循环中处理响应流,直到模型不再触发工具调用 +5. 后处理:记忆提取、上下文压缩检查、统计更新 + +--- + +## 子模块列表 + +### [CLI 启动与解析](核心模块设计-CLI启动与解析.md) + +覆盖 `Program.cs` 的四阶段启动流程、`QuickPathHandler` 快速路径处理,以及基于 `System.CommandLine` 的 CLI 命令定义。 + +原始来源: `../../src/entrypoints/cli.tsx`, `../../src/screens/REPL.tsx` + +--- + +### [查询引擎 (QueryEngine)](核心模块设计-查询引擎-QueryEngine.md) + +覆盖 `IQueryEngine` 接口、`QueryEngine` 流式循环实现,以及 `SystemPromptBuilder` 的六段构建逻辑。 + +原始来源: `../../src/QueryEngine.ts` + +--- + +### [工具系统](核心模块设计-工具系统.md) + +覆盖 `ITool` / `ITool` 接口体系、`ToolBase` 抽象基类、`BashTool` 完整实现,以及 `ToolRegistry` 组装与 MCP 去重策略。 + +原始来源: `../../src/tools.ts`, `../../src/tools/` + +--- + +### [多代理协调器 (Coordinator)](核心模块设计-多代理协调.md) + +覆盖 coordinator mode 的模式切换、worker 启停与继续、团队消息路由、会话恢复匹配,以及 `../../src/assistant/` 的历史会话读取配套能力。 + +原始来源: `../../src/coordinator/`, `../../src/assistant/` + +--- + +### [命令系统](核心模块设计-命令系统.md) + +覆盖 `ICommand` 接口、`CommandRegistry` 注册 70+ 斜杠命令,以及命令分类与可用性控制机制。 + +原始来源: `../../src/commands.ts`, `../../src/commands/` + +--- + +### [API 多提供商路由](核心模块设计-API提供商路由.md) + +覆盖 `ApiProviderRouter` 的环境变量自动检测、`AnthropicProvider` SSE 流式解析实现,以及全部提供商枚举。 + +原始来源: `../../src/services/api/` + +--- + +## 关键设计决策 + +**依赖注入替代全局单例** + +原始 TypeScript 代码广泛使用模块级全局变量共享状态。.NET 重写通过 `IServiceProvider` 和构造函数注入统一管理所有依赖,生命周期由 `AddCoreServices()` / `AddEngine()` 等扩展方法集中配置。 + +**快速路径零开销** + +`QuickPathHandler.TryHandle` 在 DI 容器构建之前执行,确保 `--version`、`--mcp-daemon` 等场景不承担任何初始化开销。 + +**工具池稳定排序** + +`ToolRegistry` 对基础工具列表按名称字典序排序,保证在相同 feature flag 组合下 System Prompt 中的工具顺序不变,从而最大化 Anthropic prompt cache 命中率。 + +**MCP 工具去重策略** + +内置工具的名称集合作为 `HashSet` 优先占位,MCP 同名工具被静默丢弃,防止外部 MCP 服务器意外覆盖核心工具行为。 + +--- + +## 参考资料 + +- [原始代码映射 — 核心模块](reference/原始代码映射-核心模块.md) +- [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md) +- [基础设施设计 — 状态管理](../基础设施设计/基础设施设计-状态管理.md) +- [基础设施设计 — MCP 协议集成](../基础设施设计/基础设施设计-MCP协议集成.md) diff --git a/docs/测试与构建/reference/原始代码映射-测试与构建.md b/docs/测试与构建/reference/原始代码映射-测试与构建.md new file mode 100644 index 0000000..a0bf709 --- /dev/null +++ b/docs/测试与构建/reference/原始代码映射-测试与构建.md @@ -0,0 +1,116 @@ +# 原始代码映射 — 测试与构建 + +> 所属项目: free-code .NET 10 重写 +> 文档类型: 参考映射表 +> 上级文档: [测试与构建总览](../测试与构建.md) + +--- + +## 说明 + +本文档建立 .NET 10 测试类型和构建配置与原始 TypeScript 源文件之间的映射关系。测试层是 .NET 重写新增的内容(原始项目无系统化测试),因此大部分映射说明的是"覆盖哪个原始模块的逻辑"而不是"重写了哪个测试文件"。构建部分则记录了从 Bun 脚本到 MSBuild/dotnet publish 的对应关系。 + +--- + +## 测试覆盖映射 + +### 单元测试 — FreeCode.Tests.Unit + +| 测试类 | 测试项目路径 | 覆盖原始 TS 文件 | 测试重点 | +|--------|------------|----------------|---------| +| `BashToolTests` | `Tests/Tools/BashToolTests.cs` | `../../../src/tools/BashTool.tsx` | 命令执行、超时中断、后台任务 ID、只读分类 | +| `FileReadToolTests` | `Tests/Tools/FileReadToolTests.cs` | `../../../src/tools/FileReadTool.tsx` | 路径验证、编码处理、行范围切片 | +| `FileEditToolTests` | `Tests/Tools/FileEditToolTests.cs` | `../../../src/tools/FileEditTool.tsx` | 字符串替换、多匹配报错、文件不存在报错 | +| `FileWriteToolTests` | `Tests/Tools/FileWriteToolTests.cs` | `../../../src/tools/FileWriteTool.tsx` | 新建文件、覆盖已有文件、父目录不存在 | +| `GlobToolTests` | `Tests/Tools/GlobToolTests.cs` | `../../../src/tools/GlobTool.tsx` | 模式匹配、路径限制、结果排序 | +| `GrepToolTests` | `Tests/Tools/GrepToolTests.cs` | `../../../src/tools/GrepTool.tsx` | 正则搜索、include 过滤、上下文行 | +| `AgentToolTests` | `Tests/Tools/AgentToolTests.cs` | `../../../src/tools/AgentTool.tsx` | 子代理启动、结果收集、嵌套深度限制 | +| `ToolRegistryTests` | `Tests/Tools/ToolRegistryTests.cs` | `../../../src/tools.ts` | 基础工具注册、MCP 工具去重、稳定排序 | +| `QueryEngineTests` | `Tests/Engine/QueryEngineTests.cs` | `../../../src/QueryEngine.ts` | 消息循环、工具调用轮次、流式输出 | +| `SystemPromptBuilderTests` | `Tests/Engine/SystemPromptBuilderTests.cs` | `../../../src/QueryEngine.ts`(内联 `getSystemPrompt`)| 六段构建顺序、feature flag 条件段 | +| `CommandRegistryTests` | `Tests/Commands/CommandRegistryTests.cs` | `../../../src/commands.ts` | 命令注册、可用性过滤、认证状态过滤 | +| `AnthropicProviderTests` | `Tests/ApiProviders/AnthropicProviderTests.cs` | `../../../src/services/api/claude.ts` | SSE 解析、事件类型映射、错误处理 | +| `CodexProviderTests` | `Tests/ApiProviders/CodexProviderTests.cs` | `../../../src/services/api/`(Codex adapter)| 消息格式转换、流式响应 | +| `ProviderRouterTests` | `Tests/ApiProviders/ProviderRouterTests.cs` | `../../../src/services/api/`(env 判断逻辑)| 环境变量路由、默认提供商 | +| `McpClientTests` | `Tests/Mcp/McpClientTests.cs` | `../../../src/services/mcp/` | JSON-RPC 请求/响应、工具调用 | +| `McpClientManagerTests` | `Tests/Mcp/McpClientManagerTests.cs` | `../../../src/services/mcp/` | 多服务器管理、断线重连 | +| `TransportTests` | `Tests/Mcp/TransportTests.cs` | `../../../src/services/mcp/`(传输层)| stdio 传输、SSE 传输 | +| `LspClientManagerTests` | `Tests/Lsp/LspClientManagerTests.cs` | `../../../src/services/lsp/` | 服务器实例管理、LSP 协议握手 | +| `AuthServiceTests` | `Tests/Services/AuthServiceTests.cs` | `../../../src/services/oauth/` | Token 缓存、刷新逻辑 | +| `RateLimitServiceTests` | `Tests/Services/RateLimitServiceTests.cs` | `../../../src/services/` | 令牌桶算法、429 处理 | +| `SessionMemoryServiceTests` | `Tests/Services/SessionMemoryServiceTests.cs` | `../../../src/services/sessionMemory/` | 记忆读写、过期淘汰 | +| `CompanionServiceTests` | `Tests/Services/CompanionServiceTests.cs` | 无直接对应(新增 Companion 功能)| 骰子确定性、用户隔离 | +| `NotificationServiceTests` | `Tests/Services/NotificationServiceTests.cs` | `../../../src/services/` | 通知订阅、事件分发 | +| `AppStateStoreTests` | `Tests/State/AppStateStoreTests.cs` | `../../../src/state/` | 状态不可变性、订阅通知、并发安全 | + +--- + +### 集成测试 — FreeCode.Tests.Integration + +| 测试类 | 测试项目路径 | 覆盖场景 | 涉及原始模块 | +|--------|------------|---------|------------| +| `QueryPipelineTests` | `QueryPipelineTests.cs` | 完整查询管道:QueryEngine + Provider + ToolRegistry | `../../../src/QueryEngine.ts` + `../../../src/tools.ts` | +| `McpIntegrationTests` | `McpIntegrationTests.cs` | 真实 MCP stdio 服务器连接和工具调用 | `../../../src/services/mcp/` | +| `LspIntegrationTests` | `LspIntegrationTests.cs` | LSP 服务器启动、诊断获取 | `../../../src/services/lsp/` | +| `BridgeIntegrationTests` | `BridgeIntegrationTests.cs` | IDE Bridge 握手和命令分发 | `../../../src/bridge/` | +| `TaskManagerTests` | `TaskManagerTests.cs` | 后台任务创建、状态更新、取消 | `../../../src/tasks/` | +| `PluginLoadingTests` | `PluginLoadingTests.cs` | AssemblyLoadContext 加载卸载插件 | `../../../src/plugins/` | + +--- + +### E2E 测试 — FreeCode.Tests.E2E + +| 测试类 | 测试项目路径 | 覆盖场景 | 涉及原始模块 | +|--------|------------|---------|------------| +| `CliTests` | `CliTests.cs` | `--version`、`--help` 标志输出 | `../../../src/entrypoints/cli.tsx` | +| `OneShotModeTests` | `OneShotModeTests.cs` | `-p "prompt"` 一次性模式完整流程 | `../../../src/entrypoints/cli.tsx` | +| `InteractiveReplTests` | `InteractiveReplTests.cs` | 伪终端输入输出验证 | `../../../src/screens/REPL.tsx` | + +--- + +## 构建映射 + +### 构建脚本对应 + +| .NET 构建文件 | 原始 TS 文件 | 功能对应 | 关键差异 | +|--------------|------------|---------|---------| +| `Directory.Build.props` | 无直接对应 | 全局项目属性 | MSBuild 中心化配置,Bun 项目无此概念 | +| `build.sh` | `scripts/build.ts` | 多平台批量构建 | Bun 打包 JS bundle;`dotnet publish` 输出 AOT 原生二进制 | +| `install.sh` | `install.sh`(原始)| 一键安装 | 检测逻辑相同;目标文件从 Bun bundle 改为 AOT 二进制 | +| `FreeCode.csproj` | `package.json` | 项目依赖和元数据 | 明确引用各子项目,取代 npm 依赖树 | + +--- + +### 发布目标对应 + +| .NET RID | 原始 Bun 平台 | 说明 | +|----------|-------------|------| +| `osx-arm64` | Darwin arm64 | Apple Silicon Mac,与原始 `install.sh` 的 `Darwin-arm64` 分支对应 | +| `osx-x64` | Darwin x86_64 | Intel Mac,与原始 `Darwin-x86_64` 分支对应 | +| `linux-x64` | Linux x86_64 | 主流 Linux 服务器,与原始 `Linux-x86_64` 分支对应 | +| `linux-arm64` | Linux aarch64 | 与原始 `Linux-aarch64` 分支对应 | +| `win-x64` | 无(原始不支持 Windows)| .NET 重写新增的目标平台 | + +--- + +## 未直接映射的原始文件 + +以下原始文件无对应测试文件(原始项目没有测试),相关逻辑的验证由集成测试和 E2E 测试覆盖: + +| 原始文件 | 验证方式 | +|----------|---------| +| `../../../src/entrypoints/cli.tsx` | `CliTests.cs`、`OneShotModeTests.cs` E2E 验证 | +| `../../../src/screens/REPL.tsx` | `InteractiveReplTests.cs` E2E 验证 | +| `../../../src/components/` | 无自动化 UI 测试(Terminal.Gui 视图层手工验证)| +| `../../../src/hooks/` | 通过 `QueryEngineTests` 和 `AppStateStoreTests` 间接覆盖 | +| `../../../src/state/` | `AppStateStoreTests.cs` 单元测试 | + +--- + +## 参考资料 + +- [测试与构建总览](../测试与构建.md) +- [测试方案设计](../测试与构建-测试方案设计.md) +- [构建与部署](../测试与构建-构建与部署.md) +- [迁移路线图](../测试与构建-迁移路线图.md) +- [原始代码映射 — 核心模块](../../核心模块设计/reference/原始代码映射-核心模块.md) diff --git a/docs/测试与构建/测试与构建-构建与部署.md b/docs/测试与构建/测试与构建-构建与部署.md new file mode 100644 index 0000000..4b75d6c --- /dev/null +++ b/docs/测试与构建/测试与构建-构建与部署.md @@ -0,0 +1,235 @@ +# 构建与部署 + +> 所属项目: free-code .NET 10 重写 +> 文档类型: 构建设计 +> 来源章节: DESIGN-NET10-PART3.md § 23 +> 上级文档: [测试与构建总览](测试与构建.md) + +--- + +## 概述 + +构建目标是跨 5 个平台的 AOT 原生二进制,输出单文件、无运行时依赖的可执行文件。MSBuild 通过 `Directory.Build.props` 统一管理全局属性,`build.sh` 封装多平台批量发布,`install.sh` 提供一键下载安装体验。 + +原始 TypeScript 项目使用 Bun 打包为单一 JavaScript bundle。.NET 重写的 AOT 路径在启动时间和内存占用上接近原生,同时消除了 Bun 运行时依赖。 + +--- + +## 23.1 MSBuild 配置 + +### Directory.Build.props(全局属性) + +所有子项目自动继承这些属性,无需在每个 `.csproj` 中重复声明。 + +```xml + + + + net10.0 + 13.0 + enable + enable + true + true + true + partial + false + true + true + 1.0.0 + free-code + FreeCode + + +``` + +关键属性说明: + +| 属性 | 值 | 作用 | +|------|----|------| +| `AotPublish` | `true` | 启用 AOT 原生编译 | +| `PublishSingleFile` | `true` | 输出单一可执行文件 | +| `PublishTrimmed` | `true` | 裁剪未使用的程序集 | +| `TrimMode` | `partial` | 部分裁剪,保留反射入口点 | +| `JsonSerializerIsReflectionEnabledByDefault` | `false` | 强制使用 Source Generator 序列化 | +| `EnableAotAnalyzer` | `true` | 编译期检测 AOT 不兼容代码 | +| `LangVersion` | `13.0` | C# 13 特性(primary constructors、集合表达式等)| + +--- + +### FreeCode.csproj(主项目) + +```xml + + + + Exe + net10.0 + + + + + + + + + + + + + + + + + + + +``` + +主项目引用全部 15 个子项目,自身仅包含 `Program.cs` 入口,所有业务逻辑分布在各子项目库中。 + +--- + +## 23.2 构建脚本 + +### build.sh(多平台批量构建) + +```bash +#!/bin/bash +# build.sh — 多平台构建 +set -euo pipefail + +VERSION="${1:-$(git describe --tags --always)}" +OUTPUT_DIR="./dist" + +# 构建目标平台 +PLATFORMS=( + "osx-arm64" + "osx-x64" + "linux-x64" + "linux-arm64" + "win-x64" +) + +for PLATFORM in "${PLATFORMS[@]}"; do + echo "Building for $PLATFORM..." + RID="${PLATFORM}" + + dotnet publish src/FreeCode/FreeCode.csproj \ + -c Release \ + -r "$RID" \ + -o "$OUTPUT_DIR/$PLATFORM" \ + /p:VersionPrefix="$VERSION" \ + /p:AotPublish=true \ + /p:PublishSingleFile=true \ + /p:PublishTrimmed=true + + # 重命名二进制 + if [[ "$PLATFORM" == win-* ]]; then + mv "$OUTPUT_DIR/$PLATFORM/free-code.exe" \ + "$OUTPUT_DIR/$PLATFORM/free-code-$VERSION-$PLATFORM.exe" + else + chmod +x "$OUTPUT_DIR/$PLATFORM/free-code" + mv "$OUTPUT_DIR/$PLATFORM/free-code" \ + "$OUTPUT_DIR/$PLATFORM/free-code-$VERSION-$PLATFORM" + fi + + echo "✓ $PLATFORM done" +done + +echo "All builds complete in $OUTPUT_DIR/" +``` + +脚本逻辑说明: + +1. 版本号优先读取命令行参数,fallback 到 `git describe --tags --always` +2. 遍历 5 个 RID,每个平台独立调用 `dotnet publish` +3. 输出产物按 `free-code-{VERSION}-{PLATFORM}` 格式命名 +4. Windows 平台保留 `.exe` 后缀,其余平台添加可执行权限 + +--- + +## 23.3 安装脚本 + +### install.sh(一键安装) + +```bash +#!/bin/bash +# install.sh — 一键安装 +set -euo pipefail + +# 检测平台 +OS="$(uname -s)" +ARCH="$(uname -m)" + +case "$OS-$ARCH" in + Darwin-arm64) PLATFORM="osx-arm64" ;; + Darwin-x86_64) PLATFORM="osx-x64" ;; + Linux-x86_64) PLATFORM="linux-x64" ;; + Linux-aarch64) PLATFORM="linux-arm64" ;; + *) echo "Unsupported platform: $OS-$ARCH"; exit 1 ;; +esac + +INSTALL_DIR="$HOME/.free-code/bin" +mkdir -p "$INSTALL_DIR" + +# 下载 +BINARY_URL="https://github.com/paoloanzn/free-code/releases/latest/download/free-code-latest-$PLATFORM" +echo "Downloading free-code for $PLATFORM..." +curl -fsSL "$BINARY_URL" -o "$INSTALL_DIR/free-code" +chmod +x "$INSTALL_DIR/free-code" + +# 添加到 PATH +if ! echo "$PATH" | grep -q "$INSTALL_DIR"; then + SHELL_RC="$HOME/.zshrc" + [ -f "$HOME/.bashrc" ] && SHELL_RC="$HOME/.bashrc" + echo "export PATH=\"$INSTALL_DIR:\$PATH\"" >> "$SHELL_RC" + echo "Added $INSTALL_DIR to PATH in $SHELL_RC" +fi + +echo "✓ free-code installed to $INSTALL_DIR/free-code" +echo " Run 'free-code' to start" +``` + +安装流程: + +1. 通过 `uname -s` 和 `uname -m` 检测当前平台 +2. 从 GitHub Releases 下载对应 RID 的预编译二进制 +3. 安装到 `~/.free-code/bin/free-code` +4. 自动检测 shell 配置文件(优先 `.zshrc`,fallback `.bashrc`)并追加 PATH + +--- + +## 23.4 目标平台矩阵 + +| RID | 操作系统 | 架构 | 说明 | +|-----|---------|------|------| +| `osx-arm64` | macOS | Apple Silicon | M1/M2/M3 Mac | +| `osx-x64` | macOS | Intel x64 | Intel Mac | +| `linux-x64` | Linux | x86-64 | 主流 Linux 服务器 | +| `linux-arm64` | Linux | AArch64 | Raspberry Pi、AWS Graviton 等 | +| `win-x64` | Windows | x64 | Windows 10/11 | + +--- + +## 23.5 发布参数说明 + +| dotnet publish 参数 | 含义 | +|---------------------|------| +| `-c Release` | Release 配置,开启优化 | +| `-r {RID}` | 目标运行时,指定后产物为自包含 | +| `/p:AotPublish=true` | AOT 原生编译,消除 JIT 开销 | +| `/p:PublishSingleFile=true` | 将所有资源打包为单一可执行文件 | +| `/p:PublishTrimmed=true` | 裁剪未引用的程序集,减小体积 | +| `/p:VersionPrefix={VERSION}` | 注入版本号到程序集元数据 | + +AOT 编译将 IL 代码提前编译为目标平台原生机器码,启动时间从 JIT 模式的数百毫秒降至个位数毫秒,与原始 Bun 构建产物的冷启动时间相当。 + +--- + +## 参考资料 + +- [测试与构建总览](测试与构建.md) +- [原始代码映射 — 测试与构建](reference/原始代码映射-测试与构建.md) +- [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md) +- [迁移路线图](测试与构建-迁移路线图.md) diff --git a/docs/测试与构建/测试与构建-测试方案设计.md b/docs/测试与构建/测试与构建-测试方案设计.md new file mode 100644 index 0000000..fbd9c2d --- /dev/null +++ b/docs/测试与构建/测试与构建-测试方案设计.md @@ -0,0 +1,369 @@ +# 测试方案设计 + +> 所属项目: free-code .NET 10 重写 +> 文档类型: 测试设计 +> 来源章节: DESIGN-NET10-PART3.md § 22 +> 上级文档: [测试与构建总览](测试与构建.md) + +--- + +## 概述 + +原始 TypeScript 项目没有系统化的测试覆盖。.NET 重写从零建立分层测试体系,目标是核心路径 80%+ 覆盖率。测试框架组合为 **xUnit 2 + NSubstitute 5 + FluentAssertions 7**,三个独立测试项目分别承载单元、集成和 E2E 三个层次。 + +--- + +## 22.1 测试项目结构 + +``` +tests/ +├── FreeCode.Tests.Unit/ # 120+ 单元测试 +│ ├── Engine/ +│ │ ├── QueryEngineTests.cs +│ │ └── SystemPromptBuilderTests.cs +│ ├── Tools/ +│ │ ├── BashToolTests.cs +│ │ ├── FileEditToolTests.cs +│ │ ├── FileReadToolTests.cs +│ │ ├── FileWriteToolTests.cs +│ │ ├── GlobToolTests.cs +│ │ ├── GrepToolTests.cs +│ │ ├── AgentToolTests.cs +│ │ └── ToolRegistryTests.cs +│ ├── Commands/ +│ │ └── CommandRegistryTests.cs +│ ├── ApiProviders/ +│ │ ├── AnthropicProviderTests.cs +│ │ ├── CodexProviderTests.cs +│ │ └── ProviderRouterTests.cs +│ ├── Mcp/ +│ │ ├── McpClientTests.cs +│ │ ├── McpClientManagerTests.cs +│ │ └── TransportTests.cs +│ ├── Lsp/ +│ │ └── LspClientManagerTests.cs +│ ├── Services/ +│ │ ├── AuthServiceTests.cs +│ │ ├── RateLimitServiceTests.cs +│ │ ├── SessionMemoryServiceTests.cs +│ │ ├── CompanionServiceTests.cs +│ │ └── NotificationServiceTests.cs +│ └── State/ +│ └── AppStateStoreTests.cs +├── FreeCode.Tests.Integration/ # 60 集成测试 +│ ├── QueryPipelineTests.cs +│ ├── McpIntegrationTests.cs +│ ├── LspIntegrationTests.cs +│ ├── BridgeIntegrationTests.cs +│ ├── TaskManagerTests.cs +│ └── PluginLoadingTests.cs +└── FreeCode.Tests.E2E/ # 20 端到端测试 + ├── CliTests.cs + ├── OneShotModeTests.cs + └── InteractiveReplTests.cs +``` + +--- + +## 22.2 单元测试示例 + +### BashToolTests.cs + +测试覆盖: 命令执行、超时处理、后台任务、只读分类。 +原始来源: `../../src/tools/BashTool.tsx` + +```csharp +// === BashToolTests.cs === +public class BashToolTests +{ + private readonly BashTool _tool = new( + Substitute.For(), + Substitute.For()); + + [Fact] + public async Task ExecuteAsync_SimpleCommand_ReturnsOutput() + { + // Arrange + var input = new BashToolInput { Command = "echo hello" }; + var context = new ToolExecutionContext( + WorkingDirectory: Path.GetTempPath(), + PermissionMode: PermissionMode.Default, + AdditionalWorkingDirectories: [], + PermissionEngine: Substitute.For(), + LspManager: Substitute.For(), + TaskManager: Substitute.For(), + Services: Substitute.For()); + + // Act + var result = await _tool.ExecuteAsync(input, context); + + // Assert + result.IsError.Should().BeFalse(); + result.Data.Stdout.Trim().Should().Be("hello"); + result.Data.ExitCode.Should().Be(0); + } + + [Fact] + public async Task ExecuteAsync_Timeout_KillsProcess() + { + var input = new BashToolInput { Command = "sleep 10", Timeout = 500 }; + var context = CreateContext(); + + var result = await _tool.ExecuteAsync(input, context); + + result.Data.Interrupted.Should().BeTrue(); + result.Data.ExitCode.Should().Be(-1); + } + + [Fact] + public async Task ExecuteAsync_Background_ReturnsTaskId() + { + var taskManager = Substitute.For(); + taskManager.CreateShellTaskAsync(Arg.Any(), Arg.Any()) + .Returns(new LocalShellTask { TaskId = "bg-123", Command = "sleep 10" }); + + var tool = new BashTool(taskManager, Substitute.For()); + var input = new BashToolInput { Command = "sleep 10", RunInBackground = true }; + var result = await tool.ExecuteAsync(input, CreateContext()); + + result.Data.BackgroundTaskId.Should().Be("bg-123"); + } + + [Theory] + [InlineData("ls", true)] + [InlineData("rm -rf /", false)] + [InlineData("cat file.txt", true)] + [InlineData("echo test > file", false)] + public void IsReadOnly_ClassifiesCorrectly(string command, bool expected) + { + var input = new BashToolInput { Command = command }; + _tool.IsReadOnly(input).Should().Be(expected); + } +} +``` + +--- + +### ToolRegistryTests.cs + +测试覆盖: 基础工具注册、MCP 工具去重策略。 +原始来源: `../../src/tools.ts` + +```csharp +// === ToolRegistryTests.cs === +public class ToolRegistryTests +{ + [Fact] + public async Task GetToolsAsync_ReturnsBaseTools() + { + var registry = CreateRegistry(); + var tools = await registry.GetToolsAsync(); + + tools.Should().Contain(t => t.Name == "Read"); + tools.Should().Contain(t => t.Name == "Edit"); + tools.Should().Contain(t => t.Name == "Bash"); + tools.Should().Contain(t => t.Name == "Agent"); + } + + [Fact] + public async Task AssembleToolPool_DeduplicatesMcpTools() + { + // 内置 "Bash" 工具 + MCP 也提供 "Bash" → 只保留内置 + var registry = CreateRegistry(); + var mcpManager = Substitute.For(); + mcpManager.GetToolsAsync().Returns(new List + { + CreateTool("Bash"), // 与内置重名,应被丢弃 + CreateTool("SlackSearch"), // 新工具,应被添加 + }); + + // 验证: Bash 只有一个 (内置), SlackSearch 被添加 + } +} +``` + +--- + +### AnthropicProviderTests.cs + +测试覆盖: SSE 流式事件解析。 +原始来源: `../../src/services/api/claude.ts` + +```csharp +// === AnthropicProviderTests.cs === +public class AnthropicProviderTests +{ + [Fact] + public async Task StreamAsync_ParsesSSECorrectly() + { + var sseResponse = "event: message_start\ndata: {\"type\":\"message_start\"}\n\n" + + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"text\":\"Hello\"}}\n\n" + + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"; + + var handler = new MockHttpHandler(sseResponse); + var provider = new AnthropicProvider(new HttpClient(handler)); + + var messages = new List(); + await foreach (var msg in provider.StreamAsync(new ApiRequest())) + messages.Add(msg); + + messages.Should().ContainSingle(m => m is SDKMessage.StreamingDelta sd && sd.Text == "Hello"); + } +} +``` + +--- + +### CompanionServiceTests.cs + +测试覆盖: 骰子结果确定性、不同用户产生不同结果。 +原始来源: 无直接对应(新增 Companion 功能) + +```csharp +// === CompanionServiceTests.cs === +public class CompanionServiceTests +{ + [Fact] + public void RollBones_IsDeterministic() + { + // 相同 userId → 相同骨骼 + var service = new CompanionService(Substitute.For()); + var c1 = service.RollBones("user-123"); + var c2 = service.RollBones("user-123"); + c1.Should().BeEquivalentTo(c2); + } + + [Fact] + public void RollBones_DifferentUsers_DifferentResults() + { + var service = new CompanionService(Substitute.For()); + var c1 = service.RollBones("user-A"); + var c2 = service.RollBones("user-B"); + // 极大概率不同 (18 种族, 6 眼形, 5 稀有度) + (c1.Species != c2.Species || c1.Eye != c2.Eye).Should().BeTrue(); + } +} +``` + +--- + +## 22.3 集成测试示例 + +### McpIntegrationTests.cs + +测试覆盖: stdio MCP 服务器连接建立。 +原始来源: `../../src/services/mcp/` + +```csharp +// === McpIntegrationTests.cs === +public class McpIntegrationTests : IAsyncLifetime +{ + private McpClientManager _manager = null!; + private string _tempDir = null!; + + public async Task InitializeAsync() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"mcp-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + + // 创建一个测试用 MCP stdio 服务器脚本 + var serverScript = Path.Combine(_tempDir, "test-server.sh"); + await File.WriteAllTextAsync(serverScript, @"#!/bin/bash +echo '{""jsonrpc"":""2.0"",""id"":1,""result"":{""protocolVersion"":""2025-03-26"",""capabilities"":{""tools"":{}}}}' +"); + Process.Start("chmod", $"+x {serverScript}")?.WaitForExit(); + } + + [Fact] + public async Task ConnectServersAsync_ConnectsToStdioServer() + { + // 配置一个 stdio MCP 服务器 + var config = new StdioServerConfig { Command = "echo", Scope = ConfigScope.Local }; + // ... 验证连接成功 + } +} +``` + +--- + +## 22.4 E2E 测试示例 + +### CliTests.cs + +测试覆盖: 版本标志输出、一次性模式响应。 +原始来源: `../../src/entrypoints/cli.tsx` + +```csharp +// === CliTests.cs === +public class CliTests +{ + [Fact] + public async Task VersionFlag_PrintsVersion() + { + var result = await RunCliAsync("--version"); + result.ExitCode.Should().Be(0); + result.Output.Should().StartWith("free-code "); + } + + [Fact] + public async Task OneShotMode_ReturnsResponse() + { + // 需要 mock API server + var result = await RunCliAsync("-p", "what is 2+2?"); + result.ExitCode.Should().Be(0); + } + + private static async Task<(int ExitCode, string Output)> RunCliAsync(params string[] args) + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"run --project src/FreeCode {string.Join(" ", args)}", + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + } + }; + process.Start(); + var output = await process.StandardOutput.ReadToEndAsync(); + await process.WaitForExitAsync(); + return (process.ExitCode, output); + } +} +``` + +--- + +## 22.5 测试金字塔 + +| 层次 | 项目 | 数量 | 执行时间目标 | +|------|------|------|------------| +| 单元测试 | `FreeCode.Tests.Unit` | 120+ | < 30 秒 | +| 集成测试 | `FreeCode.Tests.Integration` | 60 | < 3 分钟 | +| E2E 测试 | `FreeCode.Tests.E2E` | 20 | < 10 分钟 | +| **合计** | | **200** | | + +单元测试覆盖所有工具、引擎、命令、API 提供商、MCP/LSP 客户端、服务层和状态管理。集成测试验证跨组件交互(查询管道、MCP 连接、LSP 通信、Bridge 握手、任务管理器、插件加载)。E2E 测试启动完整进程,验证 CLI 行为对外部用户可见的部分。 + +--- + +## 22.6 测试框架版本 + +| 包 | 版本 | 用途 | +|----|------|------| +| `xunit` | 2.x | 测试运行器和断言基础 | +| `NSubstitute` | 5.x | 接口 mock / stub / spy | +| `FluentAssertions` | 7.x | 可读的断言链 | +| `Microsoft.NET.Test.Sdk` | 最新 | dotnet test 集成 | +| `coverlet.collector` | 最新 | 代码覆盖率收集 | + +--- + +## 参考资料 + +- [测试与构建总览](测试与构建.md) +- [原始代码映射 — 测试与构建](reference/原始代码映射-测试与构建.md) +- [核心模块设计 — 工具系统](../核心模块设计/核心模块设计-工具系统.md) +- [基础设施设计](../基础设施设计/基础设施设计.md) diff --git a/docs/测试与构建/测试与构建-迁移路线图.md b/docs/测试与构建/测试与构建-迁移路线图.md new file mode 100644 index 0000000..22a74df --- /dev/null +++ b/docs/测试与构建/测试与构建-迁移路线图.md @@ -0,0 +1,234 @@ +# 迁移路线图 + +> 所属项目: free-code .NET 10 重写 +> 文档类型: 规划设计 +> 来源章节: DESIGN-NET10-PART3.md § 24 +> 上级文档: [测试与构建总览](测试与构建.md) + +--- + +## 概述 + +从 TypeScript/Bun 到 .NET 10 的完整重写分 12 个阶段、35 个工作周推进,设置 8 个里程碑节点作为质量检查点。第 36-37 周为缓冲期和文档收尾,实际总工期约 37 周。 + +路线图遵循"先主路径、后完整功能、最后质量和发布"的顺序,确保每个里程碑都有可运行的产物,而不是等到全部完成才能验证。 + +--- + +## 24.1 35 周详细规划 + +### Phase 1: 基础框架(Week 1-4) + +``` +Phase 1: 基础框架 (Week 1-4) +├── Week 1: 解决方案结构 + Directory.Build.props + 所有 17 个项目创建 +├── Week 2: DI 容器 + 核心接口 (ITool, ICommand, IApiProvider, IQueryEngine) +├── Week 3: System.CommandLine CLI 解析 + QuickPathHandler +├── Week 4: AppState record + AppStateStore + FeatureFlags +└── 里程碑 M1: dotnet run 输出版本号 +``` + +**目标**: 建立可编译的解决方案骨架,所有接口定义就位,最基础的 CLI 入口可以运行。 + +--- + +### Phase 2: 核心引擎(Week 5-8) + +``` +Phase 2: 核心引擎 (Week 5-8) +├── Week 5: QueryEngine 流式循环 + Message 类型 +├── Week 6: SystemPromptBuilder (6段构建) +├── Week 7: ToolBase + FluentValidation +├── Week 8: ToolRegistry + CommandRegistry 注册 +└── 里程碑 M2: 一次性 prompt 模式工作 (-p) +``` + +**目标**: 一次性模式(`-p "prompt"`)可以向 API 发送请求并接收流式响应,不需要完整 UI。 + +--- + +### Phase 3: API 提供商(Week 9-11) + +``` +Phase 3: API 提供商 (Week 9-11) +├── Week 9: AnthropicProvider SSE 解析 +├── Week 10: CodexProvider 消息格式适配 +├── Week 11: BedrockProvider + VertexProvider + FoundryProvider +└── 里程碑: 所有 5 个提供商路由工作 +``` + +**目标**: 环境变量切换任意一个提供商都能正常处理流式响应。 + +--- + +### Phase 4: 核心工具(Week 12-15) + +``` +Phase 4: 核心工具 (Week 12-15) +├── Week 12: FileReadTool + FileEditTool + FileWriteTool +├── Week 13: BashTool + GlobTool + GrepTool +├── Week 14: AgentTool + SkillTool +├── Week 15: WebFetch + WebSearch + TodoWrite + AskUser + 其他 +└── 里程碑 M3: Bash/Read/Edit 工具链可用 +``` + +**目标**: 模型可以调用文件操作和 Shell 命令工具,完成基本编码任务。 + +--- + +### Phase 5: 命令实现(Week 16-18) + +``` +Phase 5: 命令实现 (Week 16-18) +├── Week 16: 核心 30 个命令 (help, exit, clear, config, model, login...) +├── Week 17: 功能命令 25 个 (mcp, plugins, skills, tasks...) +├── Week 18: 条件 + 内部命令 15 个 +└── 里程碑: 所有 70+ 命令注册 +``` + +**目标**: 全部斜杠命令可以被解析和分发,核心命令(`/help`、`/model`、`/login`)有完整实现。 + +--- + +### Phase 6: 终端 UI(Week 19-22) + +``` +Phase 6: 终端 UI (Week 19-22) +├── Week 19: Terminal.Gui App + REPLScreen 基本布局 +├── Week 20: PromptInput + MessageList + 流式渲染 +├── Week 21: PermissionDialog + ToolUseDisplay + StatusBar +├── Week 22: Theme + Keybindings + CompanionSprite +└── 里程碑 M4: 交互式 REPL 可用 +``` + +**目标**: 用户可以在终端中交互式输入 prompt 并看到流式输出,工具调用有权限确认界面。 + +--- + +### Phase 7: 基础设施(Week 23-25) + +``` +Phase 7: 基础设施 (Week 23-25) +├── Week 23: MCP SDK (传输层 + 客户端 + McpClientManager) +├── Week 24: LSP (LspClientManager + LspServerInstance) +├── Week 25: OAuth + Bridge + BackgroundTasks + RateLimit +└── 里程碑 M5: 功能对等原始项目 +``` + +**目标**: MCP 服务器可以连接并提供工具,LSP 集成工作,Bridge 模式可以与 IDE 通信,这是与原始 TypeScript 项目功能对等的节点。 + +--- + +### Phase 8: 高级服务(Week 26-28) + +``` +Phase 8: 高级服务 (Week 26-28) +├── Week 26: SessionMemory + AutoDream +├── Week 27: TeamMemorySync + RemoteSessions +├── Week 28: Voice + Notifications + Companion +└── 里程碑: 所有高级功能工作 +``` + +**目标**: 会话记忆、团队记忆同步、语音输入、Companion 精灵等扩展功能全部就位。 + +--- + +### Phase 9: 扩展系统(Week 29-30) + +``` +Phase 9: 扩展系统 (Week 29-30) +├── Week 29: SkillLoader + frontmatter 解析 +├── Week 30: PluginManager + AssemblyLoadContext + Marketplace +└── 里程碑 M6: 可加载外部技能和插件 +``` + +**目标**: 外部 `.md` 技能文件和 `.dll` 插件可以在运行时动态加载,插件市场接口就位。 + +--- + +### Phase 10: 测试(Week 31-33) + +``` +Phase 10: 测试 (Week 31-33) +├── Week 31: 120 单元测试 (工具/引擎/提供商/服务) +├── Week 32: 60 集成测试 (MCP/LSP/Bridge/管道) +├── Week 33: 20 E2E 测试 + 性能基准 +└── 里程碑 M7: 核心路径 80%+ 覆盖率 +``` + +**目标**: 三层测试套件全部通过,覆盖率报告显示核心路径 80% 以上,性能基准记录在案。 + +--- + +### Phase 11: 构建发布(Week 34-35) + +``` +Phase 11: 构建发布 (Week 34-35) +├── Week 34: AOT 优化 + Source Generator + 5 平台构建 +├── Week 35: 安装脚本 + CI/CD + 文档 +└── 里程碑 M8: 一键安装脚本工作 +``` + +**目标**: `install.sh` 可以在 macOS 和 Linux 上一键安装,CI/CD 管道自动构建和发布。 + +--- + +### Phase 12: 文档(Week 36-37,缓冲期) + +``` +Phase 12: 文档 (Week 36-37, buffer) +├── README + 架构文档 + API 文档 +└── 最终验收 +``` + +**目标**: 文档完整,所有设计文档更新为最终实现,项目可以公开发布。 + +--- + +## 24.2 里程碑表 + +| 里程碑 | 完成周 | 验收标准 | 依赖 | +|--------|--------|---------|------| +| M1 | Week 4 | `dotnet run` 输出版本号,无运行时错误 | Phase 1 完成 | +| M2 | Week 8 | `-p "hello"` 返回 API 响应,流式输出到终端 | Phase 2 完成 | +| M3 | Week 15 | 模型成功调用 Bash/Read/Edit 工具完成简单任务 | Phase 4 完成 | +| M4 | Week 22 | 交互式 REPL 可用,权限确认 UI 正常 | Phase 6 完成 | +| M5 | Week 25 | 功能对等原始 TypeScript 项目,MCP/LSP/Bridge 工作 | Phase 7 完成 | +| M6 | Week 30 | 可加载外部技能文件和插件 dll | Phase 9 完成 | +| M7 | Week 33 | 测试覆盖率 >= 80%,全部测试通过 | Phase 10 完成 | +| M8 | Week 35 | 一键安装脚本在 macOS/Linux 工作,CI 绿灯 | Phase 11 完成 | + +--- + +## 24.3 风险评估 + +| 风险 | 概率 | 影响 | 缓解策略 | +|------|------|------|---------| +| MCP SDK 无成熟 .NET 库 | 高 | 高 | Phase 7 前期自研传输层,参考官方 TypeScript SDK 实现 | +| Terminal.Gui v2 组件不足 | 中 | 中 | Spectre.Console 补充缺失组件,必要时自定义 View | +| AOT 反射限制 | 中 | 高 | Source Generator 生成序列化代码,启用 `EnableAotAnalyzer` 提前暴露问题 | +| 插件 ALC 卸载内存泄漏 | 低 | 中 | 定期监控内存,提供强制 GC 入口,文档说明已知限制 | +| LSP OmniSharp 库兼容性 | 中 | 中 | StreamJsonRpc 作为备选,最坏情况自实现 JSON-RPC 层 | +| StreamJsonRpc AOT 兼容 | 中 | 中 | 评估期间同时准备自定义 JSON-RPC 实现,Phase 7 初期决策 | + +--- + +## 24.4 并行开发策略 + +各阶段内部的周可以部分并行推进。具体来说: + +- Phase 3 的三家次要提供商(Bedrock、Vertex、Foundry)可以分配给不同开发者同时实现 +- Phase 4 中文件操作工具(Week 12)和 Shell 工具(Week 13)无依赖关系,可并行 +- Phase 10 的单元测试可以在 Phase 7-9 期间随功能完成同步编写,不必等到 Phase 10 才开始 + +单人开发时按串行顺序执行,团队协作时按上述拆分并行。 + +--- + +## 参考资料 + +- [测试与构建总览](测试与构建.md) +- [测试方案设计](测试与构建-测试方案设计.md) +- [构建与部署](测试与构建-构建与部署.md) +- [原始代码映射 — 测试与构建](reference/原始代码映射-测试与构建.md) +- [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md) diff --git a/docs/测试与构建/测试与构建.md b/docs/测试与构建/测试与构建.md new file mode 100644 index 0000000..11becd1 --- /dev/null +++ b/docs/测试与构建/测试与构建.md @@ -0,0 +1,90 @@ +# 测试与构建 — 总览 + +> 所属项目: free-code .NET 10 重写 +> 配套文档: [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md) | [核心模块设计](../核心模块设计/核心模块设计.md) | [参考映射](reference/原始代码映射-测试与构建.md) + +--- + +## 概述 + +本组文档覆盖 free-code .NET 10 重写项目的质量保证、构建发布和迁移规划三个方面。原始 TypeScript 项目没有系统化的测试套件;.NET 重写从零开始建立完整的分层测试体系,并以 AOT 单文件发布为目标设计构建管道。 + +测试框架采用 **xUnit 2 + NSubstitute 5 + FluentAssertions 7** 组合。目标覆盖率是核心路径 80%+,由 200 个测试用例(120 单元 + 60 集成 + 20 E2E)构成测试金字塔。 + +构建目标是跨 5 个平台(macOS arm64/x64、Linux x64/arm64、Windows x64)的 AOT 原生二进制,通过 `build.sh` 脚本批量发布,配合 `install.sh` 实现一键安装。 + +迁移路线图覆盖 35 个工作周,分 12 个阶段,设置 8 个里程碑节点,附带风险评估矩阵。 + +--- + +## 子模块列表 + +### [测试方案设计](测试与构建-测试方案设计.md) + +覆盖三层测试项目结构、单元测试示例(`BashToolTests`、`ToolRegistryTests`、`AnthropicProviderTests`、`CompanionServiceTests`)、集成测试示例(`McpIntegrationTests`)、E2E 测试示例(`CliTests`),以及测试金字塔数量分布。 + +原始来源: 无直接对应(原始项目无系统测试) + +--- + +### [构建与部署](测试与构建-构建与部署.md) + +覆盖 `Directory.Build.props` 全局 MSBuild 配置、`FreeCode.csproj` 主项目引用、`build.sh` 多平台构建脚本、`install.sh` 一键安装脚本,以及 AOT + 单文件 + Trimmed 发布参数。 + +原始来源: `scripts/build.ts`(Bun 构建脚本)、`install.sh`(原始安装脚本) + +--- + +### [迁移路线图](测试与构建-迁移路线图.md) + +覆盖 35 周 12 阶段详细规划、M1-M8 里程碑说明表,以及风险评估与缓解策略矩阵。 + +原始来源: 无直接对应(新增规划文档) + +--- + +## 测试金字塔 + +``` + [E2E: 20] + / CliTests \ + / OneShotMode \ + / InteractiveRepl \ + ================== + [集成: 60] + / QueryPipeline \ + / McpIntegration \ + / LspIntegration \ + / BridgeIntegration \ + / TaskManager Plugins \ +================================= + [单元: 120] + Engine / Tools / Commands / + ApiProviders / Mcp / Lsp / + Services / State +``` + +--- + +## 关键设计决策 + +**测试替身策略** + +所有外部依赖(HTTP 客户端、文件系统、进程管理)通过接口注入,测试中由 NSubstitute 的 `Substitute.For()` 替换,确保单元测试完全隔离,不依赖网络或本地环境。 + +**AOT 兼容优先** + +构建配置全面启用 `AotPublish`、`PublishTrimmed`、`EnableAotAnalyzer`,确保代码在编译期就暴露反射问题,而不是在生产运行时崩溃。JSON 序列化通过 Source Generator 生成,完全不依赖运行时反射。 + +**平台矩阵覆盖** + +`build.sh` 对 `osx-arm64`、`osx-x64`、`linux-x64`、`linux-arm64`、`win-x64` 五个 RID 各生成独立的原生二进制,与原始 Bun 构建脚本的单平台模式相比,构建产物更小、启动更快。 + +--- + +## 参考资料 + +- [原始代码映射 — 测试与构建](reference/原始代码映射-测试与构建.md) +- [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md) +- [核心模块设计 — 工具系统](../核心模块设计/核心模块设计-工具系统.md) +- [基础设施设计](../基础设施设计/基础设施设计.md) diff --git a/easy-code.slnx b/easy-code.slnx new file mode 100644 index 0000000..b87f991 --- /dev/null +++ b/easy-code.slnx @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..66181b6 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SOLUTION_ROOT="$(dirname "$SCRIPT_DIR")" +VERSION="${1:-0.1.0}" + +echo "Building free-code .NET v${VERSION}..." + +RID_TARGETS=("osx-arm64" "osx-x64" "linux-x64" "linux-arm64" "win-x64") + +for RID in "${RID_TARGETS[@]}"; do + echo "Publishing for ${RID}..." + dotnet publish "${SOLUTION_ROOT}/src/FreeCode/FreeCode.csproj" \ + -c Release \ + -r "${RID}" \ + /p:Version="${VERSION}" \ + /p:PublishAot=true \ + /p:PublishSingleFile=true \ + /p:PublishTrimmed=true \ + -o "${SOLUTION_ROOT}/dist/${RID}" +done + +echo "Build complete. Outputs in ${SOLUTION_ROOT}/dist/" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..ea711e3 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,55 @@ +#!/bin/bash +set -euo pipefail + +REPO="free-code/free-code" +INSTALL_DIR="${HOME}/.free-code/bin" + +echo "Installing free-code..." + +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +ARCH="$(uname -m)" + +case "${OS}" in + darwin) OS="osx" ;; + linux) OS="linux" ;; + *) echo "Unsupported OS: ${OS}"; exit 1 ;; +esac + +case "${ARCH}" in + x86_64|amd64) ARCH="x64" ;; + arm64|aarch64) ARCH="arm64" ;; + *) echo "Unsupported architecture: ${ARCH}"; exit 1 ;; +esac + +RID="${OS}-${ARCH}" +BINARY_NAME="free-code" + +if [ "${OS}" = "win" ]; then + BINARY_NAME="free-code.exe" +fi + +mkdir -p "${INSTALL_DIR}" +echo "Downloading ${RID} binary..." + +echo "Building from source..." +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +dotnet publish "${SCRIPT_DIR}/../src/FreeCode/FreeCode.csproj" \ + -c Release \ + -r "${RID}" \ + /p:PublishAot=true \ + /p:PublishSingleFile=true \ + /p:PublishTrimmed=true \ + -o "${INSTALL_DIR}" + +chmod +x "${INSTALL_DIR}/${BINARY_NAME}" 2>/dev/null || true + +echo "Adding to PATH..." +PROFILE_FILES=("${HOME}/.bashrc" "${HOME}/.zshrc" "${HOME}/.profile") +for PROFILE in "${PROFILE_FILES[@]}"; do + if [ -f "${PROFILE}" ] && ! grep -q "${INSTALL_DIR}" "${PROFILE}" 2>/dev/null; then + printf 'export PATH="%s:$PATH"\n' "${INSTALL_DIR}" >> "${PROFILE}" + echo "Added to ${PROFILE}" + fi +done + +echo "Installation complete. Run 'source ~/.bashrc' or start a new terminal." diff --git a/src/FreeCode.ApiProviders/AnthropicProvider.cs b/src/FreeCode.ApiProviders/AnthropicProvider.cs new file mode 100644 index 0000000..2b9958a --- /dev/null +++ b/src/FreeCode.ApiProviders/AnthropicProvider.cs @@ -0,0 +1,187 @@ +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using FreeCode.Core.Models; + +namespace FreeCode.ApiProviders; + +internal sealed record PendingToolUse(string Id, string Name, StringBuilder JsonBuilder); + +public sealed class AnthropicProvider : FreeCode.Core.Interfaces.IApiProvider +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + private readonly HttpClient _httpClient; + private readonly string _baseUrl; + private readonly string? _apiKey; + private readonly string _model; + + public AnthropicProvider(HttpClient? httpClient = null) + { + _httpClient = httpClient ?? new HttpClient(); + _baseUrl = Environment.GetEnvironmentVariable("ANTHROPIC_BASE_URL") ?? "https://api.anthropic.com"; + _apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") + ?? Environment.GetEnvironmentVariable("ANTHROPIC_AUTH_TOKEN"); + _model = Environment.GetEnvironmentVariable("ANTHROPIC_MODEL") ?? "claude-sonnet-4-6"; + } + + public async IAsyncEnumerable StreamAsync(ApiRequest request, CancellationToken ct = default) + { + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, new Uri(new Uri(_baseUrl.TrimEnd('/')), "/v1/messages")); + httpRequest.Headers.TryAddWithoutValidation("anthropic-version", "2023-06-01"); + httpRequest.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream")); + if (!string.IsNullOrWhiteSpace(_apiKey)) + { + httpRequest.Headers.TryAddWithoutValidation("x-api-key", _apiKey); + httpRequest.Headers.TryAddWithoutValidation("authorization", $"Bearer {_apiKey}"); + } + + var payload = new + { + model = request.Model ?? _model, + system = request.SystemPrompt, + messages = request.Messages, + tools = request.Tools, + stream = true, + max_tokens = 4096 + }; + + httpRequest.Content = JsonContent.Create(payload, options: SerializerOptions); + + using var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var pendingToolUses = new Dictionary(); + + await foreach (var data in ReadSseDataAsync(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), ct).ConfigureAwait(false)) + { + using var document = JsonDocument.Parse(data); + var root = document.RootElement; + if (!root.TryGetProperty("type", out var typeProperty)) + { + continue; + } + + var type = typeProperty.GetString(); + switch (type) + { + case "content_block_start": + if (root.TryGetProperty("content_block", out var contentBlock) + && contentBlock.TryGetProperty("type", out var blockType)) + { + var blockTypeStr = blockType.GetString(); + var index = root.TryGetProperty("index", out var indexProp) ? indexProp.GetInt32() : -1; + + if (string.Equals(blockTypeStr, "tool_use", StringComparison.OrdinalIgnoreCase) && index >= 0) + { + pendingToolUses[index] = new PendingToolUse( + contentBlock.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? string.Empty : string.Empty, + contentBlock.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? string.Empty : string.Empty, + new StringBuilder()); + } + else if (string.Equals(blockTypeStr, "text", StringComparison.OrdinalIgnoreCase)) + { + if (contentBlock.TryGetProperty("text", out var startText)) + { + var startTextValue = startText.GetString(); + if (!string.IsNullOrEmpty(startTextValue)) + { + yield return new SDKMessage.StreamingDelta(startTextValue); + } + } + } + } + + break; + + case "content_block_delta": + if (root.TryGetProperty("delta", out var delta)) + { + var deltaIndex = root.TryGetProperty("index", out var di) ? di.GetInt32() : -1; + + if (delta.TryGetProperty("text", out var textDelta)) + { + yield return new SDKMessage.StreamingDelta(textDelta.GetString() ?? string.Empty); + } + else if (delta.TryGetProperty("partial_json", out var partialJson) && deltaIndex >= 0 && pendingToolUses.TryGetValue(deltaIndex, out var pending)) + { + pending.JsonBuilder.Append(partialJson.GetString() ?? string.Empty); + } + } + + break; + + case "content_block_stop": + var stopIndex = root.TryGetProperty("index", out var si) ? si.GetInt32() : -1; + if (stopIndex >= 0 && pendingToolUses.TryGetValue(stopIndex, out var completed)) + { + pendingToolUses.Remove(stopIndex); + var rawJson = completed.JsonBuilder.ToString(); + JsonElement toolInput; + if (string.IsNullOrWhiteSpace(rawJson)) + { + toolInput = JsonDocument.Parse("{}").RootElement.Clone(); + } + else + { + using var inputDoc = JsonDocument.Parse(rawJson); + toolInput = inputDoc.RootElement.Clone(); + } + + yield return new SDKMessage.ToolUseStart(completed.Id, completed.Name, toolInput); + } + + break; + + case "message_stop": + yield break; + } + } + } + + private static async IAsyncEnumerable ReadSseDataAsync(Stream stream, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) + { + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: false); + var buffer = new StringBuilder(); + + while (!ct.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(ct).ConfigureAwait(false); + if (line is null) + { + if (buffer.Length > 0) + { + yield return buffer.ToString(); + } + + yield break; + } + + if (line.Length == 0) + { + if (buffer.Length > 0) + { + yield return buffer.ToString(); + buffer.Clear(); + } + + continue; + } + + if (line.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + var data = line.AsSpan(5).TrimStart(); + if (data.Length == 0) + { + continue; + } + + if (buffer.Length > 0) + { + buffer.Append('\n'); + } + + buffer.Append(data); + } + } + } +} diff --git a/src/FreeCode.ApiProviders/ApiProviderRouter.cs b/src/FreeCode.ApiProviders/ApiProviderRouter.cs new file mode 100644 index 0000000..eceffa0 --- /dev/null +++ b/src/FreeCode.ApiProviders/ApiProviderRouter.cs @@ -0,0 +1,37 @@ +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; + +namespace FreeCode.ApiProviders; + +public class ApiProviderRouter : IApiProviderRouter +{ + private readonly IServiceProvider _services; + private readonly ApiProviderType _activeProvider; + + public ApiProviderRouter(IServiceProvider services) + { + _services = services; + _activeProvider = DetectProvider(); + } + + private static ApiProviderType DetectProvider() + { + if (Env("CLAUDE_CODE_USE_OPENAI") == "1") return ApiProviderType.OpenAICodex; + if (Env("CLAUDE_CODE_USE_BEDROCK") == "1") return ApiProviderType.AwsBedrock; + if (Env("CLAUDE_CODE_USE_VERTEX") == "1") return ApiProviderType.GoogleVertex; + if (Env("CLAUDE_CODE_USE_FOUNDRY") == "1") return ApiProviderType.AnthropicFoundry; + return ApiProviderType.Anthropic; + } + + public IApiProvider GetActiveProvider() => _activeProvider switch + { + ApiProviderType.Anthropic => (AnthropicProvider)_services.GetService(typeof(AnthropicProvider))!, + ApiProviderType.OpenAICodex => (CodexProvider)_services.GetService(typeof(CodexProvider))!, + ApiProviderType.AwsBedrock => (BedrockProvider)_services.GetService(typeof(BedrockProvider))!, + ApiProviderType.GoogleVertex => (VertexProvider)_services.GetService(typeof(VertexProvider))!, + ApiProviderType.AnthropicFoundry => (FoundryProvider)_services.GetService(typeof(FoundryProvider))!, + _ => throw new InvalidOperationException($"Unknown provider: {_activeProvider}") + }; + + private static string? Env(string name) => Environment.GetEnvironmentVariable(name); +} diff --git a/src/FreeCode.ApiProviders/BedrockProvider.cs b/src/FreeCode.ApiProviders/BedrockProvider.cs new file mode 100644 index 0000000..f5456da --- /dev/null +++ b/src/FreeCode.ApiProviders/BedrockProvider.cs @@ -0,0 +1,232 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using Microsoft.Extensions.Configuration; + +namespace FreeCode.ApiProviders; + +public sealed class BedrockProvider : FreeCode.Core.Interfaces.IApiProvider +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + private readonly HttpClient _httpClient; + private readonly string _baseUrl; + private readonly string _region; + private readonly string _modelPrefix; + private readonly string? _bearerToken; + private readonly string? _apiKey; + private readonly bool _skipAuth; + private readonly IRateLimitService? _rateLimitService; + + public BedrockProvider(HttpClient? httpClient = null, IConfiguration? configuration = null, IRateLimitService? rateLimitService = null) + { + _httpClient = httpClient ?? new HttpClient(); + _rateLimitService = rateLimitService; + _region = GetSetting(configuration, "AWS_REGION", "Bedrock:Region") + ?? GetSetting(configuration, "AWS_DEFAULT_REGION", "Bedrock:DefaultRegion") + ?? "us-east-1"; + _baseUrl = GetSetting(configuration, "ANTHROPIC_BEDROCK_BASE_URL", "Bedrock:BaseUrl") + ?? $"https://bedrock-runtime.{_region}.amazonaws.com"; + _modelPrefix = GetSetting(configuration, "AWS_BEDROCK_MODEL_PREFIX", "Bedrock:ModelPrefix") ?? "us.anthropic"; + _bearerToken = ResolveBearerToken(configuration); + _apiKey = GetSetting(configuration, "ANTHROPIC_BEDROCK_API_KEY", "Bedrock:ApiKey"); + _skipAuth = string.Equals(GetSetting(configuration, "CLAUDE_CODE_SKIP_BEDROCK_AUTH", "Bedrock:SkipAuth"), "1", StringComparison.OrdinalIgnoreCase); + } + + public async IAsyncEnumerable StreamAsync(ApiRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + var model = request.Model ?? "claude-sonnet-4-6"; + var modelId = model.Contains(':', StringComparison.Ordinal) ? model : $"{_modelPrefix}.{model}"; + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, new Uri(new Uri(_baseUrl.TrimEnd('/')), $"/model/{Uri.EscapeDataString(modelId)}/invoke-with-response-stream")); + httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + if (!_skipAuth && !string.IsNullOrWhiteSpace(_bearerToken)) + { + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _bearerToken); + } + else if (!_skipAuth && !string.IsNullOrWhiteSpace(_apiKey)) + { + httpRequest.Headers.Add("x-api-key", _apiKey); + } + + var payload = new + { + anthropic_version = "2023-06-01", + system = request.SystemPrompt, + messages = request.Messages, + tools = request.Tools, + max_tokens = 4096, + stream = true + }; + + httpRequest.Content = JsonContent.Create(payload, options: SerializerOptions); + + using var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + var responseHeaders = ToHeaderDictionary(response.Headers, response.Content.Headers); + if (_rateLimitService?.CanProceed(responseHeaders) == false) + { + throw CreateRateLimitException(responseHeaders); + } + + response.EnsureSuccessStatusCode(); + + await foreach (var data in ReadSseDataAsync(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), ct).ConfigureAwait(false)) + { + using var document = JsonDocument.Parse(data); + var root = document.RootElement; + if (!root.TryGetProperty("type", out var typeProperty)) + { + continue; + } + + switch (typeProperty.GetString()) + { + case "content_block_delta": + if (root.TryGetProperty("delta", out var delta)) + { + if (delta.TryGetProperty("text", out var text)) + { + yield return new SDKMessage.StreamingDelta(text.GetString() ?? string.Empty); + } + else if (delta.TryGetProperty("partial_json", out var partialJson)) + { + yield return new SDKMessage.StreamingDelta(partialJson.GetString() ?? string.Empty); + } + } + + break; + case "content_block_start": + if (root.TryGetProperty("content_block", out var contentBlock) + && contentBlock.TryGetProperty("type", out var blockType) + && string.Equals(blockType.GetString(), "tool_use", StringComparison.OrdinalIgnoreCase)) + { + var input = contentBlock.TryGetProperty("input", out var inputProperty) + ? inputProperty.Clone() + : JsonDocument.Parse("{}").RootElement.Clone(); + + yield return new SDKMessage.ToolUseStart( + contentBlock.TryGetProperty("id", out var idProperty) ? idProperty.GetString() ?? string.Empty : string.Empty, + contentBlock.TryGetProperty("name", out var nameProperty) ? nameProperty.GetString() ?? string.Empty : string.Empty, + input); + } + + break; + case "message_stop": + case "response.completed": + yield break; + } + } + } + + private static string? ResolveBearerToken(IConfiguration? configuration) + { + var credentialsFromEnv = GetSetting(configuration, + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_SESSION_TOKEN", + "AWS_ACCESS_TOKEN", + "Bedrock:BearerToken"); + if (!string.IsNullOrWhiteSpace(credentialsFromEnv)) + { + return credentialsFromEnv; + } + + var accessKey = GetSetting(configuration, "AWS_ACCESS_KEY_ID", "Bedrock:AccessKeyId"); + var secretKey = GetSetting(configuration, "AWS_SECRET_ACCESS_KEY", "Bedrock:SecretAccessKey"); + if (!string.IsNullOrWhiteSpace(accessKey) && !string.IsNullOrWhiteSpace(secretKey)) + { + var sessionToken = GetSetting(configuration, "AWS_SESSION_TOKEN", "Bedrock:SessionToken"); + return string.IsNullOrWhiteSpace(sessionToken) + ? $"{accessKey}:{secretKey}" + : $"{accessKey}:{secretKey}:{sessionToken}"; + } + + return null; + } + + private static string? GetSetting(IConfiguration? configuration, params string[] keys) + { + foreach (var key in keys) + { + var value = Environment.GetEnvironmentVariable(key) ?? configuration?[key]; + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return null; + } + + private static Dictionary ToHeaderDictionary(HttpResponseHeaders responseHeaders, HttpContentHeaders contentHeaders) + { + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var header in responseHeaders) + { + headers[header.Key] = string.Join(",", header.Value); + } + + foreach (var header in contentHeaders) + { + headers[header.Key] = string.Join(",", header.Value); + } + + return headers; + } + + private Exception CreateRateLimitException(IReadOnlyDictionary headers) + { + var retryAfter = _rateLimitService?.GetRetryAfter(headers as IDictionary ?? new Dictionary(headers, StringComparer.OrdinalIgnoreCase)); + return retryAfter is { } delay && delay > TimeSpan.Zero + ? new HttpRequestException($"Bedrock rate limit exceeded. Retry after {delay.TotalSeconds:F0} seconds.") + : new HttpRequestException("Bedrock rate limit exceeded."); + } + + private static async IAsyncEnumerable ReadSseDataAsync(Stream stream, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) + { + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: false); + var buffer = new StringBuilder(); + + while (!ct.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(ct).ConfigureAwait(false); + if (line is null) + { + if (buffer.Length > 0) + { + yield return buffer.ToString(); + } + + yield break; + } + + if (line.Length == 0) + { + if (buffer.Length > 0) + { + yield return buffer.ToString(); + buffer.Clear(); + } + + continue; + } + + if (line.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + var data = line.AsSpan(5).TrimStart(); + if (data.Length == 0) + { + continue; + } + + if (buffer.Length > 0) + { + buffer.Append('\n'); + } + + buffer.Append(data); + } + } + } +} diff --git a/src/FreeCode.ApiProviders/CodexProvider.cs b/src/FreeCode.ApiProviders/CodexProvider.cs new file mode 100644 index 0000000..3742233 --- /dev/null +++ b/src/FreeCode.ApiProviders/CodexProvider.cs @@ -0,0 +1,151 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using FreeCode.Core.Models; + +namespace FreeCode.ApiProviders; + +public sealed class CodexProvider : FreeCode.Core.Interfaces.IApiProvider +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + private readonly HttpClient _httpClient; + private readonly string _baseUrl; + private readonly string? _token; + private readonly string _model; + + public CodexProvider(HttpClient? httpClient = null) + { + _httpClient = httpClient ?? new HttpClient(); + _baseUrl = Environment.GetEnvironmentVariable("OPENAI_BASE_URL") ?? "https://api.openai.com"; + _token = Environment.GetEnvironmentVariable("OPENAI_API_KEY") + ?? Environment.GetEnvironmentVariable("OPENAI_BEARER_TOKEN") + ?? Environment.GetEnvironmentVariable("CLAUDE_CODE_OPENAI_API_KEY"); + _model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-5.3-codex"; + } + + public async IAsyncEnumerable StreamAsync(ApiRequest request, CancellationToken ct = default) + { + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, new Uri(new Uri(_baseUrl.TrimEnd('/')), "/v1/responses")); + httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + if (!string.IsNullOrWhiteSpace(_token)) + { + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); + } + + var payload = new + { + model = request.Model ?? _model, + input = request.Messages, + instructions = request.SystemPrompt, + tools = request.Tools, + stream = true + }; + + httpRequest.Content = JsonContent.Create(payload, options: SerializerOptions); + + using var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + await foreach (var data in ReadSseDataAsync(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), ct).ConfigureAwait(false)) + { + using var document = JsonDocument.Parse(data); + var root = document.RootElement; + var type = root.TryGetProperty("type", out var typeProperty) ? typeProperty.GetString() : null; + switch (type) + { + case "response.output_text.delta": + if (root.TryGetProperty("delta", out var delta) || root.TryGetProperty("text", out delta)) + { + yield return new SDKMessage.StreamingDelta(delta.GetString() ?? string.Empty); + } + + break; + case "response.output_item.added": + if (root.TryGetProperty("item", out var item) + && item.TryGetProperty("type", out var itemType) + && (string.Equals(itemType.GetString(), "function_call", StringComparison.OrdinalIgnoreCase) + || string.Equals(itemType.GetString(), "tool_call", StringComparison.OrdinalIgnoreCase))) + { + var input = item.TryGetProperty("arguments", out var args) + ? ParseJsonOrEmpty(args.GetString()) + : JsonDocument.Parse("{}").RootElement.Clone(); + + yield return new SDKMessage.ToolUseStart( + item.TryGetProperty("id", out var idProperty) ? idProperty.GetString() ?? string.Empty : string.Empty, + item.TryGetProperty("name", out var nameProperty) ? nameProperty.GetString() ?? string.Empty : string.Empty, + input); + } + + break; + case "response.completed": + case "response.failed": + yield break; + } + } + } + + private static JsonElement ParseJsonOrEmpty(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return JsonDocument.Parse("{}").RootElement.Clone(); + } + + try + { + return JsonDocument.Parse(json).RootElement.Clone(); + } + catch (JsonException) + { + return JsonDocument.Parse("{}").RootElement.Clone(); + } + } + + private static async IAsyncEnumerable ReadSseDataAsync(Stream stream, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) + { + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: false); + var buffer = new StringBuilder(); + + while (!ct.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(ct).ConfigureAwait(false); + if (line is null) + { + if (buffer.Length > 0) + { + yield return buffer.ToString(); + } + + yield break; + } + + if (line.Length == 0) + { + if (buffer.Length > 0) + { + yield return buffer.ToString(); + buffer.Clear(); + } + + continue; + } + + if (line.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + var data = line.AsSpan(5).TrimStart(); + if (data.Length == 0) + { + continue; + } + + if (buffer.Length > 0) + { + buffer.Append('\n'); + } + + buffer.Append(data); + } + } + } +} diff --git a/src/FreeCode.ApiProviders/FoundryProvider.cs b/src/FreeCode.ApiProviders/FoundryProvider.cs new file mode 100644 index 0000000..f404642 --- /dev/null +++ b/src/FreeCode.ApiProviders/FoundryProvider.cs @@ -0,0 +1,141 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using FreeCode.Core.Models; + +namespace FreeCode.ApiProviders; + +public sealed class FoundryProvider : FreeCode.Core.Interfaces.IApiProvider +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + private readonly HttpClient _httpClient; + private readonly string _baseUrl; + private readonly string? _apiKey; + private readonly string _deployment; + + public FoundryProvider(HttpClient? httpClient = null) + { + _httpClient = httpClient ?? new HttpClient(); + _baseUrl = Environment.GetEnvironmentVariable("ANTHROPIC_FOUNDRY_BASE_URL") + ?? Environment.GetEnvironmentVariable("ANTHROPIC_BASE_URL") + ?? "https://api.anthropic.com"; + _apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_FOUNDRY_API_KEY") + ?? Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY"); + _deployment = Environment.GetEnvironmentVariable("ANTHROPIC_FOUNDRY_DEPLOYMENT") ?? "default"; + } + + public async IAsyncEnumerable StreamAsync(ApiRequest request, CancellationToken ct = default) + { + var model = request.Model ?? _deployment; + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, new Uri(new Uri(_baseUrl.TrimEnd('/')), $"/v1/deployments/{Uri.EscapeDataString(model)}/messages")); + httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + if (!string.IsNullOrWhiteSpace(_apiKey)) + { + httpRequest.Headers.TryAddWithoutValidation("x-api-key", _apiKey); + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + } + + var payload = new + { + model, + system = request.SystemPrompt, + messages = request.Messages, + tools = request.Tools, + stream = true, + max_tokens = 4096 + }; + + httpRequest.Content = JsonContent.Create(payload, options: SerializerOptions); + + using var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + await foreach (var data in ReadSseDataAsync(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), ct).ConfigureAwait(false)) + { + using var document = JsonDocument.Parse(data); + var root = document.RootElement; + if (!root.TryGetProperty("type", out var typeProperty)) + { + continue; + } + + switch (typeProperty.GetString()) + { + case "content_block_delta": + if (root.TryGetProperty("delta", out var delta) && delta.TryGetProperty("text", out var text)) + { + yield return new SDKMessage.StreamingDelta(text.GetString() ?? string.Empty); + } + + break; + case "content_block_start": + if (root.TryGetProperty("content_block", out var contentBlock) + && contentBlock.TryGetProperty("type", out var blockType) + && string.Equals(blockType.GetString(), "tool_use", StringComparison.OrdinalIgnoreCase)) + { + var input = contentBlock.TryGetProperty("input", out var inputProperty) + ? inputProperty.Clone() + : JsonDocument.Parse("{}").RootElement.Clone(); + + yield return new SDKMessage.ToolUseStart( + contentBlock.TryGetProperty("id", out var idProperty) ? idProperty.GetString() ?? string.Empty : string.Empty, + contentBlock.TryGetProperty("name", out var nameProperty) ? nameProperty.GetString() ?? string.Empty : string.Empty, + input); + } + + break; + case "message_stop": + case "response.completed": + yield break; + } + } + } + + private static async IAsyncEnumerable ReadSseDataAsync(Stream stream, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) + { + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: false); + var buffer = new StringBuilder(); + + while (!ct.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(ct).ConfigureAwait(false); + if (line is null) + { + if (buffer.Length > 0) + { + yield return buffer.ToString(); + } + + yield break; + } + + if (line.Length == 0) + { + if (buffer.Length > 0) + { + yield return buffer.ToString(); + buffer.Clear(); + } + + continue; + } + + if (line.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + var data = line.AsSpan(5).TrimStart(); + if (data.Length == 0) + { + continue; + } + + if (buffer.Length > 0) + { + buffer.Append('\n'); + } + + buffer.Append(data); + } + } + } +} diff --git a/src/FreeCode.ApiProviders/FreeCode.ApiProviders.csproj b/src/FreeCode.ApiProviders/FreeCode.ApiProviders.csproj new file mode 100644 index 0000000..6491b3a --- /dev/null +++ b/src/FreeCode.ApiProviders/FreeCode.ApiProviders.csproj @@ -0,0 +1,10 @@ + + + FreeCode.ApiProviders + + + + + + + diff --git a/src/FreeCode.ApiProviders/ServiceCollectionExtensions.cs b/src/FreeCode.ApiProviders/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..8c57339 --- /dev/null +++ b/src/FreeCode.ApiProviders/ServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode.ApiProviders; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddFreeCodeApiProviders(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/FreeCode.ApiProviders/VertexProvider.cs b/src/FreeCode.ApiProviders/VertexProvider.cs new file mode 100644 index 0000000..2a55df7 --- /dev/null +++ b/src/FreeCode.ApiProviders/VertexProvider.cs @@ -0,0 +1,246 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using Microsoft.Extensions.Configuration; + +namespace FreeCode.ApiProviders; + +public sealed class VertexProvider : FreeCode.Core.Interfaces.IApiProvider +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + private readonly HttpClient _httpClient; + private readonly string _projectId; + private readonly string _location; + private readonly string _baseUrl; + private readonly string? _accessToken; + private readonly IRateLimitService? _rateLimitService; + + public VertexProvider(HttpClient? httpClient = null, IConfiguration? configuration = null, IRateLimitService? rateLimitService = null) + { + _httpClient = httpClient ?? new HttpClient(); + _rateLimitService = rateLimitService; + _projectId = GetSetting(configuration, + "GOOGLE_CLOUD_PROJECT", + "GCP_PROJECT", + "GOOGLE_PROJECT_ID", + "Vertex:ProjectId") + ?? "unknown-project"; + _location = GetSetting(configuration, "VERTEX_LOCATION", "GOOGLE_CLOUD_LOCATION", "Vertex:Location") + ?? "us-central1"; + _baseUrl = GetSetting(configuration, "VERTEX_BASE_URL", "Vertex:BaseUrl") ?? "https://aiplatform.googleapis.com"; + _accessToken = ResolveAccessToken(configuration); + } + + public async IAsyncEnumerable StreamAsync(ApiRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + var model = request.Model ?? "claude-sonnet-4-6"; + var url = new Uri(new Uri(_baseUrl.TrimEnd('/')), $"/v1/projects/{Uri.EscapeDataString(_projectId)}/locations/{Uri.EscapeDataString(_location)}/publishers/anthropic/models/{Uri.EscapeDataString(model)}:streamRawPredict"); + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url); + httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + if (!string.IsNullOrWhiteSpace(_accessToken)) + { + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken); + } + + var payload = new + { + system = request.SystemPrompt, + contents = request.Messages, + tools = request.Tools, + stream = true + }; + + httpRequest.Content = JsonContent.Create(payload, options: SerializerOptions); + + using var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + var responseHeaders = ToHeaderDictionary(response.Headers, response.Content.Headers); + if (_rateLimitService?.CanProceed(responseHeaders) == false) + { + throw CreateRateLimitException(responseHeaders); + } + + response.EnsureSuccessStatusCode(); + + await foreach (var data in ReadSseDataAsync(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), ct).ConfigureAwait(false)) + { + using var document = JsonDocument.Parse(data); + var root = document.RootElement; + if (!root.TryGetProperty("type", out var typeProperty)) + { + continue; + } + + switch (typeProperty.GetString()) + { + case "content_block_delta": + if (root.TryGetProperty("delta", out var delta) && delta.TryGetProperty("text", out var text)) + { + yield return new SDKMessage.StreamingDelta(text.GetString() ?? string.Empty); + } + + break; + case "content_block_start": + if (root.TryGetProperty("content_block", out var contentBlock) + && contentBlock.TryGetProperty("type", out var blockType) + && string.Equals(blockType.GetString(), "tool_use", StringComparison.OrdinalIgnoreCase)) + { + var input = contentBlock.TryGetProperty("input", out var inputProperty) + ? inputProperty.Clone() + : JsonDocument.Parse("{}").RootElement.Clone(); + + yield return new SDKMessage.ToolUseStart( + contentBlock.TryGetProperty("id", out var idProperty) ? idProperty.GetString() ?? string.Empty : string.Empty, + contentBlock.TryGetProperty("name", out var nameProperty) ? nameProperty.GetString() ?? string.Empty : string.Empty, + input); + } + + break; + case "message_stop": + case "response.completed": + yield break; + } + } + } + + private static string? ResolveAccessToken(IConfiguration? configuration) + { + var accessToken = GetSetting(configuration, + "GOOGLE_OAUTH_ACCESS_TOKEN", + "GCP_ACCESS_TOKEN", + "GOOGLE_APPLICATION_CREDENTIALS_ACCESS_TOKEN", + "Vertex:AccessToken"); + if (!string.IsNullOrWhiteSpace(accessToken)) + { + return accessToken; + } + + var credentialsJson = GetSetting(configuration, "GOOGLE_APPLICATION_CREDENTIALS_JSON", "Vertex:CredentialsJson"); + if (!string.IsNullOrWhiteSpace(credentialsJson)) + { + try + { + using var document = JsonDocument.Parse(credentialsJson); + if (document.RootElement.TryGetProperty("access_token", out var tokenElement)) + { + return tokenElement.GetString(); + } + } + catch (JsonException) + { + return null; + } + } + + var credentialsPath = GetSetting(configuration, "GOOGLE_APPLICATION_CREDENTIALS", "Vertex:CredentialsPath"); + if (!string.IsNullOrWhiteSpace(credentialsPath) && File.Exists(credentialsPath)) + { + try + { + using var document = JsonDocument.Parse(File.ReadAllText(credentialsPath)); + if (document.RootElement.TryGetProperty("access_token", out var tokenElement)) + { + return tokenElement.GetString(); + } + } + catch (IOException) + { + return null; + } + catch (JsonException) + { + return null; + } + } + + return null; + } + + private static string? GetSetting(IConfiguration? configuration, params string[] keys) + { + foreach (var key in keys) + { + var value = Environment.GetEnvironmentVariable(key) ?? configuration?[key]; + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return null; + } + + private static Dictionary ToHeaderDictionary(HttpResponseHeaders responseHeaders, HttpContentHeaders contentHeaders) + { + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var header in responseHeaders) + { + headers[header.Key] = string.Join(",", header.Value); + } + + foreach (var header in contentHeaders) + { + headers[header.Key] = string.Join(",", header.Value); + } + + return headers; + } + + private Exception CreateRateLimitException(IReadOnlyDictionary headers) + { + var retryAfter = _rateLimitService?.GetRetryAfter(headers as IDictionary ?? new Dictionary(headers, StringComparer.OrdinalIgnoreCase)); + return retryAfter is { } delay && delay > TimeSpan.Zero + ? new HttpRequestException($"Vertex rate limit exceeded. Retry after {delay.TotalSeconds:F0} seconds.") + : new HttpRequestException("Vertex rate limit exceeded."); + } + + private static async IAsyncEnumerable ReadSseDataAsync(Stream stream, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) + { + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: false); + var buffer = new StringBuilder(); + + while (!ct.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(ct).ConfigureAwait(false); + if (line is null) + { + if (buffer.Length > 0) + { + yield return buffer.ToString(); + } + + yield break; + } + + if (line.Length == 0) + { + if (buffer.Length > 0) + { + yield return buffer.ToString(); + buffer.Clear(); + } + + continue; + } + + if (line.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + var data = line.AsSpan(5).TrimStart(); + if (data.Length == 0) + { + continue; + } + + if (buffer.Length > 0) + { + buffer.Append('\n'); + } + + buffer.Append(data); + } + } + } +} diff --git a/src/FreeCode.Bridge/BridgeApiClient.cs b/src/FreeCode.Bridge/BridgeApiClient.cs new file mode 100644 index 0000000..75356ec --- /dev/null +++ b/src/FreeCode.Bridge/BridgeApiClient.cs @@ -0,0 +1,120 @@ +using System.Net.Http.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Models; + +namespace FreeCode.Bridge; + +public sealed class BridgeApiClient +{ + private readonly HttpClient _httpClient; + + public BridgeApiClient(HttpClient? httpClient = null) + { + _httpClient = httpClient ?? new HttpClient(); + _httpClient.Timeout = TimeSpan.FromSeconds(30); + } + + public async Task GetStatusAsync(BridgeConfig config, CancellationToken ct = default) + { + using var response = await _httpClient.GetAsync(Root(config, "bridge/status"), ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await ReadJsonAsync(response, ct).ConfigureAwait(false) ?? new BridgeStatusInfo(BridgeStatus.Idle); + } + + public async Task PostEventAsync(BridgeConfig config, object payload, CancellationToken ct = default) + { + using var response = await SendJsonAsync(HttpMethod.Post, Root(config, "bridge/events"), payload, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + public async Task SendSessionUpdateAsync(BridgeConfig config, string sessionId, object payload, CancellationToken ct = default) + { + using var response = await SendJsonAsync(HttpMethod.Post, Root(config, $"bridge/sessions/{Uri.EscapeDataString(sessionId)}/update"), payload, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + public async Task RequestPermissionAsync(BridgeConfig config, string sessionId, object payload, CancellationToken ct = default) + { + using var response = await SendJsonAsync(HttpMethod.Post, Root(config, $"bridge/sessions/{Uri.EscapeDataString(sessionId)}/permissions/request"), payload, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await ReadJsonAsync(response, ct).ConfigureAwait(false); + } + + public async Task RegisterBridgeEnvironmentAsync(BridgeConfig config, BridgeEnvironment environment, CancellationToken ct = default) + { + using var response = await SendJsonAsync(HttpMethod.Post, Root(config, "bridge/environments"), environment, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await ReadJsonAsync(response, ct).ConfigureAwait(false) ?? new BridgeStatusInfo(BridgeStatus.Registered); + } + + public async Task PollForWorkAsync(BridgeConfig config, string environmentId, CancellationToken ct = default) + { + using var response = await _httpClient.GetAsync(Root(config, $"bridge/environments/{Uri.EscapeDataString(environmentId)}/work"), ct).ConfigureAwait(false); + if (response.StatusCode == System.Net.HttpStatusCode.NoContent) + { + return null; + } + + response.EnsureSuccessStatusCode(); + return await ReadJsonAsync(response, ct).ConfigureAwait(false); + } + + public async Task AcknowledgeWorkAsync(BridgeConfig config, string workId, string sessionToken, CancellationToken ct = default) + { + using var response = await SendJsonAsync(HttpMethod.Post, Root(config, $"bridge/work/{Uri.EscapeDataString(workId)}/ack"), new { sessionToken }, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + public async Task SpawnSessionAsync(BridgeConfig config, SessionSpawnOptions options, CancellationToken ct = default) + { + using var response = await SendJsonAsync(HttpMethod.Post, Root(config, "bridge/sessions"), options, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await ReadJsonAsync(response, ct).ConfigureAwait(false) ?? new SessionHandle(options.Environment.Id, string.Empty); + } + + public async Task SendPermissionResponseAsync(BridgeConfig config, string sessionId, PermissionResponse responseBody, CancellationToken ct = default) + { + using var response = await SendJsonAsync(HttpMethod.Post, Root(config, $"bridge/sessions/{Uri.EscapeDataString(sessionId)}/permissions"), responseBody, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + public async Task HeartbeatAsync(BridgeConfig config, string workId, string sessionToken, CancellationToken ct = default) + { + using var response = await SendJsonAsync(HttpMethod.Post, Root(config, $"bridge/work/{Uri.EscapeDataString(workId)}/heartbeat"), new { sessionToken }, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + public async Task StopWorkAsync(BridgeConfig config, string workId, CancellationToken ct = default) + { + using var response = await _httpClient.DeleteAsync(Root(config, $"bridge/work/{Uri.EscapeDataString(workId)}"), ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + public async Task DeregisterEnvironmentAsync(BridgeConfig config, string environmentId, CancellationToken ct = default) + { + using var response = await _httpClient.DeleteAsync(Root(config, $"bridge/environments/{Uri.EscapeDataString(environmentId)}"), ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + private async Task SendJsonAsync(HttpMethod method, Uri uri, object payload, CancellationToken ct) + { + var request = new HttpRequestMessage(method, uri) + { + Content = JsonContent.Create(payload) + }; + + return await _httpClient.SendAsync(request, ct).ConfigureAwait(false); + } + + private static Uri Root(BridgeConfig config, string relativePath) + { + var baseUri = new Uri(config.BaseUrl.TrimEnd('/') + "/", UriKind.Absolute); + return new Uri(baseUri, relativePath); + } + + private static async Task ReadJsonAsync(HttpResponseMessage response, CancellationToken ct) + { + await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + return (T?)await response.Content.ReadFromJsonAsync(typeof(T), SourceGenerationContext.Default, ct).ConfigureAwait(false); + } +} diff --git a/src/FreeCode.Bridge/BridgeService.cs b/src/FreeCode.Bridge/BridgeService.cs new file mode 100644 index 0000000..0bdf3a3 --- /dev/null +++ b/src/FreeCode.Bridge/BridgeService.cs @@ -0,0 +1,300 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Bridge; + +public sealed class BridgeService : IBridgeService, IAsyncDisposable +{ + private const string SessionTokenKey = "sessionToken"; + private const string PromptKey = "prompt"; + private const string ModelKey = "model"; + private const string AgentTypeKey = "agentType"; + private const string WorkingDirectoryKey = "workingDirectory"; + + private readonly BridgeApiClient _client; + private readonly BridgeConfig _config; + private readonly ConcurrentDictionary _activeSessions = new(StringComparer.OrdinalIgnoreCase); + private readonly SemaphoreSlim _startGate = new(1, 1); + private CancellationTokenSource? _runCts; + private Task? _runLoop; + private BridgeEnvironment? _environment; + private SessionHandle? _session; + private BridgeStatusInfo _status = new(BridgeStatus.Idle); + + public BridgeService() + : this(new BridgeApiClient(), new BridgeConfig(Environment.GetEnvironmentVariable("FREECODE_BRIDGE_URL") ?? "http://127.0.0.1:8787")) + { + } + + public BridgeService(BridgeApiClient client, BridgeConfig config) + { + _client = client; + _config = config; + } + + public BridgeStatus Status => _status.Status; + + public async Task RegisterEnvironmentAsync() + { + var environment = _environment ??= new BridgeEnvironment(Guid.NewGuid().ToString("N"), Environment.MachineName, SpawnMode.SingleSession, Environment.CurrentDirectory); + _status = await _client.RegisterBridgeEnvironmentAsync(_config, environment).ConfigureAwait(false); + return environment; + } + + public async Task StartAsync(CancellationToken ct = default) + { + await _startGate.WaitAsync(ct).ConfigureAwait(false); + try + { + if (_runLoop is not null) + { + return; + } + + _runCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _runLoop = Task.Run(() => RunAsync(_runCts.Token), CancellationToken.None); + } + finally + { + _startGate.Release(); + } + } + + public async Task StopAsync() + { + if (_runCts is null) + { + return; + } + + _runCts.Cancel(); + if (_runLoop is not null) + { + try + { + await _runLoop.ConfigureAwait(false); + } + catch (OperationCanceledException ex) + { + if (!(_runCts?.IsCancellationRequested ?? false)) + { + Console.Error.WriteLine($"Bridge run loop error: {ex.Message}"); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Bridge run loop error: {ex.Message}"); + } + } + + _runLoop = null; + _runCts.Dispose(); + _runCts = null; + } + + public async Task PollForWorkAsync(CancellationToken ct) + { + var environment = _environment; + if (environment is null) + { + return null; + } + + return await _client.PollForWorkAsync(_config, environment.Id, ct).ConfigureAwait(false); + } + + public async Task SpawnSessionAsync(SessionSpawnOptions options) + { + _session = await _client.SpawnSessionAsync(_config, options).ConfigureAwait(false); + _activeSessions[_session.SessionId] = _session; + _status = new BridgeStatusInfo(BridgeStatus.Attached); + return _session; + } + + public async Task AcknowledgeWorkAsync(string workId, string sessionToken) + { + await _client.AcknowledgeWorkAsync(_config, workId, sessionToken).ConfigureAwait(false); + } + + public async Task SendPermissionResponseAsync(string sessionId, PermissionResponse response) + { + await _client.SendPermissionResponseAsync(_config, sessionId, response).ConfigureAwait(false); + } + + public async Task HeartbeatAsync(string workId, string sessionToken) + { + await _client.HeartbeatAsync(_config, workId, sessionToken).ConfigureAwait(false); + } + + public async Task StopWorkAsync(string workId) + { + await _client.StopWorkAsync(_config, workId).ConfigureAwait(false); + } + + public async Task DeregisterEnvironmentAsync() + { + if (_environment is null) + { + return; + } + + await _client.DeregisterEnvironmentAsync(_config, _environment.Id).ConfigureAwait(false); + _status = new BridgeStatusInfo(BridgeStatus.Idle); + } + + public async Task RunAsync(CancellationToken ct = default) + { + await RegisterEnvironmentAsync().ConfigureAwait(false); + while (!ct.IsCancellationRequested) + { + var work = await PollForWorkAsync(ct).ConfigureAwait(false); + if (work is null) + { + await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false); + continue; + } + + var secret = DecodeWorkSecret(work); + await AcknowledgeWorkAsync(work.Id, secret.SessionToken).ConfigureAwait(false); + + var sessionDirectory = PrepareSessionDirectory(work, secret.WorkingDirectory); + var environment = GetSessionEnvironment(sessionDirectory); + var session = await SpawnSessionAsync(new SessionSpawnOptions(environment, secret.Prompt, secret.Model, secret.AgentType)).ConfigureAwait(false); + + _activeSessions[work.Id] = session; + _ = MonitorSessionAsync(work, session, secret.SessionToken, ct); + } + } + + private async Task MonitorSessionAsync(WorkItem work, SessionHandle session, string sessionToken, CancellationToken ct) + { + try + { + _session = session; + await HeartbeatAsync(work.Id, sessionToken).ConfigureAwait(false); + await _client.SendSessionUpdateAsync(_config, session.SessionId, new { workId = work.Id, status = "running" }, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + await SafeSendSessionUpdateAsync(session.SessionId, new { workId = work.Id, status = "cancelled" }).ConfigureAwait(false); + } + catch (Exception ex) + { + await SafeSendSessionUpdateAsync(session.SessionId, new { workId = work.Id, status = "failed", error = ex.Message }).ConfigureAwait(false); + } + finally + { + _activeSessions.TryRemove(work.Id, out _); + + if (_session?.SessionId == session.SessionId) + { + _session = null; + } + + await SafeSendSessionUpdateAsync(session.SessionId, new { workId = work.Id, status = "completed" }).ConfigureAwait(false); + _status = _activeSessions.IsEmpty ? new BridgeStatusInfo(BridgeStatus.Registered) : new BridgeStatusInfo(BridgeStatus.Attached); + } + } + + public async Task PollForCommandsAsync(CancellationToken ct = default) + { + while (!ct.IsCancellationRequested) + { + var work = await PollForWorkAsync(ct).ConfigureAwait(false); + if (work is null) + { + await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false); + continue; + } + + if (!string.IsNullOrWhiteSpace(work.SessionToken)) + { + await AcknowledgeWorkAsync(work.Id, work.SessionToken).ConfigureAwait(false); + } + } + } + + public async Task SendResponseAsync(object response) + { + if (_session is not null) + { + await _client.SendSessionUpdateAsync(_config, _session.SessionId, response).ConfigureAwait(false); + } + } + + public async Task BroadcastEventAsync(object evt) + { + await _client.PostEventAsync(_config, evt).ConfigureAwait(false); + } + + private BridgeEnvironment GetSessionEnvironment(string sessionDirectory) + { + var environment = _environment ?? new BridgeEnvironment(Guid.NewGuid().ToString("N"), Environment.MachineName, SpawnMode.SingleSession, Environment.CurrentDirectory); + return environment with { WorkingDirectory = sessionDirectory }; + } + + private string PrepareSessionDirectory(WorkItem work, string? workingDirectory) + { + var rootDirectory = string.IsNullOrWhiteSpace(workingDirectory) + ? _environment?.WorkingDirectory ?? Environment.CurrentDirectory + : workingDirectory; + + var safeWorkId = string.Concat(work.Id.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '_' : ch)); + var sessionDirectory = Path.Combine(rootDirectory, ".freecode-bridge", safeWorkId); + Directory.CreateDirectory(sessionDirectory); + return sessionDirectory; + } + + private WorkSecret DecodeWorkSecret(WorkItem work) + { + var sessionToken = TryGetString(work.Payload, SessionTokenKey) ?? work.SessionToken; + if (string.IsNullOrWhiteSpace(sessionToken)) + { + throw new InvalidOperationException($"Work item '{work.Id}' does not include a session token."); + } + + return new WorkSecret( + sessionToken, + TryGetString(work.Payload, PromptKey), + TryGetString(work.Payload, ModelKey), + TryGetString(work.Payload, AgentTypeKey), + TryGetString(work.Payload, WorkingDirectoryKey)); + } + + private async Task SafeSendSessionUpdateAsync(string sessionId, object payload) + { + try + { + await _client.SendSessionUpdateAsync(_config, sessionId, payload).ConfigureAwait(false); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Bridge session update failed: {ex.Message}"); + } + } + + private static string? TryGetString(JsonElement payload, string propertyName) + { + return payload.ValueKind == JsonValueKind.Object + && payload.TryGetProperty(propertyName, out var value) + && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } + + public async ValueTask DisposeAsync() + { + await StopAsync().ConfigureAwait(false); + await DeregisterEnvironmentAsync().ConfigureAwait(false); + } + + private sealed record WorkSecret( + string SessionToken, + string? Prompt, + string? Model, + string? AgentType, + string? WorkingDirectory); +} diff --git a/src/FreeCode.Bridge/BridgeTypes.cs b/src/FreeCode.Bridge/BridgeTypes.cs new file mode 100644 index 0000000..7b9177d --- /dev/null +++ b/src/FreeCode.Bridge/BridgeTypes.cs @@ -0,0 +1,8 @@ +using FreeCode.Core.Enums; +using FreeCode.Core.Models; + +namespace FreeCode.Bridge; + +public sealed record BridgeConfig(string BaseUrl, string? ApiKey = null); + +public sealed record BridgeStatusInfo(BridgeStatus Status); diff --git a/src/FreeCode.Bridge/FreeCode.Bridge.csproj b/src/FreeCode.Bridge/FreeCode.Bridge.csproj new file mode 100644 index 0000000..273314a --- /dev/null +++ b/src/FreeCode.Bridge/FreeCode.Bridge.csproj @@ -0,0 +1,10 @@ + + + FreeCode.Bridge + + + + + + + diff --git a/src/FreeCode.Bridge/ServiceCollectionExtensions.cs b/src/FreeCode.Bridge/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..342e061 --- /dev/null +++ b/src/FreeCode.Bridge/ServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode.Bridge; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddFreeCodeBridge(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + return services; + } +} diff --git a/src/FreeCode.Bridge/SourceGenerationContext.cs b/src/FreeCode.Bridge/SourceGenerationContext.cs new file mode 100644 index 0000000..6f4a5a1 --- /dev/null +++ b/src/FreeCode.Bridge/SourceGenerationContext.cs @@ -0,0 +1,17 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using FreeCode.Core.Models; + +namespace FreeCode.Bridge; + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(BridgeStatusInfo))] +[JsonSerializable(typeof(BridgeEnvironment))] +[JsonSerializable(typeof(WorkItem))] +[JsonSerializable(typeof(SessionHandle))] +[JsonSerializable(typeof(SessionSpawnOptions))] +[JsonSerializable(typeof(PermissionResponse))] +[JsonSerializable(typeof(JsonElement))] +internal sealed partial class SourceGenerationContext : JsonSerializerContext +{ +} diff --git a/src/FreeCode.Commands/AddDirCommand.cs b/src/FreeCode.Commands/AddDirCommand.cs new file mode 100644 index 0000000..dcc74e8 --- /dev/null +++ b/src/FreeCode.Commands/AddDirCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class AddDirCommand : CommandBase +{ + public override string Name => "add-dir"; + public override string Description => "Add a working directory."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/AdvisorCommand.cs b/src/FreeCode.Commands/AdvisorCommand.cs new file mode 100644 index 0000000..820aae9 --- /dev/null +++ b/src/FreeCode.Commands/AdvisorCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class AdvisorCommand : CommandBase +{ + public override string Name => "advisor"; + public override string Description => "Get advice."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/AgentsCommand.cs b/src/FreeCode.Commands/AgentsCommand.cs new file mode 100644 index 0000000..c41d78b --- /dev/null +++ b/src/FreeCode.Commands/AgentsCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class AgentsCommand : CommandBase +{ + public override string Name => "agents"; + public override string Description => "List available agents."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/AntTraceCommand.cs b/src/FreeCode.Commands/AntTraceCommand.cs new file mode 100644 index 0000000..49c2209 --- /dev/null +++ b/src/FreeCode.Commands/AntTraceCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class AntTraceCommand : CommandBase +{ + public override string Name => "ant-trace"; + public override string Description => "Trace operations."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/AssistantCommand.cs b/src/FreeCode.Commands/AssistantCommand.cs new file mode 100644 index 0000000..22e7d13 --- /dev/null +++ b/src/FreeCode.Commands/AssistantCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class AssistantCommand : CommandBase +{ + public override string Name => "assistant"; + public override string Description => "Manage assistant profiles."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/BranchCommand.cs b/src/FreeCode.Commands/BranchCommand.cs new file mode 100644 index 0000000..963be7c --- /dev/null +++ b/src/FreeCode.Commands/BranchCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class BranchCommand : CommandBase +{ + public override string Name => "branch"; + public override string Description => "Manage git branches."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/BreakCacheCommand.cs b/src/FreeCode.Commands/BreakCacheCommand.cs new file mode 100644 index 0000000..09e48d3 --- /dev/null +++ b/src/FreeCode.Commands/BreakCacheCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class BreakCacheCommand : CommandBase +{ + public override string Name => "break-cache"; + public override string Description => "Break the prompt cache."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/BridgeKickCommand.cs b/src/FreeCode.Commands/BridgeKickCommand.cs new file mode 100644 index 0000000..ca16194 --- /dev/null +++ b/src/FreeCode.Commands/BridgeKickCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class BridgeKickCommand : CommandBase +{ + public override string Name => "bridge-kick"; + public override string Description => "Kick the bridge session."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/BriefCommand.cs b/src/FreeCode.Commands/BriefCommand.cs new file mode 100644 index 0000000..88e2bdc --- /dev/null +++ b/src/FreeCode.Commands/BriefCommand.cs @@ -0,0 +1,57 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FreeCode.Core.Enums; +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class BriefCommand : CommandBase +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public override string Name => "brief"; + public override string Description => "Toggle brief output mode."; + public override CommandCategory Category => CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + { + var config = LoadConfig(); + var currentBrief = config["briefMode"]?.GetValue() ?? false; + var newBrief = !currentBrief; + + config["briefMode"] = newBrief; + SaveConfig(config); + + return Task.FromResult(new CommandResult(true, $"Brief mode: {(newBrief ? "ON" : "OFF")}")); + } + + private static JsonObject LoadConfig() + { + var path = GetConfigPath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + + if (!File.Exists(path)) + { + return []; + } + + try + { + return JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? []; + } + catch (JsonException) + { + return []; + } + } + + private static void SaveConfig(JsonObject config) + { + var path = GetConfigPath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, config.ToJsonString(JsonOptions)); + } + + private static string GetConfigPath() + => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".free-code", "config.json"); +} diff --git a/src/FreeCode.Commands/BtwCommand.cs b/src/FreeCode.Commands/BtwCommand.cs new file mode 100644 index 0000000..16daf86 --- /dev/null +++ b/src/FreeCode.Commands/BtwCommand.cs @@ -0,0 +1,15 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class BtwCommand : CommandBase +{ + public override string Name => "btw"; + public override string Description => "Buddy companion interaction."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override bool IsEnabled() => true; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/BughunterCommand.cs b/src/FreeCode.Commands/BughunterCommand.cs new file mode 100644 index 0000000..15d99a3 --- /dev/null +++ b/src/FreeCode.Commands/BughunterCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class BughunterCommand : CommandBase +{ + public override string Name => "bughunter"; + public override string Description => "Bug hunting mode."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/ChromeCommand.cs b/src/FreeCode.Commands/ChromeCommand.cs new file mode 100644 index 0000000..2826ed5 --- /dev/null +++ b/src/FreeCode.Commands/ChromeCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class ChromeCommand : CommandBase +{ + public override string Name => "chrome"; + public override string Description => "Chrome extension integration."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/ClearCommand.cs b/src/FreeCode.Commands/ClearCommand.cs new file mode 100644 index 0000000..a27a132 --- /dev/null +++ b/src/FreeCode.Commands/ClearCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class ClearCommand : CommandBase +{ + public override string Name => "clear"; + public override string Description => "Clear the current terminal view."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => Task.FromResult(new CommandResult(true)); +} diff --git a/src/FreeCode.Commands/ColorCommand.cs b/src/FreeCode.Commands/ColorCommand.cs new file mode 100644 index 0000000..9358752 --- /dev/null +++ b/src/FreeCode.Commands/ColorCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class ColorCommand : CommandBase +{ + public override string Name => "color"; + public override string Description => "Set the color scheme."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/CommandBase.cs b/src/FreeCode.Commands/CommandBase.cs new file mode 100644 index 0000000..19dedc7 --- /dev/null +++ b/src/FreeCode.Commands/CommandBase.cs @@ -0,0 +1,16 @@ +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public abstract class CommandBase : ICommand +{ + public abstract string Name { get; } + public virtual string[]? Aliases => null; + public abstract string Description { get; } + public abstract CommandCategory Category { get; } + public virtual CommandAvailability Availability => CommandAvailability.Always; + public virtual bool IsEnabled() => true; + public abstract Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default); +} diff --git a/src/FreeCode.Commands/CommandExecutionHelper.cs b/src/FreeCode.Commands/CommandExecutionHelper.cs new file mode 100644 index 0000000..3067842 --- /dev/null +++ b/src/FreeCode.Commands/CommandExecutionHelper.cs @@ -0,0 +1,1708 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using FreeCode.State; + +namespace FreeCode.Commands; + +internal static class CommandExecutionHelper +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public static async Task ExecuteAsync(string commandName, CommandContext context, string? args, CancellationToken ct = default) + { + return commandName.ToLowerInvariant() switch + { + "session" => await ExecuteSessionAsync(context, args, ct).ConfigureAwait(false), + "resume" => await ExecuteResumeAsync(context, args, ct).ConfigureAwait(false), + "rename" => await ExecuteRenameAsync(context, args, ct).ConfigureAwait(false), + "export" => await ExecuteExportAsync(context, args, ct).ConfigureAwait(false), + "config" => await ExecuteConfigAsync(context, args, ct).ConfigureAwait(false), + "theme" => await ExecuteThemeAsync(context, args, ct).ConfigureAwait(false), + "color" => await ExecuteColorAsync(context, args, ct).ConfigureAwait(false), + "output-style" => await ExecuteOutputStyleAsync(context, args, ct).ConfigureAwait(false), + "keybindings" => await ExecuteKeybindingsAsync(context, args, ct).ConfigureAwait(false), + "status" => await ExecuteStatusAsync(context, ct).ConfigureAwait(false), + "cost" => await ExecuteCostAsync(context, ct).ConfigureAwait(false), + "stats" => await ExecuteStatsAsync(context, ct).ConfigureAwait(false), + "extra-usage" => await ExecuteExtraUsageAsync(context, ct).ConfigureAwait(false), + "diff" => await ExecuteDiffAsync(context, ct).ConfigureAwait(false), + "copy" => await ExecuteCopyAsync(context, ct).ConfigureAwait(false), + "doctor" => await ExecuteDoctorAsync(context, ct).ConfigureAwait(false), + "memory" => await ExecuteMemoryAsync(context, args, ct).ConfigureAwait(false), + "agents" => await ExecuteAgentsAsync(context, ct).ConfigureAwait(false), + "commit" => await ExecuteCommitPromptAsync(context, args, ct).ConfigureAwait(false), + "commit-push-pr" => await ExecuteCommitPushPrPromptAsync(context, args, ct).ConfigureAwait(false), + "branch" => await ExecuteBranchAsync(context, args, ct).ConfigureAwait(false), + "mcp" => await ExecuteMcpAsync(context, args, ct).ConfigureAwait(false), + "hooks" => await ExecuteHooksAsync(context, args, ct).ConfigureAwait(false), + "files" => await ExecuteFilesAsync(context, args, ct).ConfigureAwait(false), + "skills" => await ExecuteSkillsAsync(context, args, ct).ConfigureAwait(false), + "plugin" => await ExecutePluginAsync(context, args, ct).ConfigureAwait(false), + "version" => await ExecuteVersionAsync(context).ConfigureAwait(false), + "add-dir" => await ExecuteAddDirAsync(context, args, ct).ConfigureAwait(false), + "vim" => await ExecuteVimAsync(context, args, ct).ConfigureAwait(false), + "fast" => await ExecuteFastAsync(context, args, ct).ConfigureAwait(false), + "upgrade" => await ExecuteUpgradeAsync(context, ct).ConfigureAwait(false), + "feedback" => await ExecuteFeedbackAsync(context, args, ct).ConfigureAwait(false), + "tag" => await ExecuteTagAsync(context, args, ct).ConfigureAwait(false), + "compact" => await ExecuteCompactAsync(context, ct).ConfigureAwait(false), + "context" => await ExecuteContextAsync(context, ct).ConfigureAwait(false), + "permissions" => await ExecutePermissionsAsync(context, args, ct).ConfigureAwait(false), + "effort" => await ExecuteEffortAsync(context, args, ct).ConfigureAwait(false), + "tasks" => await ExecuteTasksAsync(context, args, ct).ConfigureAwait(false), + "teleport" => await ExecuteTeleportAsync(context, args, ct).ConfigureAwait(false), + "rewind" => await ExecuteRewindAsync(context, args, ct).ConfigureAwait(false), + "terminal-setup" => await ExecuteTerminalSetupAsync(context, ct).ConfigureAwait(false), + "desktop" => await ExecuteDesktopAsync(context, args, ct).ConfigureAwait(false), + "mobile" => await ExecuteMobileAsync(context, args, ct).ConfigureAwait(false), + "chrome" => await ExecuteChromeAsync(context, args, ct).ConfigureAwait(false), + "privacy-settings" => await ExecutePrivacySettingsAsync(context, args, ct).ConfigureAwait(false), + "assistant" => await ExecuteAssistantAsync(context, args, ct).ConfigureAwait(false), + "sandbox-toggle" => await ExecuteSandboxToggleAsync(context, args, ct).ConfigureAwait(false), + "remote-env" => await ExecuteRemoteEnvAsync(context, args, ct).ConfigureAwait(false), + "rate-limit-options" => await ExecuteRateLimitOptionsAsync(context, args, ct).ConfigureAwait(false), + "passes" => await ExecutePassesAsync(context, args, ct).ConfigureAwait(false), + "break-cache" => await ExecuteBreakCacheAsync(context, ct).ConfigureAwait(false), + "summary" => await ExecuteSummaryPromptAsync(context, args, ct).ConfigureAwait(false), + "share" => await ExecuteShareAsync(context, args, ct).ConfigureAwait(false), + "release-notes" => await ExecuteReleaseNotesAsync(context, ct).ConfigureAwait(false), + "status-line" => await ExecuteStatusLineAsync(context, args, ct).ConfigureAwait(false), + "pr-comments" => await ExecutePrCommentsAsync(context, args, ct).ConfigureAwait(false), + "review" => await ExecuteReviewPromptAsync(context, args, ct).ConfigureAwait(false), + "ultrareview" => await ExecuteUltraReviewPromptAsync(context, args, ct).ConfigureAwait(false), + "install-github-app" => await ExecuteInstallAppAsync("GitHub", "https://github.com/apps/claude-code", ct).ConfigureAwait(false), + "install-slack-app" => await ExecuteInstallAppAsync("Slack", "https://slack.com/apps", ct).ConfigureAwait(false), + "btw" => await ExecuteBtwAsync(context, args, ct).ConfigureAwait(false), + "ultraplan" => await ExecuteUltraPlanPromptAsync(context, args, ct).ConfigureAwait(false), + "thinkback" => await ExecuteThinkbackAsync(context, ct).ConfigureAwait(false), + "thinkback-play" => await ExecuteThinkbackPlayAsync(context, ct).ConfigureAwait(false), + "heapdump" => await ExecuteHeapdumpAsync(context, ct).ConfigureAwait(false), + "mock-limits" => await ExecuteMockLimitsAsync(context, ct).ConfigureAwait(false), + "bridge-kick" => await ExecuteBridgeKickAsync(context, ct).ConfigureAwait(false), + "ant-trace" => await ExecuteAntTraceAsync(context, args, ct).ConfigureAwait(false), + "perf-issue" => await ExecutePerfIssueAsync(context, args, ct).ConfigureAwait(false), + "debug-tool-call" => await ExecuteDebugToolCallAsync(context, args, ct).ConfigureAwait(false), + "onboarding" => await ExecuteOnboardingAsync(context, ct).ConfigureAwait(false), + "bughunter" => await ExecuteBughunterAsync(context, args, ct).ConfigureAwait(false), + "good-claude" => await ExecuteGoodClaudeAsync(context, args, ct).ConfigureAwait(false), + "issue" => await ExecuteIssueAsync(context, args, ct).ConfigureAwait(false), + "advisor" => await ExecuteAdvisorAsync(context, args, ct).ConfigureAwait(false), + "insights" => await ExecuteInsightsAsync(context, ct).ConfigureAwait(false), + "reset-limits" => await ExecuteResetLimitsAsync(context, ct).ConfigureAwait(false), + "ctx-viz" => await ExecuteCtxVizAsync(context, ct).ConfigureAwait(false), + "oauth-refresh" => await ExecuteOauthRefreshAsync(context, ct).ConfigureAwait(false), + _ => new CommandResult(false, $"Unknown command: {commandName}") + }; + } + + private static async Task ExecuteSessionAsync(CommandContext context, string? args, CancellationToken ct) + { + var tokens = Tokenize(args); + var sessionsDir = GetSessionsDir(); + Directory.CreateDirectory(sessionsDir); + + if (tokens.Count == 0 || string.Equals(tokens[0], "list", StringComparison.OrdinalIgnoreCase)) + { + var files = Directory.EnumerateFiles(sessionsDir, "*.json").OrderByDescending(File.GetLastWriteTimeUtc).ToArray(); + if (files.Length == 0) + { + return new CommandResult(true, "No sessions found."); + } + + var lines = new List { "Sessions:" }; + foreach (var file in files) + { + lines.Add($"- {Path.GetFileNameWithoutExtension(file)} ({GetMessageCount(file)} messages)"); + } + + return new CommandResult(true, string.Join(Environment.NewLine, lines)); + } + + if (string.Equals(tokens[0], "new", StringComparison.OrdinalIgnoreCase)) + { + var id = tokens.Count > 1 ? tokens[1] : Guid.NewGuid().ToString("N"); + var path = Path.Combine(sessionsDir, EnsureSessionFileName(id)); + await File.WriteAllTextAsync(path, "[]", ct).ConfigureAwait(false); + UpdateFileHistory(context, Array.Empty()); + return new CommandResult(true, $"Created session {id}."); + } + + if (string.Equals(tokens[0], "delete", StringComparison.OrdinalIgnoreCase) && tokens.Count > 1) + { + var path = ResolveSessionPath(tokens[1]); + if (path is null) + { + return new CommandResult(false, $"Session {tokens[1]} not found."); + } + + File.Delete(path); + return new CommandResult(true, $"Deleted session {tokens[1]}."); + } + + return new CommandResult(false, "Usage: /session [list|new [id]|delete ]"); + } + + private static async Task ExecuteResumeAsync(CommandContext context, string? args, CancellationToken ct) + { + var sessionPath = ResolveSessionPath(string.IsNullOrWhiteSpace(args) ? GetLatestSessionId() : args.Trim()); + if (sessionPath is null) + { + return new CommandResult(false, "No matching session found."); + } + + var messages = await ReadSessionAsync(sessionPath, ct).ConfigureAwait(false); + UpdateFileHistory(context, messages); + return new CommandResult(true, $"Resumed session {Path.GetFileNameWithoutExtension(sessionPath)} with {messages.Count} messages."); + } + + private static Task ExecuteRenameAsync(CommandContext context, string? args, CancellationToken ct) + { + var tokens = Tokenize(args); + if (tokens.Count == 0) + { + return Task.FromResult(new CommandResult(false, "Usage: /rename [old-id]")); + } + + var newId = tokens[0]; + var oldId = tokens.Count > 1 ? tokens[1] : GetLatestSessionId(); + var source = ResolveSessionPath(oldId); + if (source is null) + { + return Task.FromResult(new CommandResult(false, $"Session {oldId} not found.")); + } + + var target = Path.Combine(GetSessionsDir(), EnsureSessionFileName(newId)); + File.Move(source, target, true); + return Task.FromResult(new CommandResult(true, $"Renamed session {oldId} to {newId}.")); + } + + private static async Task ExecuteExportAsync(CommandContext context, string? args, CancellationToken ct) + { + var messages = GetConversation(context); + var outputPath = ResolveOutputPath(args, "markdown"); + var markdown = BuildConversationMarkdown(messages); + await File.WriteAllTextAsync(outputPath, markdown, ct).ConfigureAwait(false); + return new CommandResult(true, $"Exported {messages.Count} messages to {outputPath}."); + } + + private static Task ExecuteConfigAsync(CommandContext context, string? args, CancellationToken ct) + { + var tokens = Tokenize(args); + var config = LoadConfig(); + + if (tokens.Count == 0 || string.Equals(tokens[0], "list", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(new CommandResult(true, config.ToJsonString(JsonOptions))); + } + + if (string.Equals(tokens[0], "get", StringComparison.OrdinalIgnoreCase) && tokens.Count > 1) + { + var value = config[tokens[1]]?.ToJsonString(JsonOptions) ?? "null"; + return Task.FromResult(new CommandResult(true, value)); + } + + if (string.Equals(tokens[0], "set", StringComparison.OrdinalIgnoreCase) && tokens.Count > 2) + { + SetConfigValue(config, tokens[1], tokens[2]); + SaveConfig(config); + return Task.FromResult(new CommandResult(true, $"Set {tokens[1]}.") ); + } + + if (string.Equals(tokens[0], "unset", StringComparison.OrdinalIgnoreCase) && tokens.Count > 1) + { + config.Remove(tokens[1]); + SaveConfig(config); + return Task.FromResult(new CommandResult(true, $"Removed {tokens[1]}.") ); + } + + return Task.FromResult(new CommandResult(false, "Usage: /config [list|get |set |unset ]")); + } + + private static Task ExecuteThemeAsync(CommandContext context, string? args, CancellationToken ct) + => ExecuteNamedSettingAsync("theme", new[] { "default", "dark", "light", "system" }, context, args); + + private static Task ExecuteColorAsync(CommandContext context, string? args, CancellationToken ct) + => ExecuteNamedSettingAsync("colorScheme", new[] { "default", "warm", "cool", "mono" }, context, args); + + private static Task ExecuteOutputStyleAsync(CommandContext context, string? args, CancellationToken ct) + => ExecuteNamedSettingAsync("outputStyle", new[] { "text", "json", "markdown" }, context, args); + + private static Task ExecuteKeybindingsAsync(CommandContext context, string? args, CancellationToken ct) + { + var config = LoadConfig(); + var bindings = GetObject(config, "keybindings"); + var tokens = Tokenize(args); + + if (tokens.Count == 0 || string.Equals(tokens[0], "list", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(new CommandResult(true, bindings.ToJsonString(JsonOptions))); + } + + if (string.Equals(tokens[0], "set", StringComparison.OrdinalIgnoreCase) && tokens.Count > 2) + { + bindings[tokens[1]] = tokens[2]; + config["keybindings"] = bindings; + SaveConfig(config); + return Task.FromResult(new CommandResult(true, $"Set keybinding {tokens[1]} = {tokens[2]}.")); + } + + if (string.Equals(tokens[0], "unset", StringComparison.OrdinalIgnoreCase) && tokens.Count > 1) + { + bindings.Remove(tokens[1]); + config["keybindings"] = bindings; + SaveConfig(config); + return Task.FromResult(new CommandResult(true, $"Removed keybinding {tokens[1]}.")); + } + + return Task.FromResult(new CommandResult(false, "Usage: /keybindings [list|set |unset ]")); + } + + private static Task ExecuteStatusAsync(CommandContext context, CancellationToken ct) + { + var lines = BuildStatusLines(context, includeUsage: true); + return Task.FromResult(new CommandResult(true, string.Join(Environment.NewLine, lines))); + } + + private static Task ExecuteCostAsync(CommandContext context, CancellationToken ct) + { + var usage = GetQueryEngine(context)?.GetCurrentUsage() ?? new TokenUsage(0, 0, 0, 0); + var estimated = usage.InputTokens * 0.000003m + usage.OutputTokens * 0.000015m + usage.CacheCreationTokens * 0.000005m + usage.CacheReadTokens * 0.000001m; + return Task.FromResult(new CommandResult(true, $"Estimated cost: ${estimated:F4}\nInput: {usage.InputTokens}\nOutput: {usage.OutputTokens}\nCache create: {usage.CacheCreationTokens}\nCache read: {usage.CacheReadTokens}")); + } + + private static Task ExecuteStatsAsync(CommandContext context, CancellationToken ct) + { + var state = GetAppState(context); + var tasks = state?.Tasks.Count ?? 0; + var files = state?.FileHistory.Count ?? 0; + var memory = LoadMemoryEntries().Count; + var flags = GetFeatureFlagService(context)?.GetEnabledFlags().Count ?? 0; + return Task.FromResult(new CommandResult(true, $"Tasks: {tasks}\nFile history: {files}\nMemory entries: {memory}\nEnabled flags: {flags}")); + } + + private static Task ExecuteExtraUsageAsync(CommandContext context, CancellationToken ct) + { + var usage = GetQueryEngine(context)?.GetCurrentUsage() ?? new TokenUsage(0, 0, 0, 0); + return Task.FromResult(new CommandResult(true, $"Detailed usage\nInput tokens: {usage.InputTokens}\nOutput tokens: {usage.OutputTokens}\nCache creation tokens: {usage.CacheCreationTokens}\nCache read tokens: {usage.CacheReadTokens}")); + } + + private static async Task ExecuteDiffAsync(CommandContext context, CancellationToken ct) + { + var result = await RunProcessAsync("git", "diff", context.WorkingDirectory, null, ct).ConfigureAwait(false); + return result.ExitCode == 0 ? new CommandResult(true, result.StdOut) : new CommandResult(false, result.StdErr.Length > 0 ? result.StdErr : result.StdOut); + } + + private static Task ExecuteCopyAsync(CommandContext context, CancellationToken ct) + { + var text = GetLastAssistantText(context); + if (string.IsNullOrWhiteSpace(text)) + { + return Task.FromResult(new CommandResult(false, "No text available to copy.")); + } + + return CopyToClipboardAsync(text, ct); + } + + private static async Task ExecuteDoctorAsync(CommandContext context, CancellationToken ct) + { + var lines = new List(); + lines.Add($"API key: {(string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")) ? "missing" : "present")}"); + lines.Add($"Config file: {(File.Exists(GetConfigPath()) ? "present" : "missing")}"); + + var git = await RunProcessAsync("git", "rev-parse --is-inside-work-tree", context.WorkingDirectory, null, ct).ConfigureAwait(false); + lines.Add($"Git repo: {(git.ExitCode == 0 ? "yes" : "no")}"); + + using var client = new HttpClient(); + try + { + using var response = await client.GetAsync("https://example.com", ct).ConfigureAwait(false); + lines.Add($"Network: {(response.IsSuccessStatusCode ? "ok" : "limited")}"); + } + catch (Exception) + { + lines.Add("Network: unavailable"); + } + + return new CommandResult(true, string.Join(Environment.NewLine, lines)); + } + + private static async Task ExecuteMemoryAsync(CommandContext context, string? args, CancellationToken ct) + { + var tokens = Tokenize(args); + var path = GetMemoryPath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + + if (tokens.Count == 0 || string.Equals(tokens[0], "list", StringComparison.OrdinalIgnoreCase)) + { + var entries = LoadMemoryEntries(); + return new CommandResult(true, entries.Count == 0 ? "No memory entries." : string.Join(Environment.NewLine, entries.Select((entry, index) => $"{index + 1}. {entry}"))); + } + + if (string.Equals(tokens[0], "add", StringComparison.OrdinalIgnoreCase) && tokens.Count > 1) + { + var entries = LoadMemoryEntries(); + entries.Add(string.Join(' ', tokens.Skip(1))); + SaveMemoryEntries(entries); + return new CommandResult(true, "Memory entry added."); + } + + if (string.Equals(tokens[0], "clear", StringComparison.OrdinalIgnoreCase)) + { + SaveMemoryEntries([]); + return new CommandResult(true, "Memory cleared."); + } + + return new CommandResult(false, "Usage: /memory [list|add |clear]"); + } + + private static Task ExecuteAgentsAsync(CommandContext context, CancellationToken ct) + { + var lines = new[] + { + "Available agents:", + "- explore: search and inspect code", + "- librarian: fetch docs and references", + "- oracle: verification and analysis", + "- hephaestus: build and repair work" + }; + return Task.FromResult(new CommandResult(true, string.Join(Environment.NewLine, lines))); + } + + private static async Task ExecuteCommitPromptAsync(CommandContext context, string? args, CancellationToken ct) + { + var staged = await RunProcessAsync("git", "diff --staged --stat", context.WorkingDirectory, null, ct).ConfigureAwait(false); + var files = await RunProcessAsync("git", "diff --staged --name-only", context.WorkingDirectory, null, ct).ConfigureAwait(false); + var prompt = $"Write a concise git commit message for these staged changes.\n\nFiles:\n{files.StdOut.Trim()}\n\nSummary:\n{staged.StdOut.Trim()}"; + return new CommandResult(true, prompt); + } + + private static async Task ExecuteCommitPushPrPromptAsync(CommandContext context, string? args, CancellationToken ct) + { + var branch = await RunProcessAsync("git", "branch --show-current", context.WorkingDirectory, null, ct).ConfigureAwait(false); + var prompt = $"Commit the staged changes, push the current branch ({branch.StdOut.Trim()}), and open a pull request with a clear title and summary."; + return new CommandResult(true, prompt); + } + + private static async Task ExecuteBranchAsync(CommandContext context, string? args, CancellationToken ct) + { + var tokens = Tokenize(args); + if (tokens.Count == 0 || string.Equals(tokens[0], "list", StringComparison.OrdinalIgnoreCase)) + { + var result = await RunProcessAsync("git", "branch", context.WorkingDirectory, null, ct).ConfigureAwait(false); + return new CommandResult(result.ExitCode == 0, result.StdOut); + } + + if (string.Equals(tokens[0], "create", StringComparison.OrdinalIgnoreCase) && tokens.Count > 1) + { + var result = await RunProcessAsync("git", $"checkout -b {EscapeArg(tokens[1])}", context.WorkingDirectory, null, ct).ConfigureAwait(false); + return new CommandResult(result.ExitCode == 0, result.ExitCode == 0 ? $"Created and switched to {tokens[1]}." : result.StdErr); + } + + if (string.Equals(tokens[0], "switch", StringComparison.OrdinalIgnoreCase) && tokens.Count > 1) + { + var result = await RunProcessAsync("git", $"checkout {EscapeArg(tokens[1])}", context.WorkingDirectory, null, ct).ConfigureAwait(false); + return new CommandResult(result.ExitCode == 0, result.ExitCode == 0 ? $"Switched to {tokens[1]}." : result.StdErr); + } + + return new CommandResult(false, "Usage: /branch [list|create |switch ]"); + } + + private static async Task ExecuteMcpAsync(CommandContext context, string? args, CancellationToken ct) + { + var tokens = Tokenize(args); + var config = LoadConfig(); + var servers = GetObject(config, "mcpServers"); + + if (tokens.Count == 0 || string.Equals(tokens[0], "list", StringComparison.OrdinalIgnoreCase)) + { + if (servers.Count == 0) + { + var manager = GetMcpClientManager(context); + var connections = manager?.GetConnections() ?? []; + return new CommandResult(true, connections.Count == 0 ? "No MCP servers configured." : string.Join(Environment.NewLine, connections.Select(connection => $"- {connection.Name} ({connection.ConnectionType})"))); + } + + return new CommandResult( + true, + string.Join( + Environment.NewLine, + servers.Select((server, index) => + { + var command = server.Value is JsonObject configObject + ? configObject["command"]?.GetValue() + : null; + return $"{index + 1}. {server.Key} ({(string.IsNullOrWhiteSpace(command) ? "configured" : command)})"; + }))); + } + + if (string.Equals(tokens[0], "add", StringComparison.OrdinalIgnoreCase) && tokens.Count > 1) + { + servers[tokens[1]] = new JsonObject + { + ["command"] = tokens[1], + ["args"] = new JsonArray(), + ["scope"] = "User" + }; + config["mcpServers"] = servers; + SaveConfig(config); + await (GetMcpClientManager(context)?.ReloadAsync() ?? Task.CompletedTask).ConfigureAwait(false); + return new CommandResult(true, $"Added MCP server {tokens[1]}." ); + } + + if (string.Equals(tokens[0], "remove", StringComparison.OrdinalIgnoreCase) && tokens.Count > 1) + { + servers.Remove(tokens[1]); + config["mcpServers"] = servers; + SaveConfig(config); + await (GetMcpClientManager(context)?.ReloadAsync() ?? Task.CompletedTask).ConfigureAwait(false); + return new CommandResult(true, $"Removed MCP server {tokens[1]}." ); + } + + return new CommandResult(false, "Usage: /mcp [list|add |remove ]"); + } + + private static async Task ExecuteHooksAsync(CommandContext context, string? args, CancellationToken ct) + { + var tokens = Tokenize(args); + var config = LoadConfig(); + var hooks = GetObject(config, "hooks"); + + if (tokens.Count == 0 || string.Equals(tokens[0], "list", StringComparison.OrdinalIgnoreCase)) + { + return new CommandResult(true, hooks.ToJsonString(JsonOptions)); + } + + if (string.Equals(tokens[0], "set", StringComparison.OrdinalIgnoreCase) && tokens.Count > 2) + { + hooks[tokens[1]] = tokens[2]; + config["hooks"] = hooks; + SaveConfig(config); + return new CommandResult(true, $"Hook {tokens[1]} updated."); + } + + return new CommandResult(false, "Usage: /hooks [list|set ]"); + } + + private static async Task ExecuteFilesAsync(CommandContext context, string? args, CancellationToken ct) + { + var tokens = Tokenize(args); + var config = LoadConfig(); + var files = GetArray(config, "contextFiles"); + + if (tokens.Count == 0 || string.Equals(tokens[0], "list", StringComparison.OrdinalIgnoreCase)) + { + return new CommandResult(true, files.Count == 0 ? "No project context files." : string.Join(Environment.NewLine, files.Select((file, index) => $"{index + 1}. {file}"))); + } + + if (string.Equals(tokens[0], "add", StringComparison.OrdinalIgnoreCase) && tokens.Count > 1) + { + var file = Path.GetFullPath(Path.Combine(context.WorkingDirectory, tokens[1])); + if (!File.Exists(file)) + { + return new CommandResult(false, $"File not found: {file}"); + } + files.Add(file); + config["contextFiles"] = JsonSerializer.SerializeToNode(files); + SaveConfig(config); + return new CommandResult(true, $"Added {file}."); + } + + if (string.Equals(tokens[0], "remove", StringComparison.OrdinalIgnoreCase) && tokens.Count > 1) + { + files.RemoveAll(file => string.Equals(file, tokens[1], StringComparison.OrdinalIgnoreCase) || string.Equals(Path.GetFileName(file), tokens[1], StringComparison.OrdinalIgnoreCase)); + config["contextFiles"] = JsonSerializer.SerializeToNode(files); + SaveConfig(config); + return new CommandResult(true, $"Removed {tokens[1]}."); + } + + return new CommandResult(false, "Usage: /files [list|add |remove ]"); + } + + private static async Task ExecuteSkillsAsync(CommandContext context, string? args, CancellationToken ct) + { + var tokens = Tokenize(args); + var skillsDir = GetNamedDir("skills"); + Directory.CreateDirectory(skillsDir); + + if (tokens.Count == 0 || string.Equals(tokens[0], "list", StringComparison.OrdinalIgnoreCase)) + { + var items = Directory.EnumerateDirectories(skillsDir).Select(Path.GetFileName).OrderBy(name => name).ToArray(); + return new CommandResult(true, items.Length == 0 ? "No skills installed." : string.Join(Environment.NewLine, items.Select((item, index) => $"{index + 1}. {item}"))); + } + + if (string.Equals(tokens[0], "install", StringComparison.OrdinalIgnoreCase) && tokens.Count > 1) + { + var installResult = await InstallDirectoryBackedPackageAsync(tokens[1], skillsDir, context.WorkingDirectory, "Skill", ct).ConfigureAwait(false); + if (!installResult.Success) + { + return installResult; + } + + await (GetSkillLoader(context)?.ReloadAsync() ?? Task.CompletedTask).ConfigureAwait(false); + return installResult; + } + + if (string.Equals(tokens[0], "uninstall", StringComparison.OrdinalIgnoreCase) && tokens.Count > 1) + { + var path = Path.Combine(skillsDir, tokens[1]); + if (Directory.Exists(path)) + { + Directory.Delete(path, true); + } + await (GetSkillLoader(context)?.ReloadAsync() ?? Task.CompletedTask).ConfigureAwait(false); + return new CommandResult(true, $"Uninstalled skill {tokens[1]}." ); + } + + if ((string.Equals(tokens[0], "enable", StringComparison.OrdinalIgnoreCase) || string.Equals(tokens[0], "disable", StringComparison.OrdinalIgnoreCase)) && tokens.Count > 1) + { + var path = Path.Combine(skillsDir, tokens[1], "enabled.flag"); + if (string.Equals(tokens[0], "enable", StringComparison.OrdinalIgnoreCase)) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, "enabled", ct).ConfigureAwait(false); + } + else if (File.Exists(path)) + { + File.Delete(path); + } + await (GetSkillLoader(context)?.ReloadAsync() ?? Task.CompletedTask).ConfigureAwait(false); + return new CommandResult(true, $"{tokens[0]}d skill {tokens[1]}." ); + } + + return new CommandResult(false, "Usage: /skills [list|install |uninstall |enable |disable ]"); + } + + private static async Task ExecutePluginAsync(CommandContext context, string? args, CancellationToken ct) + { + var tokens = Tokenize(args); + var pluginDir = GetNamedDir("plugins"); + Directory.CreateDirectory(pluginDir); + + if (tokens.Count == 0 || string.Equals(tokens[0], "list", StringComparison.OrdinalIgnoreCase)) + { + var items = Directory.EnumerateDirectories(pluginDir).Select(Path.GetFileName).OrderBy(name => name).ToArray(); + return new CommandResult(true, items.Length == 0 ? "No plugins installed." : string.Join(Environment.NewLine, items.Select((item, index) => $"{index + 1}. {item}"))); + } + + if (string.Equals(tokens[0], "install", StringComparison.OrdinalIgnoreCase) && tokens.Count > 1) + { + var installResult = await InstallDirectoryBackedPackageAsync(tokens[1], pluginDir, context.WorkingDirectory, "Plugin", ct).ConfigureAwait(false); + if (!installResult.Success) + { + return installResult; + } + + await (GetPluginManager(context)?.LoadPluginsAsync() ?? Task.CompletedTask).ConfigureAwait(false); + return installResult; + } + + if (string.Equals(tokens[0], "uninstall", StringComparison.OrdinalIgnoreCase) && tokens.Count > 1) + { + var path = Path.Combine(pluginDir, tokens[1]); + if (Directory.Exists(path)) + { + Directory.Delete(path, true); + } + await (GetPluginManager(context)?.UnloadPluginAsync(tokens[1]) ?? Task.CompletedTask).ConfigureAwait(false); + return new CommandResult(true, $"Uninstalled plugin {tokens[1]}." ); + } + + if ((string.Equals(tokens[0], "enable", StringComparison.OrdinalIgnoreCase) || string.Equals(tokens[0], "disable", StringComparison.OrdinalIgnoreCase)) && tokens.Count > 1) + { + var marker = Path.Combine(pluginDir, tokens[1], string.Equals(tokens[0], "enable", StringComparison.OrdinalIgnoreCase) ? "enabled.flag" : "disabled.flag"); + Directory.CreateDirectory(Path.GetDirectoryName(marker)!); + await File.WriteAllTextAsync(marker, tokens[0], ct).ConfigureAwait(false); + return new CommandResult(true, $"{tokens[0]}d plugin {tokens[1]}." ); + } + + return new CommandResult(false, "Usage: /plugin [list|install |uninstall |enable |disable ]"); + } + + private static Task ExecuteVersionAsync(CommandContext context) + { + return Task.FromResult(new CommandResult(true, "0.1.0")); + } + + private static Task ExecuteAddDirAsync(CommandContext context, string? args, CancellationToken ct) + { + var tokens = Tokenize(args); + if (tokens.Count == 0) + { + return Task.FromResult(new CommandResult(false, "Usage: /add-dir ")); + } + + var dir = Path.GetFullPath(Path.Combine(context.WorkingDirectory, tokens[0])); + if (!Directory.Exists(dir)) + { + return Task.FromResult(new CommandResult(false, $"Directory not found: {dir}")); + } + + var config = LoadConfig(); + var dirs = GetArray(config, "workingDirectories"); + if (!dirs.Contains(dir, StringComparer.OrdinalIgnoreCase)) + { + dirs.Add(dir); + config["workingDirectories"] = JsonSerializer.SerializeToNode(dirs); + SaveConfig(config); + } + + return Task.FromResult(new CommandResult(true, $"Added working directory {dir}.")); + } + + private static Task ExecuteVimAsync(CommandContext context, string? args, CancellationToken ct) + => ToggleBooleanSettingAsync("vimMode", "Vim mode", args); + + private static Task ExecuteFastAsync(CommandContext context, string? args, CancellationToken ct) + => ToggleBooleanSettingAsync("fastMode", "Fast mode", args); + + private static async Task ExecuteUpgradeAsync(CommandContext context, CancellationToken ct) + { + var result = await RunProcessAsync("git", "remote -v", context.WorkingDirectory, null, ct).ConfigureAwait(false); + return new CommandResult(true, result.ExitCode == 0 ? $"Checked for updates.\n{result.StdOut.Trim()}" : "Unable to check for updates."); + } + + private static Task ExecuteFeedbackAsync(CommandContext context, string? args, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(args)) + { + return Task.FromResult(new CommandResult(true, "Share feedback with /feedback .")); + } + + var path = Path.Combine(GetFreeCodeDir(), "feedback.log"); + Directory.CreateDirectory(GetFreeCodeDir()); + File.AppendAllText(path, $"[{DateTime.UtcNow:O}] {args}{Environment.NewLine}"); + return Task.FromResult(new CommandResult(true, $"Saved feedback to {path}.")); + } + + private static Task ExecuteTagAsync(CommandContext context, string? args, CancellationToken ct) + { + var tokens = Tokenize(args); + var config = LoadConfig(); + var tags = GetArray(config, "sessionTags"); + + if (tokens.Count == 0 || string.Equals(tokens[0], "list", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(new CommandResult(true, tags.Count == 0 ? "No tags." : string.Join(Environment.NewLine, tags.Select((tag, index) => $"{index + 1}. {tag}")))); + } + + if (string.Equals(tokens[0], "add", StringComparison.OrdinalIgnoreCase) && tokens.Count > 1) + { + var tag = tokens[1]; + if (!tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) + { + tags.Add(tag); + config["sessionTags"] = JsonSerializer.SerializeToNode(tags); + SaveConfig(config); + } + return Task.FromResult(new CommandResult(true, $"Added tag {tag}.")); + } + + if (string.Equals(tokens[0], "remove", StringComparison.OrdinalIgnoreCase) && tokens.Count > 1) + { + tags.RemoveAll(tag => string.Equals(tag, tokens[1], StringComparison.OrdinalIgnoreCase)); + config["sessionTags"] = JsonSerializer.SerializeToNode(tags); + SaveConfig(config); + return Task.FromResult(new CommandResult(true, $"Removed tag {tokens[1]}.")); + } + + return Task.FromResult(new CommandResult(false, "Usage: /tag [list|add |remove ]")); + } + + private static async Task ExecuteCompactAsync(CommandContext context, CancellationToken ct) + { + var memory = GetSessionMemoryService(context); + var messages = GetConversation(context); + if (memory is not null) + { + await memory.TryExtractAsync(messages).ConfigureAwait(false); + } + + return new CommandResult(true, $"Requested compaction for {messages.Count} messages."); + } + + private static Task ExecuteContextAsync(CommandContext context, CancellationToken ct) + { + var messages = GetConversation(context); + var state = GetAppState(context); + var lines = new[] + { + $"Working directory: {context.WorkingDirectory}", + $"Messages: {messages.Count}", + $"File history: {state?.FileHistory.Count ?? 0}", + $"Tasks: {state?.Tasks.Count ?? 0}", + $"Memory entries: {LoadMemoryEntries().Count}" + }; + return Task.FromResult(new CommandResult(true, string.Join(Environment.NewLine, lines))); + } + + private static Task ExecutePermissionsAsync(CommandContext context, string? args, CancellationToken ct) + { + var tokens = Tokenize(args); + var state = GetAppState(context) ?? new AppState(); + var current = state.ToolPermissionContext as ToolPermissionContext ?? new ToolPermissionContext(); + + if (tokens.Count == 0 || string.Equals(tokens[0], "list", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(new CommandResult(true, $"Allowed: {string.Join(", ", current.AllowedTools)}\nDenied: {string.Join(", ", current.DeniedTools)}")); + } + + if (tokens.Count > 1 && (string.Equals(tokens[0], "allow", StringComparison.OrdinalIgnoreCase) || string.Equals(tokens[0], "deny", StringComparison.OrdinalIgnoreCase))) + { + var updated = new ToolPermissionContext + { + AllowedTools = new HashSet(current.AllowedTools, StringComparer.OrdinalIgnoreCase), + DeniedTools = new HashSet(current.DeniedTools, StringComparer.OrdinalIgnoreCase) + }; + + if (string.Equals(tokens[0], "allow", StringComparison.OrdinalIgnoreCase)) + { + ((HashSet)updated.AllowedTools).Add(tokens[1]); + ((HashSet)updated.DeniedTools).Remove(tokens[1]); + } + else + { + ((HashSet)updated.DeniedTools).Add(tokens[1]); + ((HashSet)updated.AllowedTools).Remove(tokens[1]); + } + + var store = GetAppStateStore(context); + if (store is not null) + { + var existingState = GetAppState(context) ?? new AppState(); + store.Update(_ => existingState with { ToolPermissionContext = updated }); + } + return Task.FromResult(new CommandResult(true, $"Updated permissions for {tokens[1]}.")); + } + + return Task.FromResult(new CommandResult(false, "Usage: /permissions [list|allow |deny ]")); + } + + private static Task ExecuteEffortAsync(CommandContext context, string? args, CancellationToken ct) + { + var tokens = Tokenize(args); + if (tokens.Count == 0) + { + return Task.FromResult(new CommandResult(true, GetConfigString("effort") ?? "medium")); + } + + var effort = tokens[0].ToLowerInvariant(); + if (effort is not ("low" or "medium" or "high")) + { + return Task.FromResult(new CommandResult(false, "Usage: /effort [low|medium|high]")); + } + + var config = LoadConfig(); + config["effort"] = effort; + SaveConfig(config); + return Task.FromResult(new CommandResult(true, $"Effort set to {effort}.")); + } + + private static Task ExecuteTasksAsync(CommandContext context, string? args, CancellationToken ct) + { + var manager = GetBackgroundTaskManager(context); + if (manager is null) + { + return Task.FromResult(new CommandResult(false, "Background task manager is unavailable.")); + } + + var tokens = Tokenize(args); + if (tokens.Count == 0 || string.Equals(tokens[0], "list", StringComparison.OrdinalIgnoreCase)) + { + var tasks = manager.ListTasks(); + return Task.FromResult(new CommandResult(true, tasks.Count == 0 ? "No background tasks." : string.Join(Environment.NewLine, tasks.Select(task => $"- {task.TaskId} [{task.TaskType}] {task.Status}")))); + } + + if (string.Equals(tokens[0], "stop", StringComparison.OrdinalIgnoreCase) && tokens.Count > 1) + { + manager.StopTaskAsync(tokens[1]).GetAwaiter().GetResult(); + return Task.FromResult(new CommandResult(true, $"Stopped task {tokens[1]}.")); + } + + return Task.FromResult(new CommandResult(false, "Usage: /tasks [list|stop ]")); + } + + private static async Task ExecuteTeleportAsync(CommandContext context, string? args, CancellationToken ct) + { + var tokens = Tokenize(args); + if (tokens.Count == 0) + { + return await ExecuteSessionAsync(context, "list", ct).ConfigureAwait(false); + } + + var path = ResolveSessionPath(tokens[0]); + if (path is null) + { + return new CommandResult(false, $"Session {tokens[0]} not found."); + } + + var messages = await ReadSessionAsync(path, ct).ConfigureAwait(false); + UpdateFileHistory(context, messages); + return new CommandResult(true, $"Teleported to session {Path.GetFileNameWithoutExtension(path)}."); + } + + private static Task ExecuteRewindAsync(CommandContext context, string? args, CancellationToken ct) + { + var tokens = Tokenize(args); + var count = tokens.Count > 0 && int.TryParse(tokens[0], out var value) ? Math.Max(1, value) : 1; + var messages = GetConversation(context).ToList(); + if (messages.Count == 0) + { + return Task.FromResult(new CommandResult(false, "No messages to rewind.")); + } + + var trimmed = messages.Take(Math.Max(0, messages.Count - count)).ToArray(); + UpdateFileHistory(context, trimmed); + return Task.FromResult(new CommandResult(true, $"Rewound {Math.Min(count, messages.Count)} messages.")); + } + + private static Task ExecuteTerminalSetupAsync(CommandContext context, CancellationToken ct) + { + var lines = new[] + { + $"TERM={Environment.GetEnvironmentVariable("TERM") ?? "unset"}", + $"COLORTERM={Environment.GetEnvironmentVariable("COLORTERM") ?? "unset"}", + $"NO_COLOR={(Environment.GetEnvironmentVariable("NO_COLOR") is null ? "unset" : "set")}", + $"Platform={(OperatingSystem.IsMacOS() ? "macOS" : OperatingSystem.IsLinux() ? "Linux" : OperatingSystem.IsWindows() ? "Windows" : "unknown")}" + }; + return Task.FromResult(new CommandResult(true, string.Join(Environment.NewLine, lines))); + } + + private static Task ExecuteDesktopAsync(CommandContext context, string? args, CancellationToken ct) + => ToggleBooleanSettingAsync("desktopIntegration", "Desktop integration", args); + + private static Task ExecuteMobileAsync(CommandContext context, string? args, CancellationToken ct) + => ToggleBooleanSettingAsync("mobileIntegration", "Mobile integration", args); + + private static Task ExecuteChromeAsync(CommandContext context, string? args, CancellationToken ct) + => ToggleBooleanSettingAsync("chromeIntegration", "Chrome integration", args); + + private static Task ExecutePrivacySettingsAsync(CommandContext context, string? args, CancellationToken ct) + { + var tokens = Tokenize(args); + var config = LoadConfig(); + var privacy = GetObject(config, "privacySettings"); + + if (tokens.Count == 0 || string.Equals(tokens[0], "list", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(new CommandResult(true, privacy.ToJsonString(JsonOptions))); + } + + if (string.Equals(tokens[0], "set", StringComparison.OrdinalIgnoreCase) && tokens.Count > 2) + { + privacy[tokens[1]] = tokens[2]; + config["privacySettings"] = privacy; + SaveConfig(config); + return Task.FromResult(new CommandResult(true, $"Updated privacy setting {tokens[1]}.")); + } + + return Task.FromResult(new CommandResult(false, "Usage: /privacy-settings [list|set ]")); + } + + private static Task ExecuteAssistantAsync(CommandContext context, string? args, CancellationToken ct) + => ToggleStringSettingAsync("assistantProfile", args, "default", "Assistant profile"); + + private static Task ExecuteSandboxToggleAsync(CommandContext context, string? args, CancellationToken ct) + => ToggleBooleanSettingAsync("sandboxEnabled", "Sandbox mode", args); + + private static async Task ExecuteRemoteEnvAsync(CommandContext context, string? args, CancellationToken ct) + { + var manager = GetRemoteSessionManager(context); + if (manager is null) + { + return new CommandResult(false, "Remote session manager is unavailable."); + } + + var tokens = Tokenize(args); + if (tokens.Count == 0 || string.Equals(tokens[0], "status", StringComparison.OrdinalIgnoreCase)) + { + return new CommandResult(true, "Remote environment command is ready."); + } + + if (string.Equals(tokens[0], "connect", StringComparison.OrdinalIgnoreCase) && tokens.Count > 1) + { + await manager.ConnectAsync(tokens[1], ct).ConfigureAwait(false); + return new CommandResult(true, $"Connected to remote environment {tokens[1]}."); + } + + if (string.Equals(tokens[0], "disconnect", StringComparison.OrdinalIgnoreCase)) + { + await manager.DisconnectAsync(ct).ConfigureAwait(false); + return new CommandResult(true, "Disconnected remote environment."); + } + + return new CommandResult(false, "Usage: /remote-env [status|connect |disconnect]"); + } + + private static Task ExecuteRateLimitOptionsAsync(CommandContext context, string? args, CancellationToken ct) + => ExecuteNamedJsonSettingAsync("rateLimitOptions", args); + + private static Task ExecutePassesAsync(CommandContext context, string? args, CancellationToken ct) + => ExecuteNamedJsonSettingAsync("passes", args); + + private static Task ExecuteBreakCacheAsync(CommandContext context, CancellationToken ct) + { + var config = LoadConfig(); + config["promptCacheBreakNonce"] = Guid.NewGuid().ToString("N"); + SaveConfig(config); + return Task.FromResult(new CommandResult(true, "Prompt cache break marker updated.")); + } + + private static async Task ExecuteSummaryPromptAsync(CommandContext context, string? args, CancellationToken ct) + { + var messages = GetConversation(context); + var prompt = $"Summarize the current conversation in a concise, actionable way. Include the main decisions, open questions, and any important files or commands mentioned.\n\nMessage count: {messages.Count}"; + return await Task.FromResult(new CommandResult(true, prompt)).ConfigureAwait(false); + } + + private static async Task ExecuteShareAsync(CommandContext context, string? args, CancellationToken ct) + { + var messages = GetConversation(context); + var path = Path.Combine(GetFreeCodeDir(), $"share-{DateTime.UtcNow:yyyyMMddHHmmss}.md"); + Directory.CreateDirectory(GetFreeCodeDir()); + await File.WriteAllTextAsync(path, BuildConversationMarkdown(messages), ct).ConfigureAwait(false); + return new CommandResult(true, $"Shared conversation exported to {path}."); + } + + private static Task ExecuteReleaseNotesAsync(CommandContext context, CancellationToken ct) + { + var files = new[] { "RELEASE_NOTES.md", "CHANGELOG.md", "README.md" }; + foreach (var file in files) + { + var path = Path.Combine(context.WorkingDirectory, file); + if (File.Exists(path)) + { + return Task.FromResult(new CommandResult(true, File.ReadAllText(path))); + } + } + + return Task.FromResult(new CommandResult(true, "No release notes file was found.")); + } + + private static Task ExecuteStatusLineAsync(CommandContext context, string? args, CancellationToken ct) + => ExecuteNamedStringSettingAsync("statusLine", args); + + private static async Task ExecutePrCommentsAsync(CommandContext context, string? args, CancellationToken ct) + { + var target = string.IsNullOrWhiteSpace(args) ? "" : args.Trim(); + var ghArgs = string.IsNullOrWhiteSpace(target) ? "pr view --comments" : $"pr view {EscapeArg(target)} --comments"; + var result = await RunProcessAsync("gh", ghArgs, context.WorkingDirectory, null, ct).ConfigureAwait(false); + return new CommandResult(result.ExitCode == 0, result.ExitCode == 0 ? result.StdOut : (string.IsNullOrWhiteSpace(result.StdErr) ? result.StdOut : result.StdErr)); + } + + private static async Task ExecuteReviewPromptAsync(CommandContext context, string? args, CancellationToken ct) + { + var diff = await RunProcessAsync("git", "diff --stat", context.WorkingDirectory, null, ct).ConfigureAwait(false); + var prompt = $"Review the current code changes for correctness, bugs, missing tests, and maintainability.\n\nDiff summary:\n{diff.StdOut.Trim()}"; + return new CommandResult(true, prompt); + } + + private static async Task ExecuteUltraReviewPromptAsync(CommandContext context, string? args, CancellationToken ct) + { + var status = await RunProcessAsync("git", "status --short", context.WorkingDirectory, null, ct).ConfigureAwait(false); + var prompt = $"Perform a deep, comprehensive code review. Check architecture, correctness, security, tests, and edge cases.\n\nRepository status:\n{status.StdOut.Trim()}"; + return new CommandResult(true, prompt); + } + + private static Task ExecuteInstallAppAsync(string appName, string url, CancellationToken ct) + => OpenUrlAsync(appName, url, ct); + + private static async Task ExecuteBtwAsync(CommandContext context, string? args, CancellationToken ct) + { + var companionService = GetCompanionService(context); + var seed = string.IsNullOrWhiteSpace(args) ? "buddy" : args.Trim(); + var companion = companionService?.Create(seed); + return new CommandResult(true, companion is null ? $"Buddy companion requested: {seed}." : $"Companion: {companion.Name} ({companion.Species}, {companion.Rarity})"); + } + + private static async Task ExecuteUltraPlanPromptAsync(CommandContext context, string? args, CancellationToken ct) + { + var coordinator = GetCoordinatorService(context); + if (coordinator is not null) + { + var prompt = coordinator.BuildCoordinatorSystemPrompt(new CoordinatorPromptContext(context.WorkingDirectory, null, [], [])); + return new CommandResult(true, prompt); + } + + return new CommandResult(true, "Build an advanced multi-agent plan for the current task, including milestones, risks, and validation steps."); + } + + private static Task ExecuteThinkbackAsync(CommandContext context, CancellationToken ct) + { + var messages = GetConversation(context).TakeLast(5).Select(message => $"{message.Role}: {message.Content}"); + return Task.FromResult(new CommandResult(true, string.Join(Environment.NewLine, messages))); + } + + private static Task ExecuteThinkbackPlayAsync(CommandContext context, CancellationToken ct) + { + var messages = GetConversation(context).TakeLast(10).Select((message, index) => $"Step {index + 1}: {message.Role} => {message.Content}"); + return Task.FromResult(new CommandResult(true, string.Join(Environment.NewLine, messages))); + } + + private static Task ExecuteHeapdumpAsync(CommandContext context, CancellationToken ct) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + var memory = GC.GetTotalMemory(forceFullCollection: false); + return Task.FromResult(new CommandResult(true, $"Heap collection requested. Approximate managed memory: {memory:N0} bytes.")); + } + + private static Task ExecuteMockLimitsAsync(CommandContext context, CancellationToken ct) + => Task.FromResult(new CommandResult(true, "Mock limits prepared: remaining=999, reset=never.")); + + private static async Task ExecuteBridgeKickAsync(CommandContext context, CancellationToken ct) + { + var bridge = GetBridgeService(context); + if (bridge is not null) + { + await bridge.DeregisterEnvironmentAsync().ConfigureAwait(false); + } + + return new CommandResult(true, "Bridge session cleared."); + } + + private static async Task ExecuteAntTraceAsync(CommandContext context, string? args, CancellationToken ct) + { + var path = Path.Combine(GetFreeCodeDir(), "ant-trace.log"); + Directory.CreateDirectory(GetFreeCodeDir()); + await File.AppendAllTextAsync(path, $"[{DateTime.UtcNow:O}] {args ?? "trace"}{Environment.NewLine}", ct).ConfigureAwait(false); + return new CommandResult(true, $"Trace written to {path}."); + } + + private static Task ExecutePerfIssueAsync(CommandContext context, string? args, CancellationToken ct) + { + var path = Path.Combine(GetFreeCodeDir(), "perf-issue.md"); + File.WriteAllText(path, $"# Performance issue\n\n{args ?? "Describe the issue here."}"); + return Task.FromResult(new CommandResult(true, $"Performance issue note saved to {path}.")); + } + + private static Task ExecuteDebugToolCallAsync(CommandContext context, string? args, CancellationToken ct) + => Task.FromResult(new CommandResult(true, $"Debug tool call context: {context.WorkingDirectory}\nArgs: {args ?? string.Empty}")); + + private static Task ExecuteOnboardingAsync(CommandContext context, CancellationToken ct) + { + var config = LoadConfig(); + config["onboardedAt"] = DateTime.UtcNow.ToString("O"); + SaveConfig(config); + return Task.FromResult(new CommandResult(true, "Welcome to free-code. Config saved and onboarding completed.")); + } + + private static Task ExecuteBughunterAsync(CommandContext context, string? args, CancellationToken ct) + => ToggleBooleanSettingAsync("bughunterMode", "Bughunter mode", args); + + private static Task ExecuteGoodClaudeAsync(CommandContext context, string? args, CancellationToken ct) + { + var path = Path.Combine(GetFreeCodeDir(), "ratings.log"); + File.AppendAllText(path, $"[{DateTime.UtcNow:O}] good-claude {args ?? string.Empty}{Environment.NewLine}"); + return Task.FromResult(new CommandResult(true, "Recorded a positive rating.")); + } + + private static Task ExecuteIssueAsync(CommandContext context, string? args, CancellationToken ct) + { + var path = Path.Combine(GetFreeCodeDir(), "issue.md"); + File.WriteAllText(path, $"# Issue report\n\n{args ?? "Describe the issue here."}"); + return Task.FromResult(new CommandResult(true, $"Issue note saved to {path}.")); + } + + private static Task ExecuteAdvisorAsync(CommandContext context, string? args, CancellationToken ct) + { + var advice = new List + { + "Keep changes small and verifiable.", + GetAppState(context)?.ThinkingEnabled == true ? "Thinking is enabled." : "Thinking is disabled.", + GetAuthService(context)?.IsAuthenticated == true ? "Authenticated session available." : "Not authenticated." + }; + return Task.FromResult(new CommandResult(true, string.Join(Environment.NewLine, advice))); + } + + private static Task ExecuteInsightsAsync(CommandContext context, CancellationToken ct) + { + var state = GetAppState(context); + var lines = new[] + { + $"Model: {state?.MainLoopModel ?? "unset"}", + $"Tasks: {state?.Tasks.Count ?? 0}", + $"Notifications: {state?.Notifications.Queue.Count ?? 0}", + $"Speculation: {(state?.Speculation.Status == "active" ? state.Speculation.Status : "disabled")}" + }; + return Task.FromResult(new CommandResult(true, string.Join(Environment.NewLine, lines))); + } + + private static Task ExecuteResetLimitsAsync(CommandContext context, CancellationToken ct) + { + var config = LoadConfig(); + config["rateLimitOptions"] = new JsonObject(); + SaveConfig(config); + return Task.FromResult(new CommandResult(true, "Rate limit settings reset.")); + } + + private static Task ExecuteCtxVizAsync(CommandContext context, CancellationToken ct) + { + var messages = GetConversation(context); + var viz = new StringBuilder(); + viz.AppendLine("Context graph:"); + viz.AppendLine($"cwd -> {context.WorkingDirectory}"); + viz.AppendLine($"messages -> {messages.Count}"); + viz.AppendLine($"memory -> {LoadMemoryEntries().Count}"); + return Task.FromResult(new CommandResult(true, viz.ToString().TrimEnd())); + } + + private static async Task ExecuteOauthRefreshAsync(CommandContext context, CancellationToken ct) + { + var auth = GetAuthService(context); + if (auth is null) + { + return new CommandResult(false, "Auth service is unavailable."); + } + + var token = await auth.GetOAuthTokenAsync().ConfigureAwait(false); + if (token is null) + { + return new CommandResult(false, "No OAuth token available to refresh."); + } + + var storage = GetSecureTokenStorage(context); + storage?.Set("oauth-token", token); + return new CommandResult(true, "OAuth token refreshed."); + } + + private static async Task ExecuteNamedSettingAsync(string key, IReadOnlyList choices, CommandContext context, string? args) + { + var tokens = Tokenize(args); + var config = LoadConfig(); + + if (tokens.Count == 0 || string.Equals(tokens[0], "list", StringComparison.OrdinalIgnoreCase)) + { + var current = GetConfigString(key) ?? "unset"; + return new CommandResult(true, $"Current {key}: {current}\nAvailable: {string.Join(", ", choices)}"); + } + + var value = tokens[0]; + if (!choices.Contains(value, StringComparer.OrdinalIgnoreCase)) + { + return new CommandResult(false, $"Usage: /{key} [{string.Join("|", choices)}]"); + } + + config[key] = value; + SaveConfig(config); + return new CommandResult(true, $"{key} set to {value}."); + } + + private static Task ExecuteNamedJsonSettingAsync(string key, string? args) + { + var tokens = Tokenize(args); + var config = LoadConfig(); + + if (tokens.Count == 0 || string.Equals(tokens[0], "list", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(new CommandResult(true, config[key]?.ToJsonString(JsonOptions) ?? "{}")); + } + + if (tokens.Count > 1 && string.Equals(tokens[0], "set", StringComparison.OrdinalIgnoreCase)) + { + config[key] = JsonSerializer.SerializeToNode(string.Join(' ', tokens.Skip(1))); + SaveConfig(config); + return Task.FromResult(new CommandResult(true, $"Updated {key}.")); + } + + return Task.FromResult(new CommandResult(false, $"Usage: /{key} [list|set ]")); + } + + private static Task ExecuteNamedStringSettingAsync(string key, string? args) + { + var tokens = Tokenize(args); + var config = LoadConfig(); + + if (tokens.Count == 0) + { + return Task.FromResult(new CommandResult(true, GetConfigString(key) ?? "unset")); + } + + config[key] = string.Join(' ', tokens); + SaveConfig(config); + return Task.FromResult(new CommandResult(true, $"Updated {key}.")); + } + + private static Task ToggleBooleanSettingAsync(string key, string displayName, string? args) + { + var config = LoadConfig(); + var current = config[key]?.GetValue() ?? false; + var tokens = Tokenize(args); + + if (tokens.Count == 0) + { + return Task.FromResult(new CommandResult(true, $"{displayName}: {(current ? "on" : "off")}")); + } + + var next = tokens[0].Equals("toggle", StringComparison.OrdinalIgnoreCase) ? !current : tokens[0].Equals("on", StringComparison.OrdinalIgnoreCase) || tokens[0].Equals("true", StringComparison.OrdinalIgnoreCase); + config[key] = next; + SaveConfig(config); + return Task.FromResult(new CommandResult(true, $"{displayName} {(next ? "enabled" : "disabled")}.")); + } + + private static Task ToggleStringSettingAsync(string key, string? args, string defaultValue, string displayName) + { + var config = LoadConfig(); + var current = GetConfigString(key) ?? defaultValue; + var tokens = Tokenize(args); + + if (tokens.Count == 0) + { + return Task.FromResult(new CommandResult(true, $"{displayName}: {current}")); + } + + var next = string.Join(' ', tokens); + config[key] = next; + SaveConfig(config); + return Task.FromResult(new CommandResult(true, $"{displayName} set to {next}.")); + } + + private static async Task CopyToClipboardAsync(string text, CancellationToken ct) + { + var process = OperatingSystem.IsMacOS() + ? new ProcessStartInfo("pbcopy") + : OperatingSystem.IsLinux() + ? new ProcessStartInfo("xclip", "-selection clipboard") + : new ProcessStartInfo("cmd", "/c clip"); + + process.RedirectStandardInput = true; + process.UseShellExecute = false; + + using var clipboard = Process.Start(process); + if (clipboard is null) + { + return new CommandResult(false, "Clipboard command could not be started."); + } + + await clipboard.StandardInput.WriteAsync(text.AsMemory(), ct).ConfigureAwait(false); + clipboard.StandardInput.Close(); + await clipboard.WaitForExitAsync(ct).ConfigureAwait(false); + return new CommandResult(clipboard.ExitCode == 0, clipboard.ExitCode == 0 ? "Copied to clipboard." : "Clipboard command failed."); + } + + private static async Task OpenUrlAsync(string name, string url, CancellationToken ct) + { + var startInfo = OperatingSystem.IsMacOS() + ? new ProcessStartInfo("open", url) + : OperatingSystem.IsLinux() + ? new ProcessStartInfo("xdg-open", url) + : new ProcessStartInfo("cmd", $"/c start \"\" \"{url}\""); + + startInfo.UseShellExecute = false; + var process = Process.Start(startInfo); + if (process is null) + { + return new CommandResult(false, $"Open this URL manually: {url}"); + } + + await process.WaitForExitAsync(ct).ConfigureAwait(false); + return new CommandResult(true, $"{name} app URL opened: {url}"); + } + + private static async Task<(int ExitCode, string StdOut, string StdErr)> RunProcessAsync(string fileName, string arguments, string workingDirectory, string? standardInput, CancellationToken ct) + { + var startInfo = new ProcessStartInfo(fileName, arguments) + { + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = standardInput is not null, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = new Process { StartInfo = startInfo }; + if (!process.Start()) + { + return (-1, string.Empty, $"Failed to start {fileName}."); + } + + if (standardInput is not null) + { + await process.StandardInput.WriteAsync(standardInput.AsMemory(), ct).ConfigureAwait(false); + process.StandardInput.Close(); + } + + var stdoutTask = process.StandardOutput.ReadToEndAsync(ct); + var stderrTask = process.StandardError.ReadToEndAsync(ct); + await process.WaitForExitAsync(ct).ConfigureAwait(false); + var stdout = await stdoutTask.ConfigureAwait(false); + var stderr = await stderrTask.ConfigureAwait(false); + return (process.ExitCode, stdout, stderr); + } + + private static async Task InstallDirectoryBackedPackageAsync(string source, string destinationRoot, string workingDirectory, string itemLabel, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(source); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationRoot); + ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(itemLabel); + + Directory.CreateDirectory(destinationRoot); + + var resolvedSource = ResolveInstallSourcePath(source, workingDirectory); + if (resolvedSource is not null) + { + var targetName = Path.GetFileName(resolvedSource.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + var targetPath = Path.Combine(destinationRoot, targetName); + if (Directory.Exists(targetPath)) + { + Directory.Delete(targetPath, true); + } + + CopyDirectory(resolvedSource, targetPath); + return new CommandResult(true, $"Installed {itemLabel.ToLowerInvariant()} {targetName}."); + } + + if (LooksLikeGitUrl(source)) + { + var targetName = GetInstallTargetNameFromGitUrl(source); + var targetPath = Path.Combine(destinationRoot, targetName); + if (Directory.Exists(targetPath)) + { + Directory.Delete(targetPath, true); + } + + var cloneResult = await RunProcessAsync("git", $"clone {EscapeArg(source)} {EscapeArg(targetPath)}", workingDirectory, null, ct).ConfigureAwait(false); + if (cloneResult.ExitCode != 0) + { + var error = string.IsNullOrWhiteSpace(cloneResult.StdErr) ? cloneResult.StdOut : cloneResult.StdErr; + return new CommandResult(false, string.IsNullOrWhiteSpace(error) ? $"Failed to clone {source}." : error.Trim()); + } + + return new CommandResult(true, $"Installed {itemLabel.ToLowerInvariant()} {targetName}."); + } + + return new CommandResult(false, $"{itemLabel} source must be a local directory path or git URL."); + } + + private static string EscapeArg(string value) => value.Replace("\"", "\\\""); + + private static string? ResolveInstallSourcePath(string source, string workingDirectory) + { + var candidate = Path.IsPathRooted(source) + ? source + : Path.GetFullPath(Path.Combine(workingDirectory, source)); + + return Directory.Exists(candidate) ? candidate : null; + } + + private static bool LooksLikeGitUrl(string source) + => source.Contains("://", StringComparison.Ordinal) || source.Contains("git@", StringComparison.OrdinalIgnoreCase); + + private static string GetInstallTargetNameFromGitUrl(string source) + { + var normalized = source.TrimEnd('/', '\\'); + var lastSlash = Math.Max(normalized.LastIndexOf('/'), normalized.LastIndexOf(':')); + var name = lastSlash >= 0 ? normalized[(lastSlash + 1)..] : normalized; + if (name.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + { + name = name[..^4]; + } + + return string.IsNullOrWhiteSpace(name) ? "package" : name; + } + + private static void CopyDirectory(string sourceDirectory, string destinationDirectory) + { + Directory.CreateDirectory(destinationDirectory); + foreach (var file in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(sourceDirectory, file); + var destinationFile = Path.Combine(destinationDirectory, relative); + Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!); + File.Copy(file, destinationFile, overwrite: true); + } + } + + private static string GetFreeCodeDir() => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".free-code"); + private static string GetSessionsDir() => Path.Combine(GetFreeCodeDir(), "sessions"); + private static string GetConfigPath() => Path.Combine(GetFreeCodeDir(), "config.json"); + private static string GetMemoryPath() => Path.Combine(GetFreeCodeDir(), "memory.json"); + private static string GetNamedDir(string name) => Path.Combine(GetFreeCodeDir(), name); + + private static JsonObject LoadConfig() + { + Directory.CreateDirectory(GetFreeCodeDir()); + var path = GetConfigPath(); + if (!File.Exists(path)) + { + return new JsonObject(); + } + + var text = File.ReadAllText(path); + return string.IsNullOrWhiteSpace(text) ? new JsonObject() : (JsonNode.Parse(text) as JsonObject) ?? new JsonObject(); + } + + private static void SaveConfig(JsonObject config) + { + Directory.CreateDirectory(GetFreeCodeDir()); + File.WriteAllText(GetConfigPath(), config.ToJsonString(JsonOptions)); + } + + private static JsonObject GetObject(JsonObject config, string key) + { + if (config[key] is JsonObject existing) + { + return existing; + } + + var created = new JsonObject(); + config[key] = created; + return created; + } + + private static List GetArray(JsonObject config, string key) + { + if (config[key] is JsonArray array) + { + return array.Select(node => node?.GetValue() ?? string.Empty).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(); + } + + var created = new List(); + config[key] = JsonSerializer.SerializeToNode(created); + return created; + } + + private static string? GetConfigString(string key) => LoadConfig()[key]?.GetValue(); + + private static List LoadMemoryEntries() + { + var path = GetMemoryPath(); + if (!File.Exists(path)) + { + return []; + } + + var text = File.ReadAllText(path); + if (string.IsNullOrWhiteSpace(text)) + { + return []; + } + + return JsonSerializer.Deserialize>(text) ?? []; + } + + private static void SaveMemoryEntries(List entries) + { + Directory.CreateDirectory(GetFreeCodeDir()); + File.WriteAllText(GetMemoryPath(), JsonSerializer.Serialize(entries, JsonOptions)); + } + + private static IReadOnlyList GetConversation(CommandContext context) + { + var query = GetQueryEngine(context); + if (query is not null) + { + return query.GetMessages(); + } + + return GetAppState(context)?.FileHistory ?? []; + } + + private static string BuildConversationMarkdown(IReadOnlyList messages) + { + var builder = new StringBuilder(); + builder.AppendLine("# Conversation Export"); + builder.AppendLine(); + foreach (var message in messages) + { + builder.AppendLine($"## {message.Role}"); + builder.AppendLine(message.Content?.ToString() ?? string.Empty); + builder.AppendLine(); + } + + return builder.ToString(); + } + + private static async Task> ReadSessionAsync(string path, CancellationToken ct) + { + var text = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false); + return string.IsNullOrWhiteSpace(text) ? [] : JsonSerializer.Deserialize>(text) ?? []; + } + + private static int GetMessageCount(string path) + { + try + { + var text = File.ReadAllText(path); + return string.IsNullOrWhiteSpace(text) ? 0 : (JsonSerializer.Deserialize>(text)?.Count ?? 0); + } + catch (JsonException) + { + return 0; + } + } + + private static string EnsureSessionFileName(string id) => id.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ? id : $"{id}.json"; + + private static string? ResolveSessionPath(string? id) + { + if (string.IsNullOrWhiteSpace(id)) + { + return null; + } + + var sessionsDir = GetSessionsDir(); + var name = EnsureSessionFileName(id.Trim()); + var direct = Path.Combine(sessionsDir, name); + if (File.Exists(direct)) + { + return direct; + } + + var byId = Directory.EnumerateFiles(sessionsDir, "*.json").FirstOrDefault(file => string.Equals(Path.GetFileNameWithoutExtension(file), id, StringComparison.OrdinalIgnoreCase)); + return byId; + } + + private static string GetLatestSessionId() + { + var latest = Directory.Exists(GetSessionsDir()) + ? Directory.EnumerateFiles(GetSessionsDir(), "*.json").OrderByDescending(File.GetLastWriteTimeUtc).FirstOrDefault() + : null; + return latest is null ? string.Empty : Path.GetFileNameWithoutExtension(latest); + } + + private static void UpdateFileHistory(CommandContext context, IReadOnlyList messages) + { + if (context.Services.GetService(typeof(IAppStateStore)) is not IAppStateStore store) + { + return; + } + + store.Update(state => + { + var appState = state as AppState ?? new AppState(); + return appState with { FileHistory = messages }; + }); + } + + private static void SetConfigValue(JsonObject config, string key, string value) + { + if (bool.TryParse(value, out var booleanValue)) + { + config[key] = booleanValue; + return; + } + + if (int.TryParse(value, out var intValue)) + { + config[key] = intValue; + return; + } + + config[key] = value; + } + + private static List Tokenize(string? args) + { + if (string.IsNullOrWhiteSpace(args)) + { + return []; + } + + var tokens = new List(); + var current = new StringBuilder(); + var inQuotes = false; + + foreach (var character in args) + { + if (character == '"') + { + inQuotes = !inQuotes; + continue; + } + + if (char.IsWhiteSpace(character) && !inQuotes) + { + if (current.Length > 0) + { + tokens.Add(current.ToString()); + current.Clear(); + } + + continue; + } + + current.Append(character); + } + + if (current.Length > 0) + { + tokens.Add(current.ToString()); + } + + return tokens; + } + + private static string ResolveOutputPath(string? args, string extension) + { + if (!string.IsNullOrWhiteSpace(args)) + { + return Path.GetFullPath(args.Trim()); + } + + return Path.Combine(Environment.CurrentDirectory, $"conversation.{extension}"); + } + + private static string GetLastAssistantText(CommandContext context) + { + var messages = GetConversation(context).Reverse(); + foreach (var message in messages) + { + if (message.Role == MessageRole.Assistant && message.Content is not null) + { + return message.Content.ToString() ?? string.Empty; + } + } + + return string.Empty; + } + + private static string GetSelectedValue(JsonObject config, string key) => config[key]?.GetValue() ?? "unset"; + + private static IQueryEngine? GetQueryEngine(CommandContext context) => context.Services.GetService(typeof(IQueryEngine)) as IQueryEngine; + private static IAppStateStore? GetAppStateStore(CommandContext context) => context.Services.GetService(typeof(IAppStateStore)) as IAppStateStore; + private static AppState? GetAppState(CommandContext context) => GetAppStateStore(context)?.GetState() as AppState; + private static IAuthService? GetAuthService(CommandContext context) => context.Services.GetService(typeof(IAuthService)) as IAuthService; + private static IFeatureFlagService? GetFeatureFlagService(CommandContext context) => context.Services.GetService(typeof(IFeatureFlagService)) as IFeatureFlagService; + private static IBackgroundTaskManager? GetBackgroundTaskManager(CommandContext context) => context.Services.GetService(typeof(IBackgroundTaskManager)) as IBackgroundTaskManager; + private static IMcpClientManager? GetMcpClientManager(CommandContext context) => context.Services.GetService(typeof(IMcpClientManager)) as IMcpClientManager; + private static ISkillLoader? GetSkillLoader(CommandContext context) => context.Services.GetService(typeof(ISkillLoader)) as ISkillLoader; + private static IPluginManager? GetPluginManager(CommandContext context) => context.Services.GetService(typeof(IPluginManager)) as IPluginManager; + private static IRemoteSessionManager? GetRemoteSessionManager(CommandContext context) => context.Services.GetService(typeof(IRemoteSessionManager)) as IRemoteSessionManager; + private static IBridgeService? GetBridgeService(CommandContext context) => context.Services.GetService(typeof(IBridgeService)) as IBridgeService; + private static ICompanionService? GetCompanionService(CommandContext context) => context.Services.GetService(typeof(ICompanionService)) as ICompanionService; + private static ICoordinatorService? GetCoordinatorService(CommandContext context) => context.Services.GetService(typeof(ICoordinatorService)) as ICoordinatorService; + private static ISessionMemoryService? GetSessionMemoryService(CommandContext context) => context.Services.GetService(typeof(ISessionMemoryService)) as ISessionMemoryService; + private static ISecureTokenStorage? GetSecureTokenStorage(CommandContext context) => context.Services.GetService(typeof(ISecureTokenStorage)) as ISecureTokenStorage; + + private static List BuildStatusLines(CommandContext context, bool includeUsage) + { + var state = GetAppState(context); + var auth = GetAuthService(context); + var lines = new List + { + $"Authenticated: {(auth?.IsAuthenticated == true ? "yes" : "no")}", + $"Claude.ai user: {(auth?.IsClaudeAiUser == true ? "yes" : "no")}", + $"Internal user: {(auth?.IsInternalUser == true ? "yes" : "no")}", + $"Current model: {state?.MainLoopModel ?? "unset"}", + $"Session model: {state?.MainLoopModelForSession ?? "unset"}", + $"Messages: {GetConversation(context).Count}", + $"Tasks: {state?.Tasks.Count ?? 0}" + }; + + if (includeUsage) + { + var usage = GetQueryEngine(context)?.GetCurrentUsage() ?? new TokenUsage(0, 0, 0, 0); + lines.Add($"Usage: in={usage.InputTokens}, out={usage.OutputTokens}, cache-create={usage.CacheCreationTokens}, cache-read={usage.CacheReadTokens}"); + } + + return lines; + } +} diff --git a/src/FreeCode.Commands/CommandRegistry.cs b/src/FreeCode.Commands/CommandRegistry.cs new file mode 100644 index 0000000..437c731 --- /dev/null +++ b/src/FreeCode.Commands/CommandRegistry.cs @@ -0,0 +1,101 @@ +using FreeCode.Core.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode.Commands; + +public sealed class CommandRegistry(IServiceProvider serviceProvider) : ICommandRegistry +{ + private readonly object _gate = new(); + private IReadOnlyList? _cachedCommands; + + public Task> GetCommandsAsync() + => Task.FromResult(GetOrCreateCommands()); + + public Task> GetEnabledCommandsAsync() + { + var commands = GetOrCreateCommands(); + var authService = serviceProvider.GetService(typeof(IAuthService)) as IAuthService; + var enabled = new List(commands.Count + 4); + + foreach (var command in commands) + { + if (!command.IsEnabled()) + { + continue; + } + + if (!IsAvailable(command, authService)) + { + continue; + } + + enabled.Add(command); + } + + foreach (var pluginCommand in GetPluginCommands()) + { + if (!pluginCommand.IsEnabled()) + { + continue; + } + + if (!IsAvailable(pluginCommand, authService)) + { + continue; + } + + enabled.Add(pluginCommand); + } + + enabled.Sort(static (left, right) => + { + var categoryComparison = Comparer.Default.Compare(left.Category, right.Category); + return categoryComparison != 0 + ? categoryComparison + : StringComparer.OrdinalIgnoreCase.Compare(left.Name, right.Name); + }); + + return Task.FromResult>(enabled); + } + + private IReadOnlyList GetOrCreateCommands() + { + lock (_gate) + { + if (_cachedCommands is not null) + { + return _cachedCommands; + } + + var commands = serviceProvider.GetServices().ToArray(); + + _cachedCommands = commands + .OrderBy(command => command.Category) + .ThenBy(command => command.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + return _cachedCommands; + } + } + + private static bool IsAvailable(ICommand command, IAuthService? authService) + => command.Availability switch + { + FreeCode.Core.Enums.CommandAvailability.Always => true, + FreeCode.Core.Enums.CommandAvailability.RequiresAuth => authService?.IsAuthenticated ?? false, + FreeCode.Core.Enums.CommandAvailability.ClaudeAiOnly => authService?.IsClaudeAiUser ?? false, + FreeCode.Core.Enums.CommandAvailability.InternalOnly => authService?.IsInternalUser ?? false, + _ => false, + }; + + private IReadOnlyList GetPluginCommands() + { + var pluginManager = serviceProvider.GetService(typeof(IPluginManager)); + if (pluginManager is FreeCode.Plugins.PluginManager concreteManager) + { + return concreteManager.GetPluginCommands().OfType().ToArray(); + } + + return []; + } + +} diff --git a/src/FreeCode.Commands/CommitCommand.cs b/src/FreeCode.Commands/CommitCommand.cs new file mode 100644 index 0000000..4bd4b0d --- /dev/null +++ b/src/FreeCode.Commands/CommitCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class CommitCommand : CommandBase +{ + public override string Name => "commit"; + public override string Description => "Generate and create a git commit."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Prompt; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/CommitPushPrCommand.cs b/src/FreeCode.Commands/CommitPushPrCommand.cs new file mode 100644 index 0000000..de80999 --- /dev/null +++ b/src/FreeCode.Commands/CommitPushPrCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class CommitPushPrCommand : CommandBase +{ + public override string Name => "commit-push-pr"; + public override string[]? Aliases => new[] { "cpp" }; + public override string Description => "Commit, push, and open a pull request."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Prompt; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/CompactCommand.cs b/src/FreeCode.Commands/CompactCommand.cs new file mode 100644 index 0000000..e2bcd15 --- /dev/null +++ b/src/FreeCode.Commands/CompactCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class CompactCommand : CommandBase +{ + public override string Name => "compact"; + public override string Description => "Compact the conversation context."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/ConfigCommand.cs b/src/FreeCode.Commands/ConfigCommand.cs new file mode 100644 index 0000000..1694216 --- /dev/null +++ b/src/FreeCode.Commands/ConfigCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class ConfigCommand : CommandBase +{ + public override string Name => "config"; + public override string Description => "Get, set, or list configuration."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.LocalDialog; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/ContextCommand.cs b/src/FreeCode.Commands/ContextCommand.cs new file mode 100644 index 0000000..6e4d0ab --- /dev/null +++ b/src/FreeCode.Commands/ContextCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class ContextCommand : CommandBase +{ + public override string Name => "context"; + public override string Description => "Show current context information."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/CopyCommand.cs b/src/FreeCode.Commands/CopyCommand.cs new file mode 100644 index 0000000..4d43b5e --- /dev/null +++ b/src/FreeCode.Commands/CopyCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class CopyCommand : CommandBase +{ + public override string Name => "copy"; + public override string Description => "Copy the last response to the clipboard."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/CostCommand.cs b/src/FreeCode.Commands/CostCommand.cs new file mode 100644 index 0000000..5098ba3 --- /dev/null +++ b/src/FreeCode.Commands/CostCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class CostCommand : CommandBase +{ + public override string Name => "cost"; + public override string Description => "Show token cost estimates."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.RequiresAuth; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/CtxVizCommand.cs b/src/FreeCode.Commands/CtxVizCommand.cs new file mode 100644 index 0000000..52e70dd --- /dev/null +++ b/src/FreeCode.Commands/CtxVizCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class CtxVizCommand : CommandBase +{ + public override string Name => "ctx-viz"; + public override string Description => "Visualize the context."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/DebugToolCallCommand.cs b/src/FreeCode.Commands/DebugToolCallCommand.cs new file mode 100644 index 0000000..05a8003 --- /dev/null +++ b/src/FreeCode.Commands/DebugToolCallCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class DebugToolCallCommand : CommandBase +{ + public override string Name => "debug-tool-call"; + public override string Description => "Debug tool calls."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/DesktopCommand.cs b/src/FreeCode.Commands/DesktopCommand.cs new file mode 100644 index 0000000..a800212 --- /dev/null +++ b/src/FreeCode.Commands/DesktopCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class DesktopCommand : CommandBase +{ + public override string Name => "desktop"; + public override string Description => "Desktop integration."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/DiffCommand.cs b/src/FreeCode.Commands/DiffCommand.cs new file mode 100644 index 0000000..9c6c210 --- /dev/null +++ b/src/FreeCode.Commands/DiffCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class DiffCommand : CommandBase +{ + public override string Name => "diff"; + public override string Description => "Show the git diff."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/DoctorCommand.cs b/src/FreeCode.Commands/DoctorCommand.cs new file mode 100644 index 0000000..0237d04 --- /dev/null +++ b/src/FreeCode.Commands/DoctorCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class DoctorCommand : CommandBase +{ + public override string Name => "doctor"; + public override string Description => "Run diagnostics."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/EffortCommand.cs b/src/FreeCode.Commands/EffortCommand.cs new file mode 100644 index 0000000..01fb76d --- /dev/null +++ b/src/FreeCode.Commands/EffortCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class EffortCommand : CommandBase +{ + public override string Name => "effort"; + public override string Description => "Set the effort level."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/EnvCommand.cs b/src/FreeCode.Commands/EnvCommand.cs new file mode 100644 index 0000000..9edddb0 --- /dev/null +++ b/src/FreeCode.Commands/EnvCommand.cs @@ -0,0 +1,28 @@ +using FreeCode.Core.Enums; +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class EnvCommand : CommandBase +{ + public override string Name => "env"; + public override string Description => "Show environment variables."; + public override CommandCategory Category => CommandCategory.Local; + public override CommandAvailability Availability => CommandAvailability.InternalOnly; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + { + var variables = new List(); + foreach (var entry in Environment.GetEnvironmentVariables().Cast().OrderBy(e => (string)e.Key, StringComparer.OrdinalIgnoreCase)) + { + if (entry.Key.ToString()?.StartsWith("FREE_CODE", StringComparison.OrdinalIgnoreCase) == true) + { + variables.Add($"{entry.Key}={entry.Value}"); + } + } + + return variables.Count == 0 + ? Task.FromResult(new CommandResult(true, "No FREE_CODE environment variables set.")) + : Task.FromResult(new CommandResult(true, string.Join(Environment.NewLine, variables))); + } +} diff --git a/src/FreeCode.Commands/ExitCommand.cs b/src/FreeCode.Commands/ExitCommand.cs new file mode 100644 index 0000000..04a80b5 --- /dev/null +++ b/src/FreeCode.Commands/ExitCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class ExitCommand : CommandBase +{ + public override string Name => "exit"; + public override string[]? Aliases => ["quit"]; + public override string Description => "Exit the application."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => Task.FromResult(new CommandResult(true, "Exit requested.")); +} diff --git a/src/FreeCode.Commands/ExportCommand.cs b/src/FreeCode.Commands/ExportCommand.cs new file mode 100644 index 0000000..936c29c --- /dev/null +++ b/src/FreeCode.Commands/ExportCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class ExportCommand : CommandBase +{ + public override string Name => "export"; + public override string Description => "Export the conversation to a file."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/ExtraUsageCommand.cs b/src/FreeCode.Commands/ExtraUsageCommand.cs new file mode 100644 index 0000000..2a7919a --- /dev/null +++ b/src/FreeCode.Commands/ExtraUsageCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class ExtraUsageCommand : CommandBase +{ + public override string Name => "extra-usage"; + public override string Description => "Show detailed usage information."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.RequiresAuth; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/FastCommand.cs b/src/FreeCode.Commands/FastCommand.cs new file mode 100644 index 0000000..0eb36ea --- /dev/null +++ b/src/FreeCode.Commands/FastCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class FastCommand : CommandBase +{ + public override string Name => "fast"; + public override string Description => "Toggle fast mode."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/FeedbackCommand.cs b/src/FreeCode.Commands/FeedbackCommand.cs new file mode 100644 index 0000000..4a02af0 --- /dev/null +++ b/src/FreeCode.Commands/FeedbackCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class FeedbackCommand : CommandBase +{ + public override string Name => "feedback"; + public override string Description => "Submit feedback."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/FilesCommand.cs b/src/FreeCode.Commands/FilesCommand.cs new file mode 100644 index 0000000..a90a714 --- /dev/null +++ b/src/FreeCode.Commands/FilesCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class FilesCommand : CommandBase +{ + public override string Name => "files"; + public override string Description => "Manage project files context."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/FreeCode.Commands.csproj b/src/FreeCode.Commands/FreeCode.Commands.csproj new file mode 100644 index 0000000..7d8ffbc --- /dev/null +++ b/src/FreeCode.Commands/FreeCode.Commands.csproj @@ -0,0 +1,13 @@ + + + FreeCode.Commands + + + + + + + + + + diff --git a/src/FreeCode.Commands/GoodClaudeCommand.cs b/src/FreeCode.Commands/GoodClaudeCommand.cs new file mode 100644 index 0000000..b6a6870 --- /dev/null +++ b/src/FreeCode.Commands/GoodClaudeCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class GoodClaudeCommand : CommandBase +{ + public override string Name => "good-claude"; + public override string Description => "Rate the response."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/HeapdumpCommand.cs b/src/FreeCode.Commands/HeapdumpCommand.cs new file mode 100644 index 0000000..5ce0fd4 --- /dev/null +++ b/src/FreeCode.Commands/HeapdumpCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class HeapdumpCommand : CommandBase +{ + public override string Name => "heapdump"; + public override string Description => "Trigger a heap dump."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/HelpCommand.cs b/src/FreeCode.Commands/HelpCommand.cs new file mode 100644 index 0000000..38994a4 --- /dev/null +++ b/src/FreeCode.Commands/HelpCommand.cs @@ -0,0 +1,34 @@ +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class HelpCommand(IServiceProvider services) : CommandBase +{ + public override string Name => "help"; + public override string Description => "Show available commands."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Prompt; + + public override async Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + { + var commandRegistry = services.GetService(typeof(ICommandRegistry)) as ICommandRegistry; + if (commandRegistry is null) + { + return new CommandResult(false, "Command registry is unavailable."); + } + + var commands = await commandRegistry.GetEnabledCommandsAsync().ConfigureAwait(false); + var lines = new List(commands.Count + 1) { "Available commands:" }; + + foreach (var command in commands.OrderBy(command => command.Category).ThenBy(command => command.Name, StringComparer.OrdinalIgnoreCase)) + { + var aliasText = command.Aliases is { Length: > 0 } + ? $" ({string.Join(", ", command.Aliases)})" + : string.Empty; + + lines.Add($"/{command.Name}{aliasText} - {command.Description}"); + } + + return new CommandResult(true, string.Join(Environment.NewLine, lines)); + } +} diff --git a/src/FreeCode.Commands/HooksCommand.cs b/src/FreeCode.Commands/HooksCommand.cs new file mode 100644 index 0000000..9fcad3f --- /dev/null +++ b/src/FreeCode.Commands/HooksCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class HooksCommand : CommandBase +{ + public override string Name => "hooks"; + public override string Description => "Manage lifecycle hooks."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/IdeCommand.cs b/src/FreeCode.Commands/IdeCommand.cs new file mode 100644 index 0000000..874f807 --- /dev/null +++ b/src/FreeCode.Commands/IdeCommand.cs @@ -0,0 +1,236 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class IdeCommand(IServiceProvider services) : CommandBase +{ + private readonly IServiceProvider _services = services; + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public override string Name => "ide"; + public override string Description => "Manage IDE integrations and show status."; + public override CommandCategory Category => CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + { + var tokens = Tokenize(args); + var subcommand = tokens.Count == 0 ? "status" : tokens[0].ToLowerInvariant(); + + return subcommand switch + { + "status" => Task.FromResult(GetStatus(context)), + "connect" => ConnectAsync(context, tokens, ct), + "disconnect" => DisconnectAsync(context, ct), + "install" => InstallAsync(context, tokens, ct), + _ => Task.FromResult(new CommandResult(false, "Usage: /ide [connect [url]|disconnect|status|install ]")) + }; + } + + private static CommandResult GetStatus(CommandContext context) + { + var featureFlags = context.Services.GetService(typeof(IFeatureFlagService)) as IFeatureFlagService; + var mcpManager = context.Services.GetService(typeof(IMcpClientManager)) as IMcpClientManager; + var ideConnection = mcpManager?.GetConnections() + .FirstOrDefault(connection => string.Equals(connection.Name, "ide", StringComparison.OrdinalIgnoreCase)); + + var lines = new List + { + $"IDE integration feature: {(featureFlags?.IsEnabled("IDE_INTEGRATION") == true ? "enabled" : "disabled")}", + $"Configured connection: {(ideConnection is null ? "none" : DescribeConnection(ideConnection))}" + }; + + if (ideConnection?.Config is SseIdeServerConfig sse) + { + lines.Add($"URL: {sse.Url}"); + lines.Add($"IDE: {sse.IdeName}"); + } + else if (ideConnection?.Config is WsIdeServerConfig ws) + { + lines.Add($"URL: {ws.Url}"); + lines.Add($"IDE: {ws.IdeName}"); + } + else + { + lines.Add("Supported IDEs: VS Code, JetBrains"); + } + + return new CommandResult(true, string.Join(Environment.NewLine, lines)); + } + + private static async Task ConnectAsync(CommandContext context, IReadOnlyList tokens, CancellationToken ct) + { + if (tokens.Count < 2) + { + return new CommandResult(false, "Usage: /ide connect [url]"); + } + + var ide = NormalizeIde(tokens[1]); + if (ide is null) + { + return new CommandResult(false, "Supported IDEs: vscode, jetbrains"); + } + + var url = tokens.Count > 2 ? tokens[2] : GetDefaultIdeUrl(ide); + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != "ws" && uri.Scheme != "wss")) + { + return new CommandResult(false, $"Invalid IDE endpoint: {url}"); + } + + var config = LoadConfig(); + var mcpServers = GetObject(config, "mcpServers"); + mcpServers["ide"] = new JsonObject + { + ["type"] = uri.Scheme is "ws" or "wss" ? "ws-ide" : "sse-ide", + ["url"] = url, + ["ideName"] = ide, + ["scope"] = "User" + }; + + config["mcpServers"] = mcpServers; + SaveConfig(config); + + var manager = context.Services.GetService(typeof(IMcpClientManager)) as IMcpClientManager; + if (manager is not null) + { + await manager.ReloadAsync().ConfigureAwait(false); + } + + return new CommandResult(true, $"Connected IDE integration for {ide} at {url}."); + } + + private static async Task DisconnectAsync(CommandContext context, CancellationToken ct) + { + var config = LoadConfig(); + var mcpServers = GetObject(config, "mcpServers"); + var removed = mcpServers.Remove("ide"); + config["mcpServers"] = mcpServers; + SaveConfig(config); + + var manager = context.Services.GetService(typeof(IMcpClientManager)) as IMcpClientManager; + if (manager is not null) + { + try + { + await manager.DisconnectServerAsync("ide").ConfigureAwait(false); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Failed to disconnect IDE integration: {ex.Message}"); + } + + await manager.ReloadAsync().ConfigureAwait(false); + } + + return new CommandResult(true, removed ? "Disconnected IDE integration." : "No IDE integration was configured."); + } + + private static async Task InstallAsync(CommandContext context, IReadOnlyList tokens, CancellationToken ct) + { + if (tokens.Count < 2) + { + return new CommandResult(false, "Usage: /ide install "); + } + + var ide = NormalizeIde(tokens[1]); + if (ide is null) + { + return new CommandResult(false, "Supported IDEs: vscode, jetbrains"); + } + + var displayName = ide == "vscode" ? "VS Code" : "JetBrains"; + var config = LoadConfig(); + var mcpServers = GetObject(config, "mcpServers"); + mcpServers["ide"] = new JsonObject + { + ["command"] = "free-code-ide-extension", + ["args"] = new JsonArray(ide, GetDefaultIdeUrl(ide)), + ["env"] = new JsonObject + { + ["FREE_CODE_IDE"] = ide, + ["FREE_CODE_IDE_DISPLAY_NAME"] = displayName, + ["FREE_CODE_IDE_URL"] = GetDefaultIdeUrl(ide) + }, + ["scope"] = "User" + }; + + config["mcpServers"] = mcpServers; + SaveConfig(config); + + var manager = context.Services.GetService(typeof(IMcpClientManager)) as IMcpClientManager; + if (manager is not null) + { + await manager.ReloadAsync().ConfigureAwait(false); + } + + return new CommandResult(true, $"Installed IDE integration for {displayName} in {GetConfigPath()}. Next step: install or enable the {displayName} extension that provides the 'free-code-ide-extension' MCP bridge, then run /ide connect {ide} if you want to override the default endpoint."); + } + + private static string DescribeConnection(MCPServerConnection connection) + { + var status = connection.IsConnected ? "connected" + : connection.IsPending ? "pending" + : connection.IsFailed ? "failed" + : connection.NeedsAuth ? "needs authentication" + : connection.IsDisabled ? "disabled" + : connection.ConnectionType; + + return $"{connection.Name} ({status})"; + } + + private static string? NormalizeIde(string value) + => value.Trim().ToLowerInvariant() switch + { + "vscode" or "vs-code" or "vs_code" or "code" => "vscode", + "jetbrains" or "jb" or "intellij" or "rider" => "jetbrains", + _ => null + }; + + private static string GetDefaultIdeUrl(string ide) + => ide == "jetbrains" + ? "ws://127.0.0.1:63342/free-code" + : "http://127.0.0.1:7777/sse"; + + private static JsonObject LoadConfig() + { + var path = GetConfigPath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + + if (!File.Exists(path)) + { + return []; + } + + return JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? []; + } + + private static void SaveConfig(JsonObject config) + { + var path = GetConfigPath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, config.ToJsonString(JsonOptions)); + } + + private static JsonObject GetObject(JsonObject parent, string name) + { + if (parent[name] is JsonObject value) + { + return value; + } + + var created = new JsonObject(); + parent[name] = created; + return created; + } + + private static List Tokenize(string? args) + => string.IsNullOrWhiteSpace(args) + ? [] + : args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + + private static string GetConfigPath() + => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".free-code", "config.json"); +} diff --git a/src/FreeCode.Commands/InitCommand.cs b/src/FreeCode.Commands/InitCommand.cs new file mode 100644 index 0000000..678c750 --- /dev/null +++ b/src/FreeCode.Commands/InitCommand.cs @@ -0,0 +1,61 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using FreeCode.Core.Enums; +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class InitCommand(IServiceProvider services) : CommandBase +{ + private readonly IServiceProvider _services = services; + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public override string Name => "init"; + public override string Description => "Initialize a new project configuration."; + public override CommandCategory Category => CommandCategory.Local; + + public override async Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + { + var configDirectory = Path.Combine(context.WorkingDirectory, ".free-code"); + var configPath = Path.Combine(configDirectory, "config.json"); + Directory.CreateDirectory(configDirectory); + + JsonObject config; + if (File.Exists(configPath)) + { + config = JsonNode.Parse(await File.ReadAllTextAsync(configPath, ct).ConfigureAwait(false)) as JsonObject ?? []; + } + else + { + config = []; + } + + config["projectName"] ??= Path.GetFileName(context.WorkingDirectory); + config["workingDirectory"] = context.WorkingDirectory; + config["createdAt"] ??= DateTimeOffset.UtcNow.ToString("O"); + config["commands"] ??= new JsonObject(); + config["contextFiles"] ??= new JsonArray(); + config["mcpServers"] ??= new JsonObject(); + config["voiceEnabled"] ??= false; + config["voiceMode"] ??= "push-to-talk"; + + await File.WriteAllTextAsync(configPath, config.ToJsonString(JsonOptions), ct).ConfigureAwait(false); + + var readmePath = Path.Combine(configDirectory, "README.md"); + if (!File.Exists(readmePath)) + { + var markdown = new StringBuilder() + .AppendLine("# free-code project config") + .AppendLine() + .AppendLine($"Initialized for `{Path.GetFileName(context.WorkingDirectory)}`.") + .AppendLine() + .AppendLine("This directory stores project-specific configuration for the .NET CLI port.") + .ToString(); + + await File.WriteAllTextAsync(readmePath, markdown, ct).ConfigureAwait(false); + } + + return new CommandResult(true, $"Initialized project configuration in {configDirectory}."); + } +} diff --git a/src/FreeCode.Commands/InsightsCommand.cs b/src/FreeCode.Commands/InsightsCommand.cs new file mode 100644 index 0000000..6b92333 --- /dev/null +++ b/src/FreeCode.Commands/InsightsCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class InsightsCommand : CommandBase +{ + public override string Name => "insights"; + public override string Description => "Show insights."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/InstallGitHubAppCommand.cs b/src/FreeCode.Commands/InstallGitHubAppCommand.cs new file mode 100644 index 0000000..36cfa2d --- /dev/null +++ b/src/FreeCode.Commands/InstallGitHubAppCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class InstallGitHubAppCommand : CommandBase +{ + public override string Name => "install-github-app"; + public override string Description => "Install the GitHub app."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.RequiresAuth; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/InstallSlackAppCommand.cs b/src/FreeCode.Commands/InstallSlackAppCommand.cs new file mode 100644 index 0000000..6dcbc8a --- /dev/null +++ b/src/FreeCode.Commands/InstallSlackAppCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class InstallSlackAppCommand : CommandBase +{ + public override string Name => "install-slack-app"; + public override string Description => "Install the Slack app."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.RequiresAuth; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/IssueCommand.cs b/src/FreeCode.Commands/IssueCommand.cs new file mode 100644 index 0000000..712bc79 --- /dev/null +++ b/src/FreeCode.Commands/IssueCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class IssueCommand : CommandBase +{ + public override string Name => "issue"; + public override string Description => "Report an issue."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/KeybindingsCommand.cs b/src/FreeCode.Commands/KeybindingsCommand.cs new file mode 100644 index 0000000..4c57e1e --- /dev/null +++ b/src/FreeCode.Commands/KeybindingsCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class KeybindingsCommand : CommandBase +{ + public override string Name => "keybindings"; + public override string Description => "Configure keybindings."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/LoginCommand.cs b/src/FreeCode.Commands/LoginCommand.cs new file mode 100644 index 0000000..f4f244b --- /dev/null +++ b/src/FreeCode.Commands/LoginCommand.cs @@ -0,0 +1,19 @@ +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class LoginCommand(IAuthService authService) : CommandBase +{ + public override string Name => "login"; + public override string Description => "Start the OAuth login flow."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.Always; + + public override async Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + { + var provider = string.IsNullOrWhiteSpace(args) ? "anthropic" : args.Trim(); + await authService.LoginAsync(provider).ConfigureAwait(false); + return new CommandResult(true, $"Login flow started for {provider}."); + } +} diff --git a/src/FreeCode.Commands/LogoutCommand.cs b/src/FreeCode.Commands/LogoutCommand.cs new file mode 100644 index 0000000..35aace8 --- /dev/null +++ b/src/FreeCode.Commands/LogoutCommand.cs @@ -0,0 +1,18 @@ +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class LogoutCommand(IAuthService authService) : CommandBase +{ + public override string Name => "logout"; + public override string Description => "Clear stored authentication tokens."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.RequiresAuth; + + public override async Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + { + await authService.LogoutAsync().ConfigureAwait(false); + return new CommandResult(true, "Logged out."); + } +} diff --git a/src/FreeCode.Commands/McpCommand.cs b/src/FreeCode.Commands/McpCommand.cs new file mode 100644 index 0000000..c691b32 --- /dev/null +++ b/src/FreeCode.Commands/McpCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class McpCommand : CommandBase +{ + public override string Name => "mcp"; + public override string Description => "Manage MCP servers."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/MemoryCommand.cs b/src/FreeCode.Commands/MemoryCommand.cs new file mode 100644 index 0000000..6c957c9 --- /dev/null +++ b/src/FreeCode.Commands/MemoryCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class MemoryCommand : CommandBase +{ + public override string Name => "memory"; + public override string Description => "Manage session memory."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/MobileCommand.cs b/src/FreeCode.Commands/MobileCommand.cs new file mode 100644 index 0000000..074cbf9 --- /dev/null +++ b/src/FreeCode.Commands/MobileCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class MobileCommand : CommandBase +{ + public override string Name => "mobile"; + public override string Description => "Mobile integration."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/MockLimitsCommand.cs b/src/FreeCode.Commands/MockLimitsCommand.cs new file mode 100644 index 0000000..ed2f879 --- /dev/null +++ b/src/FreeCode.Commands/MockLimitsCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class MockLimitsCommand : CommandBase +{ + public override string Name => "mock-limits"; + public override string Description => "Mock API limits."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/ModelCommand.cs b/src/FreeCode.Commands/ModelCommand.cs new file mode 100644 index 0000000..4d9cc8d --- /dev/null +++ b/src/FreeCode.Commands/ModelCommand.cs @@ -0,0 +1,45 @@ +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using FreeCode.State; + +namespace FreeCode.Commands; + +public sealed class ModelCommand : CommandBase +{ + public override string Name => "model"; + public override string Description => "View or set the current model."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + { + var stateStore = context.Services.GetService(typeof(IAppStateStore)) as IAppStateStore; + if (stateStore is null) + { + return Task.FromResult(new CommandResult(false, "State store is unavailable.")); + } + + var model = args?.Trim(); + if (string.IsNullOrWhiteSpace(model)) + { + var state = stateStore.GetState() as AppState; + var current = state?.MainLoopModel ?? state?.MainLoopModelForSession; + var sessionModel = state?.MainLoopModelForSession; + + var output = current is null + ? "No model is currently set." + : sessionModel is null || string.Equals(current, sessionModel, StringComparison.Ordinal) + ? $"Current model: {current}" + : $"Current model: {current}{Environment.NewLine}Session model: {sessionModel}"; + + return Task.FromResult(new CommandResult(true, output)); + } + + stateStore.Update(state => + { + var appState = state as AppState ?? throw new InvalidCastException("Command model updates require AppState."); + return appState with { MainLoopModel = model }; + }); + + return Task.FromResult(new CommandResult(true, $"Model set to {model}.")); + } +} diff --git a/src/FreeCode.Commands/OauthRefreshCommand.cs b/src/FreeCode.Commands/OauthRefreshCommand.cs new file mode 100644 index 0000000..7f5c903 --- /dev/null +++ b/src/FreeCode.Commands/OauthRefreshCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class OauthRefreshCommand : CommandBase +{ + public override string Name => "oauth-refresh"; + public override string Description => "Refresh the OAuth token."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/OnboardingCommand.cs b/src/FreeCode.Commands/OnboardingCommand.cs new file mode 100644 index 0000000..7dca2e5 --- /dev/null +++ b/src/FreeCode.Commands/OnboardingCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class OnboardingCommand : CommandBase +{ + public override string Name => "onboarding"; + public override string Description => "Run onboarding."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/OutputStyleCommand.cs b/src/FreeCode.Commands/OutputStyleCommand.cs new file mode 100644 index 0000000..593b1d8 --- /dev/null +++ b/src/FreeCode.Commands/OutputStyleCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class OutputStyleCommand : CommandBase +{ + public override string Name => "output-style"; + public override string Description => "Set the output rendering style."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/PassesCommand.cs b/src/FreeCode.Commands/PassesCommand.cs new file mode 100644 index 0000000..cde0298 --- /dev/null +++ b/src/FreeCode.Commands/PassesCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class PassesCommand : CommandBase +{ + public override string Name => "passes"; + public override string Description => "Show usage passes and credits."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.RequiresAuth; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/PerfIssueCommand.cs b/src/FreeCode.Commands/PerfIssueCommand.cs new file mode 100644 index 0000000..0b7ccf6 --- /dev/null +++ b/src/FreeCode.Commands/PerfIssueCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class PerfIssueCommand : CommandBase +{ + public override string Name => "perf-issue"; + public override string Description => "Report a performance issue."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/PermissionsCommand.cs b/src/FreeCode.Commands/PermissionsCommand.cs new file mode 100644 index 0000000..c32ffc4 --- /dev/null +++ b/src/FreeCode.Commands/PermissionsCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class PermissionsCommand : CommandBase +{ + public override string Name => "permissions"; + public override string Description => "Manage permissions."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/PlanCommand.cs b/src/FreeCode.Commands/PlanCommand.cs new file mode 100644 index 0000000..c9ec3ba --- /dev/null +++ b/src/FreeCode.Commands/PlanCommand.cs @@ -0,0 +1,116 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FreeCode.Core.Enums; +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class PlanCommand(IServiceProvider services) : CommandBase +{ + private readonly IServiceProvider _services = services; + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public override string Name => "plan"; + public override string Description => "Enter or exit planning mode."; + public override CommandCategory Category => CommandCategory.Local; + + public override async Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + { + var tokens = Tokenize(args); + var subcommand = tokens.Count == 0 ? "enter" : tokens[0].ToLowerInvariant(); + + return subcommand switch + { + "enter" => await EnterAsync(context, tokens, ct).ConfigureAwait(false), + "exit" => await ExitAsync(context, tokens, ct).ConfigureAwait(false), + "status" => GetStatus(), + _ => new CommandResult(false, "Usage: /plan [enter |exit [plan]|status]") + }; + } + + private static async Task EnterAsync(CommandContext context, IReadOnlyList tokens, CancellationToken ct) + { + var reason = tokens.Count > 1 ? string.Join(' ', tokens.Skip(1)) : null; + var config = LoadConfig(); + config["planModeEnabled"] = true; + if (!string.IsNullOrWhiteSpace(reason)) + { + config["planModeReason"] = reason; + } + + await SaveConfigAsync(config, ct).ConfigureAwait(false); + return new CommandResult(true, reason is null ? "Entered plan mode." : $"Entered plan mode: {reason}"); + } + + private static async Task ExitAsync(CommandContext context, IReadOnlyList tokens, CancellationToken ct) + { + var plan = tokens.Count > 1 ? string.Join(' ', tokens.Skip(1)) : null; + var config = LoadConfig(); + config["planModeEnabled"] = false; + + if (string.IsNullOrWhiteSpace(plan)) + { + config.Remove("planModeDraft"); + } + else + { + config["planModeDraft"] = plan; + } + + await SaveConfigAsync(config, ct).ConfigureAwait(false); + return new CommandResult(true, string.IsNullOrWhiteSpace(plan) ? "Exited plan mode." : $"Exited plan mode with plan: {plan}"); + } + + private static CommandResult GetStatus() + { + var config = LoadConfig(); + var enabled = config["planModeEnabled"]?.GetValue() ?? false; + var reason = config["planModeReason"]?.GetValue(); + var draft = config["planModeDraft"]?.GetValue(); + + var lines = new List + { + enabled ? "Plan mode is active." : "Plan mode is inactive." + }; + + if (!string.IsNullOrWhiteSpace(reason)) + { + lines.Add($"Reason: {reason}"); + } + + if (!string.IsNullOrWhiteSpace(draft)) + { + lines.Add($"Draft: {draft}"); + } + + return new CommandResult(true, string.Join(Environment.NewLine, lines)); + } + + private static JsonObject LoadConfig() + { + var path = GetConfigPath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + + if (!File.Exists(path)) + { + return []; + } + + return JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? []; + } + + private static Task SaveConfigAsync(JsonObject config, CancellationToken ct) + { + var path = GetConfigPath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + return File.WriteAllTextAsync(path, config.ToJsonString(JsonOptions), ct); + } + + private static string GetConfigPath() + => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".free-code", "config.json"); + + private static List Tokenize(string? args) + => string.IsNullOrWhiteSpace(args) + ? [] + : args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); +} diff --git a/src/FreeCode.Commands/PluginCommand.cs b/src/FreeCode.Commands/PluginCommand.cs new file mode 100644 index 0000000..8d6f94e --- /dev/null +++ b/src/FreeCode.Commands/PluginCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class PluginCommand : CommandBase +{ + public override string Name => "plugin"; + public override string Description => "Manage plugins."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/PrCommentsCommand.cs b/src/FreeCode.Commands/PrCommentsCommand.cs new file mode 100644 index 0000000..dae6fd3 --- /dev/null +++ b/src/FreeCode.Commands/PrCommentsCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class PrCommentsCommand : CommandBase +{ + public override string Name => "pr-comments"; + public override string Description => "Manage pull request comments."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.RequiresAuth; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/PrivacySettingsCommand.cs b/src/FreeCode.Commands/PrivacySettingsCommand.cs new file mode 100644 index 0000000..74efc38 --- /dev/null +++ b/src/FreeCode.Commands/PrivacySettingsCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class PrivacySettingsCommand : CommandBase +{ + public override string Name => "privacy-settings"; + public override string Description => "Configure privacy settings."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/RateLimitOptionsCommand.cs b/src/FreeCode.Commands/RateLimitOptionsCommand.cs new file mode 100644 index 0000000..d07a1c7 --- /dev/null +++ b/src/FreeCode.Commands/RateLimitOptionsCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class RateLimitOptionsCommand : CommandBase +{ + public override string Name => "rate-limit-options"; + public override string Description => "Configure rate limit settings."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.RequiresAuth; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/ReleaseNotesCommand.cs b/src/FreeCode.Commands/ReleaseNotesCommand.cs new file mode 100644 index 0000000..8cdfdb4 --- /dev/null +++ b/src/FreeCode.Commands/ReleaseNotesCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class ReleaseNotesCommand : CommandBase +{ + public override string Name => "release-notes"; + public override string Description => "Show release notes."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/ReloadPluginsCommand.cs b/src/FreeCode.Commands/ReloadPluginsCommand.cs new file mode 100644 index 0000000..890671d --- /dev/null +++ b/src/FreeCode.Commands/ReloadPluginsCommand.cs @@ -0,0 +1,25 @@ +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class ReloadPluginsCommand : CommandBase +{ + public override string Name => "reload-plugins"; + public override string Description => "Reload all plugins."; + public override CommandCategory Category => CommandCategory.Local; + + public override async Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + { + var pluginManager = context.Services.GetService(typeof(IPluginManager)) as IPluginManager; + if (pluginManager is null) + { + return new CommandResult(false, "Plugin manager is unavailable."); + } + + await pluginManager.LoadPluginsAsync().ConfigureAwait(false); + var count = pluginManager.GetLoadedPlugins().Count; + return new CommandResult(true, $"Reloaded plugins. Loaded {count} plugin{(count == 1 ? string.Empty : "s")}."); + } +} diff --git a/src/FreeCode.Commands/RemoteControlCommand.cs b/src/FreeCode.Commands/RemoteControlCommand.cs new file mode 100644 index 0000000..85805b2 --- /dev/null +++ b/src/FreeCode.Commands/RemoteControlCommand.cs @@ -0,0 +1,77 @@ +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using FreeCode.State; + +namespace FreeCode.Commands; + +public sealed class RemoteControlCommand(IServiceProvider services) : CommandBase +{ + private readonly IServiceProvider _services = services; + public override string Name => "remote-control"; + public override string[]? Aliases => ["rc"]; + public override string Description => "Connect this terminal for remote-control sessions."; + public override CommandCategory Category => CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + { + var manager = context.Services.GetService(typeof(IRemoteSessionManager)) as IRemoteSessionManager; + if (manager is null) + { + return Task.FromResult(new CommandResult(false, "Remote control service is unavailable.")); + } + + var tokens = Tokenize(args); + var subcommand = tokens.Count == 0 ? "status" : tokens[0].ToLowerInvariant(); + + return subcommand switch + { + "start" or "connect" => StartAsync(context, manager, tokens, ct), + "stop" or "disconnect" => StopAsync(context, manager, ct), + "status" => Task.FromResult(GetStatus(context)), + _ => Task.FromResult(new CommandResult(false, "Usage: /remote-control [start |stop|status]")) + }; + } + + private static async Task StartAsync(CommandContext context, IRemoteSessionManager manager, IReadOnlyList tokens, CancellationToken ct) + { + if (tokens.Count < 2) + { + return new CommandResult(false, "Usage: /remote-control start "); + } + + await manager.ConnectAsync(tokens[1], ct).ConfigureAwait(false); + return new CommandResult(true, $"Started remote control session at {tokens[1]}." ); + } + + private static async Task StopAsync(CommandContext context, IRemoteSessionManager manager, CancellationToken ct) + { + await manager.DisconnectAsync(ct).ConfigureAwait(false); + return new CommandResult(true, "Stopped remote control session."); + } + + private static CommandResult GetStatus(CommandContext context) + { + var featureFlags = context.Services.GetService(typeof(IFeatureFlagService)) as IFeatureFlagService; + var stateStore = context.Services.GetService(typeof(IAppStateStore)) as IAppStateStore; + var state = stateStore?.GetState() as AppState; + + var lines = new List + { + $"Remote control feature: {(featureFlags?.IsEnabled("REMOTE_CONTROL") == true ? "enabled" : "disabled")}", + $"Connection status: {state?.RemoteConnectionStatus ?? RemoteConnectionStatus.Disconnected}" + }; + + if (!string.IsNullOrWhiteSpace(state?.RemoteSessionUrl)) + { + lines.Add($"Session URL: {state.RemoteSessionUrl}"); + } + + return new CommandResult(true, string.Join(Environment.NewLine, lines)); + } + + private static List Tokenize(string? args) + => string.IsNullOrWhiteSpace(args) + ? [] + : args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); +} diff --git a/src/FreeCode.Commands/RemoteEnvCommand.cs b/src/FreeCode.Commands/RemoteEnvCommand.cs new file mode 100644 index 0000000..c04814a --- /dev/null +++ b/src/FreeCode.Commands/RemoteEnvCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class RemoteEnvCommand : CommandBase +{ + public override string Name => "remote-env"; + public override string Description => "Connect to a remote environment."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.RequiresAuth; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/RenameCommand.cs b/src/FreeCode.Commands/RenameCommand.cs new file mode 100644 index 0000000..584f4fb --- /dev/null +++ b/src/FreeCode.Commands/RenameCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class RenameCommand : CommandBase +{ + public override string Name => "rename"; + public override string Description => "Rename the current session."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/ResetLimitsCommand.cs b/src/FreeCode.Commands/ResetLimitsCommand.cs new file mode 100644 index 0000000..a1fe829 --- /dev/null +++ b/src/FreeCode.Commands/ResetLimitsCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class ResetLimitsCommand : CommandBase +{ + public override string Name => "reset-limits"; + public override string Description => "Reset rate limits."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/ResumeCommand.cs b/src/FreeCode.Commands/ResumeCommand.cs new file mode 100644 index 0000000..794288e --- /dev/null +++ b/src/FreeCode.Commands/ResumeCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class ResumeCommand : CommandBase +{ + public override string Name => "resume"; + public override string Description => "Resume a previous session."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/ReviewCommand.cs b/src/FreeCode.Commands/ReviewCommand.cs new file mode 100644 index 0000000..20bcd72 --- /dev/null +++ b/src/FreeCode.Commands/ReviewCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class ReviewCommand : CommandBase +{ + public override string Name => "review"; + public override string Description => "Review the code."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Prompt; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/RewindCommand.cs b/src/FreeCode.Commands/RewindCommand.cs new file mode 100644 index 0000000..449650e --- /dev/null +++ b/src/FreeCode.Commands/RewindCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class RewindCommand : CommandBase +{ + public override string Name => "rewind"; + public override string Description => "Rewind the conversation."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/SandboxToggleCommand.cs b/src/FreeCode.Commands/SandboxToggleCommand.cs new file mode 100644 index 0000000..df2a265 --- /dev/null +++ b/src/FreeCode.Commands/SandboxToggleCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class SandboxToggleCommand : CommandBase +{ + public override string Name => "sandbox-toggle"; + public override string Description => "Toggle sandbox mode."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/SecurityReviewCommand.cs b/src/FreeCode.Commands/SecurityReviewCommand.cs new file mode 100644 index 0000000..7924446 --- /dev/null +++ b/src/FreeCode.Commands/SecurityReviewCommand.cs @@ -0,0 +1,77 @@ +using System.Diagnostics; +using FreeCode.Core.Enums; +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class SecurityReviewCommand : CommandBase +{ + public override string Name => "security-review"; + public override string Description => "Run security code review on current changes."; + public override CommandCategory Category => CommandCategory.Local; + + public override async Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + { + var stagedResult = await RunProcessAsync("git", "diff --staged", context.WorkingDirectory, ct).ConfigureAwait(false); + if (stagedResult.ExitCode != 0) + { + return CreateFailure(stagedResult); + } + + var unstagedResult = await RunProcessAsync("git", "diff", context.WorkingDirectory, ct).ConfigureAwait(false); + if (unstagedResult.ExitCode != 0) + { + return CreateFailure(unstagedResult); + } + + var statsResult = await RunProcessAsync("git", "diff --stat", context.WorkingDirectory, ct).ConfigureAwait(false); + if (statsResult.ExitCode != 0) + { + return CreateFailure(statsResult); + } + + var combinedDiff = string.Concat( + stagedResult.StdOut, + string.IsNullOrWhiteSpace(stagedResult.StdOut) || string.IsNullOrWhiteSpace(unstagedResult.StdOut) ? string.Empty : Environment.NewLine, + unstagedResult.StdOut); + + var prompt = "Perform a security code review of these changes. Look for: injection attacks, authentication bypasses, crypto issues, data exposure, privilege escalation. Rate each finding as CRITICAL/HIGH/MEDIUM/LOW. Suggest fixes." + + Environment.NewLine + Environment.NewLine + + $"Files changed:{Environment.NewLine}{statsResult.StdOut}" + + Environment.NewLine + + $"Diff:{Environment.NewLine}{combinedDiff}"; + + return new CommandResult(true, prompt); + } + + private static async Task<(int ExitCode, string StdOut, string StdErr)> RunProcessAsync(string fileName, string arguments, string workingDirectory, CancellationToken ct) + { + var startInfo = new ProcessStartInfo(fileName, arguments) + { + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = new Process { StartInfo = startInfo }; + if (!process.Start()) + { + return (-1, string.Empty, $"Failed to start {fileName}."); + } + + var stdoutTask = process.StandardOutput.ReadToEndAsync(ct); + var stderrTask = process.StandardError.ReadToEndAsync(ct); + await process.WaitForExitAsync(ct).ConfigureAwait(false); + var stdout = await stdoutTask.ConfigureAwait(false); + var stderr = await stderrTask.ConfigureAwait(false); + return (process.ExitCode, stdout, stderr); + } + + private static CommandResult CreateFailure((int ExitCode, string StdOut, string StdErr) result) + { + var message = string.IsNullOrWhiteSpace(result.StdErr) ? result.StdOut : result.StdErr; + return new CommandResult(false, string.IsNullOrWhiteSpace(message) ? "Failed to inspect git changes." : message.Trim()); + } +} diff --git a/src/FreeCode.Commands/ServiceCollectionExtensions.cs b/src/FreeCode.Commands/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..6243eda --- /dev/null +++ b/src/FreeCode.Commands/ServiceCollectionExtensions.cs @@ -0,0 +1,111 @@ +using FreeCode.Core.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode.Commands; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddCommands(this IServiceCollection services) + { + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/FreeCode.Commands/SessionCommand.cs b/src/FreeCode.Commands/SessionCommand.cs new file mode 100644 index 0000000..be78231 --- /dev/null +++ b/src/FreeCode.Commands/SessionCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class SessionCommand : CommandBase +{ + public override string Name => "session"; + public override string Description => "List and manage sessions."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/ShareCommand.cs b/src/FreeCode.Commands/ShareCommand.cs new file mode 100644 index 0000000..f01ca84 --- /dev/null +++ b/src/FreeCode.Commands/ShareCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class ShareCommand : CommandBase +{ + public override string Name => "share"; + public override string Description => "Share the conversation."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.RequiresAuth; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/SkillsCommand.cs b/src/FreeCode.Commands/SkillsCommand.cs new file mode 100644 index 0000000..e8453ac --- /dev/null +++ b/src/FreeCode.Commands/SkillsCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class SkillsCommand : CommandBase +{ + public override string Name => "skills"; + public override string Description => "Manage skills."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/StatsCommand.cs b/src/FreeCode.Commands/StatsCommand.cs new file mode 100644 index 0000000..6966475 --- /dev/null +++ b/src/FreeCode.Commands/StatsCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class StatsCommand : CommandBase +{ + public override string Name => "stats"; + public override string Description => "Show usage statistics."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.RequiresAuth; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/StatusCommand.cs b/src/FreeCode.Commands/StatusCommand.cs new file mode 100644 index 0000000..a49d6ca --- /dev/null +++ b/src/FreeCode.Commands/StatusCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class StatusCommand : CommandBase +{ + public override string Name => "status"; + public override string Description => "Show the current status."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.RequiresAuth; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/StatusLineCommand.cs b/src/FreeCode.Commands/StatusLineCommand.cs new file mode 100644 index 0000000..6f1957f --- /dev/null +++ b/src/FreeCode.Commands/StatusLineCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class StatusLineCommand : CommandBase +{ + public override string Name => "status-line"; + public override string Description => "Configure the status line."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/StickersCommand.cs b/src/FreeCode.Commands/StickersCommand.cs new file mode 100644 index 0000000..3798f46 --- /dev/null +++ b/src/FreeCode.Commands/StickersCommand.cs @@ -0,0 +1,15 @@ +using FreeCode.Core.Enums; +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class StickersCommand : CommandBase +{ + public override string Name => "stickers"; + public override string Description => "Get free-code stickers."; + public override CommandCategory Category => CommandCategory.Local; + public override CommandAvailability Availability => CommandAvailability.Always; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => Task.FromResult(new CommandResult(true, "Get free-code stickers at https://github.com/free-code/stickers")); +} diff --git a/src/FreeCode.Commands/SummaryCommand.cs b/src/FreeCode.Commands/SummaryCommand.cs new file mode 100644 index 0000000..de80bb2 --- /dev/null +++ b/src/FreeCode.Commands/SummaryCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class SummaryCommand : CommandBase +{ + public override string Name => "summary"; + public override string Description => "Summarize the conversation."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Prompt; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/TagCommand.cs b/src/FreeCode.Commands/TagCommand.cs new file mode 100644 index 0000000..096605f --- /dev/null +++ b/src/FreeCode.Commands/TagCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class TagCommand : CommandBase +{ + public override string Name => "tag"; + public override string Description => "Manage session tags."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/TasksCommand.cs b/src/FreeCode.Commands/TasksCommand.cs new file mode 100644 index 0000000..fcad458 --- /dev/null +++ b/src/FreeCode.Commands/TasksCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class TasksCommand : CommandBase +{ + public override string Name => "tasks"; + public override string Description => "Manage background tasks."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/TeleportCommand.cs b/src/FreeCode.Commands/TeleportCommand.cs new file mode 100644 index 0000000..aaacd83 --- /dev/null +++ b/src/FreeCode.Commands/TeleportCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class TeleportCommand : CommandBase +{ + public override string Name => "teleport"; + public override string Description => "Switch conversation context."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/TerminalSetupCommand.cs b/src/FreeCode.Commands/TerminalSetupCommand.cs new file mode 100644 index 0000000..524cc70 --- /dev/null +++ b/src/FreeCode.Commands/TerminalSetupCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class TerminalSetupCommand : CommandBase +{ + public override string Name => "terminal-setup"; + public override string Description => "Configure terminal capabilities."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/ThemeCommand.cs b/src/FreeCode.Commands/ThemeCommand.cs new file mode 100644 index 0000000..8ded653 --- /dev/null +++ b/src/FreeCode.Commands/ThemeCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class ThemeCommand : CommandBase +{ + public override string Name => "theme"; + public override string Description => "Set the UI theme."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/ThinkbackCommand.cs b/src/FreeCode.Commands/ThinkbackCommand.cs new file mode 100644 index 0000000..dee8be5 --- /dev/null +++ b/src/FreeCode.Commands/ThinkbackCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class ThinkbackCommand : CommandBase +{ + public override string Name => "thinkback"; + public override string Description => "Recall previous thoughts."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/ThinkbackPlayCommand.cs b/src/FreeCode.Commands/ThinkbackPlayCommand.cs new file mode 100644 index 0000000..98496ad --- /dev/null +++ b/src/FreeCode.Commands/ThinkbackPlayCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class ThinkbackPlayCommand : CommandBase +{ + public override string Name => "thinkback-play"; + public override string Description => "Replay the thinking process."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/UltraplanCommand.cs b/src/FreeCode.Commands/UltraplanCommand.cs new file mode 100644 index 0000000..8eb6205 --- /dev/null +++ b/src/FreeCode.Commands/UltraplanCommand.cs @@ -0,0 +1,16 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class UltraplanCommand : CommandBase +{ + public override string Name => "ultraplan"; + public override string Description => "Advanced multi-agent planning."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Prompt; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.RequiresAuth; + + public override bool IsEnabled() => true; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/UltrareviewCommand.cs b/src/FreeCode.Commands/UltrareviewCommand.cs new file mode 100644 index 0000000..7f46370 --- /dev/null +++ b/src/FreeCode.Commands/UltrareviewCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class UltrareviewCommand : CommandBase +{ + public override string Name => "ultrareview"; + public override string Description => "Deep code review."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Prompt; + public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.RequiresAuth; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/UpgradeCommand.cs b/src/FreeCode.Commands/UpgradeCommand.cs new file mode 100644 index 0000000..d5a78b9 --- /dev/null +++ b/src/FreeCode.Commands/UpgradeCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class UpgradeCommand : CommandBase +{ + public override string Name => "upgrade"; + public override string Description => "Check for updates."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/UsageCommand.cs b/src/FreeCode.Commands/UsageCommand.cs new file mode 100644 index 0000000..e3033c1 --- /dev/null +++ b/src/FreeCode.Commands/UsageCommand.cs @@ -0,0 +1,37 @@ +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class UsageCommand : CommandBase +{ + public override string Name => "usage"; + public override string Description => "Show current token usage and plan limits."; + public override CommandCategory Category => CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + { + var queryEngine = context.Services.GetService(typeof(IQueryEngine)) as IQueryEngine; + if (queryEngine is null) + { + return Task.FromResult(new CommandResult(false, "Query engine is unavailable.")); + } + + var usage = queryEngine.GetCurrentUsage(); + var estimatedCost = usage.InputTokens * 0.000003m + + usage.OutputTokens * 0.000015m + + usage.CacheCreationTokens * 0.000005m + + usage.CacheReadTokens * 0.000001m; + + var output = string.Join(Environment.NewLine, + "Current token usage:", + $"Input tokens: {usage.InputTokens}", + $"Output tokens: {usage.OutputTokens}", + $"Cache creation: {usage.CacheCreationTokens}", + $"Cache read: {usage.CacheReadTokens}", + $"Estimated cost: ${estimatedCost:F4}"); + + return Task.FromResult(new CommandResult(true, output)); + } +} diff --git a/src/FreeCode.Commands/VersionCommand.cs b/src/FreeCode.Commands/VersionCommand.cs new file mode 100644 index 0000000..8ffcd14 --- /dev/null +++ b/src/FreeCode.Commands/VersionCommand.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class VersionCommand : CommandBase +{ + public override string Name => "version"; + public override string[]? Aliases => new[] { "v" }; + public override string Description => "Show the version."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/VimCommand.cs b/src/FreeCode.Commands/VimCommand.cs new file mode 100644 index 0000000..3b663cb --- /dev/null +++ b/src/FreeCode.Commands/VimCommand.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class VimCommand : CommandBase +{ + public override string Name => "vim"; + public override string Description => "Toggle vim mode."; + public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + => CommandExecutionHelper.ExecuteAsync(Name, context, args, ct); +} diff --git a/src/FreeCode.Commands/VoiceCommand.cs b/src/FreeCode.Commands/VoiceCommand.cs new file mode 100644 index 0000000..72205e5 --- /dev/null +++ b/src/FreeCode.Commands/VoiceCommand.cs @@ -0,0 +1,139 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Commands; + +public sealed class VoiceCommand(IServiceProvider services) : CommandBase +{ + private readonly IServiceProvider _services = services; + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public override string Name => "voice"; + public override string Description => "Toggle voice mode and manage voice settings."; + public override CommandCategory Category => CommandCategory.Local; + public override CommandAvailability Availability => CommandAvailability.ClaudeAiOnly; + + public override Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + { + var voiceService = context.Services.GetService(typeof(IVoiceService)) as IVoiceService; + if (voiceService is null) + { + return Task.FromResult(new CommandResult(false, "Voice service is unavailable.")); + } + + var tokens = Tokenize(args); + var subcommand = tokens.Count == 0 ? "toggle" : tokens[0].ToLowerInvariant(); + + return subcommand switch + { + "on" or "enable" => SetVoiceEnabledAsync(context, voiceService, true, ct), + "off" or "disable" => SetVoiceEnabledAsync(context, voiceService, false, ct), + "toggle" => ToggleVoiceAsync(context, voiceService, ct), + "status" => Task.FromResult(GetStatus(context, voiceService)), + "dictation" => SetMode(context, tokens, "dictation"), + "push-to-talk" or "ptt" => SetMode(context, tokens, "push-to-talk"), + "microphone" or "mic" => ConfigureMicrophone(context, tokens), + _ => Task.FromResult(new CommandResult(false, "Usage: /voice [on|off|toggle|status|dictation|push-to-talk|microphone ]")) + }; + } + + private static async Task ToggleVoiceAsync(CommandContext context, IVoiceService voiceService, CancellationToken ct) + { + var config = LoadConfig(); + var enabled = config["voiceEnabled"]?.GetValue() ?? false; + return await SetVoiceEnabledAsync(context, voiceService, !enabled, ct).ConfigureAwait(false); + } + + private static async Task SetVoiceEnabledAsync(CommandContext context, IVoiceService voiceService, bool enabled, CancellationToken ct) + { + if (enabled) + { + await voiceService.StartAsync(ct).ConfigureAwait(false); + } + else + { + await voiceService.StopAsync(ct).ConfigureAwait(false); + } + + var config = LoadConfig(); + config["voiceEnabled"] = enabled; + config["voiceMode"] ??= "push-to-talk"; + SaveConfig(config); + return new CommandResult(true, enabled ? "Voice mode enabled." : "Voice mode disabled."); + } + + private static CommandResult GetStatus(CommandContext context, IVoiceService voiceService) + { + var config = LoadConfig(); + var featureFlags = context.Services.GetService(typeof(IFeatureFlagService)) as IFeatureFlagService; + var enabled = config["voiceEnabled"]?.GetValue() ?? false; + var mode = config["voiceMode"]?.GetValue() ?? "push-to-talk"; + var microphone = config["voiceMicrophone"]?.GetValue() ?? "default"; + + var lines = new List + { + $"Voice feature: {(featureFlags?.IsEnabled("VOICE_MODE") == true ? "enabled" : "disabled")}", + $"Voice mode: {(enabled ? "on" : "off")}", + $"Input style: {mode}", + $"Microphone: {microphone}", + "Recorder available: managed by runtime voice service" + }; + + return new CommandResult(true, string.Join(Environment.NewLine, lines)); + } + + private static Task SetMode(CommandContext context, IReadOnlyList tokens, string mode) + { + var config = LoadConfig(); + config["voiceMode"] = mode; + config["voiceEnabled"] = true; + SaveConfig(config); + return Task.FromResult(new CommandResult(true, $"Voice mode set to {mode}.")); + } + + private static Task ConfigureMicrophone(CommandContext context, IReadOnlyList tokens) + { + if (tokens.Count < 2) + { + var config = LoadConfig(); + var current = config["voiceMicrophone"]?.GetValue() ?? "default"; + return Task.FromResult(new CommandResult(true, $"Current microphone: {current}")); + } + + var configToUpdate = LoadConfig(); + configToUpdate["voiceMicrophone"] = string.Join(' ', tokens.Skip(1)); + SaveConfig(configToUpdate); + return Task.FromResult(new CommandResult(true, $"Microphone set to {string.Join(' ', tokens.Skip(1))}.")); + } + + private static JsonObject LoadConfig() + { + var path = GetConfigPath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + + if (!File.Exists(path)) + { + return []; + } + + return JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? []; + } + + private static void SaveConfig(JsonObject config) + { + var path = GetConfigPath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, config.ToJsonString(JsonOptions)); + } + + private static List Tokenize(string? args) + => string.IsNullOrWhiteSpace(args) + ? [] + : args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + + private static string GetConfigPath() + => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".free-code", "config.json"); +} diff --git a/src/FreeCode.Core/Enums/ApiProviderType.cs b/src/FreeCode.Core/Enums/ApiProviderType.cs new file mode 100644 index 0000000..61c4945 --- /dev/null +++ b/src/FreeCode.Core/Enums/ApiProviderType.cs @@ -0,0 +1,10 @@ +namespace FreeCode.Core.Enums; + +public enum ApiProviderType +{ + Anthropic, + OpenAICodex, + AwsBedrock, + GoogleVertex, + AnthropicFoundry +} diff --git a/src/FreeCode.Core/Enums/BackgroundTaskType.cs b/src/FreeCode.Core/Enums/BackgroundTaskType.cs new file mode 100644 index 0000000..c2c78a8 --- /dev/null +++ b/src/FreeCode.Core/Enums/BackgroundTaskType.cs @@ -0,0 +1,12 @@ +namespace FreeCode.Core.Enums; + +public enum BackgroundTaskType +{ + LocalShell, + LocalAgent, + RemoteAgent, + InProcessTeammate, + LocalWorkflow, + MonitorMcp, + Dream +} diff --git a/src/FreeCode.Core/Enums/BridgeStatus.cs b/src/FreeCode.Core/Enums/BridgeStatus.cs new file mode 100644 index 0000000..5250a62 --- /dev/null +++ b/src/FreeCode.Core/Enums/BridgeStatus.cs @@ -0,0 +1,8 @@ +namespace FreeCode.Core.Enums; + +public enum BridgeStatus +{ + Idle, + Registered, + Attached +} diff --git a/src/FreeCode.Core/Enums/CommandAvailability.cs b/src/FreeCode.Core/Enums/CommandAvailability.cs new file mode 100644 index 0000000..f35f17b --- /dev/null +++ b/src/FreeCode.Core/Enums/CommandAvailability.cs @@ -0,0 +1,9 @@ +namespace FreeCode.Core.Enums; + +public enum CommandAvailability +{ + Always, + RequiresAuth, + ClaudeAiOnly, + InternalOnly +} diff --git a/src/FreeCode.Core/Enums/CommandCategory.cs b/src/FreeCode.Core/Enums/CommandCategory.cs new file mode 100644 index 0000000..56c68d7 --- /dev/null +++ b/src/FreeCode.Core/Enums/CommandCategory.cs @@ -0,0 +1,8 @@ +namespace FreeCode.Core.Enums; + +public enum CommandCategory +{ + Prompt, + Local, + LocalDialog +} diff --git a/src/FreeCode.Core/Enums/CompanionEnums.cs b/src/FreeCode.Core/Enums/CompanionEnums.cs new file mode 100644 index 0000000..602fa40 --- /dev/null +++ b/src/FreeCode.Core/Enums/CompanionEnums.cs @@ -0,0 +1,6 @@ +namespace FreeCode.Core.Enums; + +public enum Species { Cat, Dog, Fox, Owl } +public enum Eye { Blue, Green, Gold, Red } +public enum Hat { None, Bowler, Cap, Crown } +public enum Rarity { Common, Uncommon, Rare, Epic, Legendary } diff --git a/src/FreeCode.Core/Enums/ConfigScope.cs b/src/FreeCode.Core/Enums/ConfigScope.cs new file mode 100644 index 0000000..d3b1ced --- /dev/null +++ b/src/FreeCode.Core/Enums/ConfigScope.cs @@ -0,0 +1,12 @@ +namespace FreeCode.Core.Enums; + +public enum ConfigScope +{ + Local, + User, + Project, + Dynamic, + Enterprise, + ClaudeAi, + Managed +} diff --git a/src/FreeCode.Core/Enums/ExpandedView.cs b/src/FreeCode.Core/Enums/ExpandedView.cs new file mode 100644 index 0000000..ca74b25 --- /dev/null +++ b/src/FreeCode.Core/Enums/ExpandedView.cs @@ -0,0 +1,8 @@ +namespace FreeCode.Core.Enums; + +public enum ExpandedView +{ + None, + Diff, + ToolResult +} diff --git a/src/FreeCode.Core/Enums/LspServerState.cs b/src/FreeCode.Core/Enums/LspServerState.cs new file mode 100644 index 0000000..97beaa4 --- /dev/null +++ b/src/FreeCode.Core/Enums/LspServerState.cs @@ -0,0 +1,9 @@ +namespace FreeCode.Core.Enums; + +public enum LspServerState +{ + Stopped, + Starting, + Running, + Error +} diff --git a/src/FreeCode.Core/Enums/MessageRole.cs b/src/FreeCode.Core/Enums/MessageRole.cs new file mode 100644 index 0000000..1878588 --- /dev/null +++ b/src/FreeCode.Core/Enums/MessageRole.cs @@ -0,0 +1,9 @@ +namespace FreeCode.Core.Enums; + +public enum MessageRole +{ + User, + Assistant, + System, + Tool +} diff --git a/src/FreeCode.Core/Enums/PermissionMode.cs b/src/FreeCode.Core/Enums/PermissionMode.cs new file mode 100644 index 0000000..50d9027 --- /dev/null +++ b/src/FreeCode.Core/Enums/PermissionMode.cs @@ -0,0 +1,9 @@ +namespace FreeCode.Core.Enums; + +public enum PermissionMode +{ + Default, + Plan, + AutoAccept, + BypassPermissions +} diff --git a/src/FreeCode.Core/Enums/RemoteConnectionStatus.cs b/src/FreeCode.Core/Enums/RemoteConnectionStatus.cs new file mode 100644 index 0000000..6b99182 --- /dev/null +++ b/src/FreeCode.Core/Enums/RemoteConnectionStatus.cs @@ -0,0 +1,10 @@ +namespace FreeCode.Core.Enums; + +public enum RemoteConnectionStatus +{ + Disconnected, + Connecting, + Connected, + Reconnecting, + Failed +} diff --git a/src/FreeCode.Core/Enums/SessionMode.cs b/src/FreeCode.Core/Enums/SessionMode.cs new file mode 100644 index 0000000..87c93f1 --- /dev/null +++ b/src/FreeCode.Core/Enums/SessionMode.cs @@ -0,0 +1,7 @@ +namespace FreeCode.Core.Enums; + +public enum SessionMode +{ + Normal, + Coordinator +} diff --git a/src/FreeCode.Core/Enums/SpawnMode.cs b/src/FreeCode.Core/Enums/SpawnMode.cs new file mode 100644 index 0000000..ed3a83e --- /dev/null +++ b/src/FreeCode.Core/Enums/SpawnMode.cs @@ -0,0 +1,8 @@ +namespace FreeCode.Core.Enums; + +public enum SpawnMode +{ + SingleSession, + Worktree, + SameDir +} diff --git a/src/FreeCode.Core/Enums/TaskStatus.cs b/src/FreeCode.Core/Enums/TaskStatus.cs new file mode 100644 index 0000000..9661278 --- /dev/null +++ b/src/FreeCode.Core/Enums/TaskStatus.cs @@ -0,0 +1,10 @@ +namespace FreeCode.Core.Enums; + +public enum TaskStatus +{ + Pending, + Running, + Completed, + Failed, + Stopped +} diff --git a/src/FreeCode.Core/Enums/ToolCategory.cs b/src/FreeCode.Core/Enums/ToolCategory.cs new file mode 100644 index 0000000..54d9f88 --- /dev/null +++ b/src/FreeCode.Core/Enums/ToolCategory.cs @@ -0,0 +1,18 @@ +namespace FreeCode.Core.Enums; + +public enum ToolCategory +{ + FileSystem, + Shell, + Agent, + Web, + Lsp, + Mcp, + UserInteraction, + Todo, + Task, + PlanMode, + AgentSwarm, + Worktree, + Config +} diff --git a/src/FreeCode.Core/Enums/ViewSelectionMode.cs b/src/FreeCode.Core/Enums/ViewSelectionMode.cs new file mode 100644 index 0000000..6cfa3c6 --- /dev/null +++ b/src/FreeCode.Core/Enums/ViewSelectionMode.cs @@ -0,0 +1,9 @@ +namespace FreeCode.Core.Enums; + +public enum ViewSelectionMode +{ + None, + Messages, + Tools, + Commands +} diff --git a/src/FreeCode.Core/Enums/WorkerStatus.cs b/src/FreeCode.Core/Enums/WorkerStatus.cs new file mode 100644 index 0000000..2e46055 --- /dev/null +++ b/src/FreeCode.Core/Enums/WorkerStatus.cs @@ -0,0 +1,11 @@ +namespace FreeCode.Core.Enums; + +public enum WorkerStatus +{ + Created, + Running, + Waiting, + Completed, + Failed, + Stopped +} diff --git a/src/FreeCode.Core/FreeCode.Core.csproj b/src/FreeCode.Core/FreeCode.Core.csproj new file mode 100644 index 0000000..8552bda --- /dev/null +++ b/src/FreeCode.Core/FreeCode.Core.csproj @@ -0,0 +1,5 @@ + + + FreeCode.Core + + diff --git a/src/FreeCode.Core/Interfaces/IApiProvider.cs b/src/FreeCode.Core/Interfaces/IApiProvider.cs new file mode 100644 index 0000000..5bf04a2 --- /dev/null +++ b/src/FreeCode.Core/Interfaces/IApiProvider.cs @@ -0,0 +1,8 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Core.Interfaces; + +public interface IApiProvider +{ + IAsyncEnumerable StreamAsync(ApiRequest request, CancellationToken ct = default); +} diff --git a/src/FreeCode.Core/Interfaces/IApiProviderRouter.cs b/src/FreeCode.Core/Interfaces/IApiProviderRouter.cs new file mode 100644 index 0000000..6947642 --- /dev/null +++ b/src/FreeCode.Core/Interfaces/IApiProviderRouter.cs @@ -0,0 +1,6 @@ +namespace FreeCode.Core.Interfaces; + +public interface IApiProviderRouter +{ + IApiProvider GetActiveProvider(); +} diff --git a/src/FreeCode.Core/Interfaces/IAppRunner.cs b/src/FreeCode.Core/Interfaces/IAppRunner.cs new file mode 100644 index 0000000..e479544 --- /dev/null +++ b/src/FreeCode.Core/Interfaces/IAppRunner.cs @@ -0,0 +1,6 @@ +namespace FreeCode.Core.Interfaces; + +public interface IAppRunner +{ + Task RunAsync(CancellationToken ct = default); +} diff --git a/src/FreeCode.Core/Interfaces/IAppStateStore.cs b/src/FreeCode.Core/Interfaces/IAppStateStore.cs new file mode 100644 index 0000000..0a93b4a --- /dev/null +++ b/src/FreeCode.Core/Interfaces/IAppStateStore.cs @@ -0,0 +1,11 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Core.Interfaces; + +public interface IAppStateStore +{ + object GetState(); + void Update(Func updater); + IDisposable Subscribe(Action listener); + event EventHandler? StateChanged; +} diff --git a/src/FreeCode.Core/Interfaces/IAuthService.cs b/src/FreeCode.Core/Interfaces/IAuthService.cs new file mode 100644 index 0000000..5b43d59 --- /dev/null +++ b/src/FreeCode.Core/Interfaces/IAuthService.cs @@ -0,0 +1,12 @@ +namespace FreeCode.Core.Interfaces; + +public interface IAuthService +{ + bool IsAuthenticated { get; } + bool IsClaudeAiUser { get; } + bool IsInternalUser { get; } + Task LoginAsync(string provider = "anthropic"); + Task LogoutAsync(); + Task GetOAuthTokenAsync(); + event EventHandler? AuthStateChanged; +} diff --git a/src/FreeCode.Core/Interfaces/IBackgroundTaskManager.cs b/src/FreeCode.Core/Interfaces/IBackgroundTaskManager.cs new file mode 100644 index 0000000..6277808 --- /dev/null +++ b/src/FreeCode.Core/Interfaces/IBackgroundTaskManager.cs @@ -0,0 +1,17 @@ +using System.Diagnostics; +using FreeCode.Core.Models; + +namespace FreeCode.Core.Interfaces; + +public interface IBackgroundTaskManager +{ + Task CreateShellTaskAsync(string command, ProcessStartInfo psi); + Task CreateAgentTaskAsync(string prompt, string? agentType, string? model); + Task CreateRemoteAgentTaskAsync(string sessionUrl); + Task CreateDreamTaskAsync(string triggerReason); + Task StopTaskAsync(string taskId); + Task GetTaskOutputAsync(string taskId); + IReadOnlyList ListTasks(); + BackgroundTask? GetTask(string taskId); + event EventHandler? TaskStateChanged; +} diff --git a/src/FreeCode.Core/Interfaces/IBridgeService.cs b/src/FreeCode.Core/Interfaces/IBridgeService.cs new file mode 100644 index 0000000..9814547 --- /dev/null +++ b/src/FreeCode.Core/Interfaces/IBridgeService.cs @@ -0,0 +1,17 @@ +using FreeCode.Core.Enums; +using FreeCode.Core.Models; + +namespace FreeCode.Core.Interfaces; + +public interface IBridgeService +{ + Task RegisterEnvironmentAsync(); + Task PollForWorkAsync(CancellationToken ct); + Task SpawnSessionAsync(SessionSpawnOptions options); + Task AcknowledgeWorkAsync(string workId, string sessionToken); + Task SendPermissionResponseAsync(string sessionId, PermissionResponse response); + Task HeartbeatAsync(string workId, string sessionToken); + Task StopWorkAsync(string workId); + Task DeregisterEnvironmentAsync(); + BridgeStatus Status { get; } +} diff --git a/src/FreeCode.Core/Interfaces/ICommand.cs b/src/FreeCode.Core/Interfaces/ICommand.cs new file mode 100644 index 0000000..39082f2 --- /dev/null +++ b/src/FreeCode.Core/Interfaces/ICommand.cs @@ -0,0 +1,15 @@ +using FreeCode.Core.Enums; +using FreeCode.Core.Models; + +namespace FreeCode.Core.Interfaces; + +public interface ICommand +{ + string Name { get; } + string[]? Aliases { get; } + string Description { get; } + CommandCategory Category { get; } + CommandAvailability Availability { get; } + bool IsEnabled(); + Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default); +} diff --git a/src/FreeCode.Core/Interfaces/ICommandRegistry.cs b/src/FreeCode.Core/Interfaces/ICommandRegistry.cs new file mode 100644 index 0000000..17e9664 --- /dev/null +++ b/src/FreeCode.Core/Interfaces/ICommandRegistry.cs @@ -0,0 +1,7 @@ +namespace FreeCode.Core.Interfaces; + +public interface ICommandRegistry +{ + Task> GetCommandsAsync(); + Task> GetEnabledCommandsAsync(); +} diff --git a/src/FreeCode.Core/Interfaces/ICompanionService.cs b/src/FreeCode.Core/Interfaces/ICompanionService.cs new file mode 100644 index 0000000..dc6f43d --- /dev/null +++ b/src/FreeCode.Core/Interfaces/ICompanionService.cs @@ -0,0 +1,8 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Core.Interfaces; + +public interface ICompanionService +{ + Companion Create(string seed); +} diff --git a/src/FreeCode.Core/Interfaces/ICoordinatorService.cs b/src/FreeCode.Core/Interfaces/ICoordinatorService.cs new file mode 100644 index 0000000..45966f0 --- /dev/null +++ b/src/FreeCode.Core/Interfaces/ICoordinatorService.cs @@ -0,0 +1,19 @@ +using FreeCode.Core.Enums; +using FreeCode.Core.Models; + +namespace FreeCode.Core.Interfaces; + +public interface ICoordinatorService +{ + bool IsCoordinatorMode { get; } + string? MatchSessionMode(SessionMode? sessionMode); + string BuildCoordinatorSystemPrompt(CoordinatorPromptContext context); + CoordinatorUserContext BuildWorkerContext(IReadOnlyList mcpClients, string? scratchpadDirectory = null); + Task SpawnWorkerAsync(SpawnWorkerRequest request, CancellationToken ct = default); + Task SendMessageAsync(string workerId, string message, CancellationToken ct = default); + Task StopWorkerAsync(string workerId, CancellationToken ct = default); + Task GetWorkerResultAsync(string workerId); + Task CreateTeamAsync(CreateTeamRequest request, CancellationToken ct = default); + Task DeleteTeamAsync(string teamId, CancellationToken ct = default); + Task SendTeamMessageAsync(string teamId, string message, CancellationToken ct = default); +} diff --git a/src/FreeCode.Core/Interfaces/IFeatureFlagService.cs b/src/FreeCode.Core/Interfaces/IFeatureFlagService.cs new file mode 100644 index 0000000..b5e7c8d --- /dev/null +++ b/src/FreeCode.Core/Interfaces/IFeatureFlagService.cs @@ -0,0 +1,7 @@ +namespace FreeCode.Core.Interfaces; + +public interface IFeatureFlagService +{ + bool IsEnabled(string featureFlag); + IReadOnlySet GetEnabledFlags(); +} diff --git a/src/FreeCode.Core/Interfaces/ILspClientManager.cs b/src/FreeCode.Core/Interfaces/ILspClientManager.cs new file mode 100644 index 0000000..2816c99 --- /dev/null +++ b/src/FreeCode.Core/Interfaces/ILspClientManager.cs @@ -0,0 +1,15 @@ +namespace FreeCode.Core.Interfaces; + +public interface ILspClientManager +{ + Task InitializeAsync(CancellationToken ct = default); + Task ShutdownAsync(); + object? GetServerForFile(string filePath); + Task EnsureServerStartedAsync(string filePath); + Task SendRequestAsync(string filePath, string method, object? parameters); + Task OpenFileAsync(string filePath, string content); + Task ChangeFileAsync(string filePath, string content); + Task SaveFileAsync(string filePath); + Task CloseFileAsync(string filePath); + bool IsConnected { get; } +} diff --git a/src/FreeCode.Core/Interfaces/IMcpClientManager.cs b/src/FreeCode.Core/Interfaces/IMcpClientManager.cs new file mode 100644 index 0000000..8bc3f00 --- /dev/null +++ b/src/FreeCode.Core/Interfaces/IMcpClientManager.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using FreeCode.Core.Models; + +namespace FreeCode.Core.Interfaces; + +public interface IMcpClientManager +{ + Task ConnectServersAsync(CancellationToken ct = default); + Task> GetToolsAsync(); + Task> GetCommandsAsync(); + Task> ListResourcesAsync(string? serverName = null, CancellationToken ct = default); + Task ReadResourceAsync(string serverName, string resourceUri, CancellationToken ct = default); + Task DisconnectServerAsync(string serverName); + Task ReconnectServerAsync(string serverName); + IReadOnlyList GetConnections(); + Task AuthenticateServerAsync(string serverName); + Task ReloadAsync(); +} diff --git a/src/FreeCode.Core/Interfaces/INotificationService.cs b/src/FreeCode.Core/Interfaces/INotificationService.cs new file mode 100644 index 0000000..1d23bbd --- /dev/null +++ b/src/FreeCode.Core/Interfaces/INotificationService.cs @@ -0,0 +1,7 @@ +namespace FreeCode.Core.Interfaces; + +public interface INotificationService +{ + Task NotifyAsync(string title, string message, CancellationToken ct = default); + bool IsSupportedTerminal(); +} diff --git a/src/FreeCode.Core/Interfaces/IPermissionEngine.cs b/src/FreeCode.Core/Interfaces/IPermissionEngine.cs new file mode 100644 index 0000000..65b6a11 --- /dev/null +++ b/src/FreeCode.Core/Interfaces/IPermissionEngine.cs @@ -0,0 +1,8 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Core.Interfaces; + +public interface IPermissionEngine +{ + Task CheckAsync(string toolName, object input, ToolExecutionContext context); +} diff --git a/src/FreeCode.Core/Interfaces/IPluginManager.cs b/src/FreeCode.Core/Interfaces/IPluginManager.cs new file mode 100644 index 0000000..ff619c4 --- /dev/null +++ b/src/FreeCode.Core/Interfaces/IPluginManager.cs @@ -0,0 +1,8 @@ +namespace FreeCode.Core.Interfaces; + +public interface IPluginManager +{ + Task LoadPluginsAsync(); + Task UnloadPluginAsync(string pluginId); + IReadOnlyList GetLoadedPlugins(); +} diff --git a/src/FreeCode.Core/Interfaces/IPromptBuilder.cs b/src/FreeCode.Core/Interfaces/IPromptBuilder.cs new file mode 100644 index 0000000..4e4fe8d --- /dev/null +++ b/src/FreeCode.Core/Interfaces/IPromptBuilder.cs @@ -0,0 +1,8 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Core.Interfaces; + +public interface IPromptBuilder +{ + Task BuildAsync(IReadOnlyList messages, ToolPermissionContext? permissionContext, SubmitMessageOptions options); +} diff --git a/src/FreeCode.Core/Interfaces/IQueryEngine.cs b/src/FreeCode.Core/Interfaces/IQueryEngine.cs new file mode 100644 index 0000000..0011d73 --- /dev/null +++ b/src/FreeCode.Core/Interfaces/IQueryEngine.cs @@ -0,0 +1,11 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Core.Interfaces; + +public interface IQueryEngine +{ + IAsyncEnumerable SubmitMessageAsync(string content, SubmitMessageOptions? options = null, CancellationToken ct = default); + Task CancelAsync(); + IReadOnlyList GetMessages(); + TokenUsage GetCurrentUsage(); +} diff --git a/src/FreeCode.Core/Interfaces/IRateLimitService.cs b/src/FreeCode.Core/Interfaces/IRateLimitService.cs new file mode 100644 index 0000000..0df6feb --- /dev/null +++ b/src/FreeCode.Core/Interfaces/IRateLimitService.cs @@ -0,0 +1,7 @@ +namespace FreeCode.Core.Interfaces; + +public interface IRateLimitService +{ + bool CanProceed(IDictionary headers); + TimeSpan? GetRetryAfter(IDictionary headers); +} diff --git a/src/FreeCode.Core/Interfaces/IRemoteSessionManager.cs b/src/FreeCode.Core/Interfaces/IRemoteSessionManager.cs new file mode 100644 index 0000000..c0a2c46 --- /dev/null +++ b/src/FreeCode.Core/Interfaces/IRemoteSessionManager.cs @@ -0,0 +1,10 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Core.Interfaces; + +public interface IRemoteSessionManager +{ + Task ConnectAsync(string endpoint, CancellationToken ct = default); + Task DisconnectAsync(CancellationToken ct = default); + IAsyncEnumerable ReadEventsAsync(CancellationToken ct = default); +} diff --git a/src/FreeCode.Core/Interfaces/ISecureTokenStorage.cs b/src/FreeCode.Core/Interfaces/ISecureTokenStorage.cs new file mode 100644 index 0000000..2a360c8 --- /dev/null +++ b/src/FreeCode.Core/Interfaces/ISecureTokenStorage.cs @@ -0,0 +1,8 @@ +namespace FreeCode.Core.Interfaces; + +public interface ISecureTokenStorage +{ + string? Get(string key); + void Set(string key, string? value); + void Remove(string key); +} diff --git a/src/FreeCode.Core/Interfaces/ISessionMemoryService.cs b/src/FreeCode.Core/Interfaces/ISessionMemoryService.cs new file mode 100644 index 0000000..c21b5f5 --- /dev/null +++ b/src/FreeCode.Core/Interfaces/ISessionMemoryService.cs @@ -0,0 +1,9 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Core.Interfaces; + +public interface ISessionMemoryService +{ + Task GetCurrentMemoryAsync(); + Task TryExtractAsync(IReadOnlyList messages); +} diff --git a/src/FreeCode.Core/Interfaces/ISkillLoader.cs b/src/FreeCode.Core/Interfaces/ISkillLoader.cs new file mode 100644 index 0000000..2a4f18a --- /dev/null +++ b/src/FreeCode.Core/Interfaces/ISkillLoader.cs @@ -0,0 +1,7 @@ +namespace FreeCode.Core.Interfaces; + +public interface ISkillLoader +{ + Task> LoadSkillsAsync(string? directory = null); + Task ReloadAsync(); +} diff --git a/src/FreeCode.Core/Interfaces/ITool.cs b/src/FreeCode.Core/Interfaces/ITool.cs new file mode 100644 index 0000000..7eb1179 --- /dev/null +++ b/src/FreeCode.Core/Interfaces/ITool.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Models; + +namespace FreeCode.Core.Interfaces; + +public interface ITool +{ + string Name { get; } + string[]? Aliases { get; } + string? SearchHint { get; } + ToolCategory Category { get; } + bool IsEnabled(); + JsonElement GetInputSchema(); + Task GetDescriptionAsync(object? input = null); + bool IsConcurrencySafe(object input); + bool IsReadOnly(object input); +} diff --git a/src/FreeCode.Core/Interfaces/IToolRegistry.cs b/src/FreeCode.Core/Interfaces/IToolRegistry.cs new file mode 100644 index 0000000..8be4d91 --- /dev/null +++ b/src/FreeCode.Core/Interfaces/IToolRegistry.cs @@ -0,0 +1,8 @@ +using FreeCode.Core.Models; + +namespace FreeCode.Core.Interfaces; + +public interface IToolRegistry +{ + Task> GetToolsAsync(ToolPermissionContext? permissionContext = null); +} diff --git a/src/FreeCode.Core/Interfaces/IToolTyped.cs b/src/FreeCode.Core/Interfaces/IToolTyped.cs new file mode 100644 index 0000000..3ec05c7 --- /dev/null +++ b/src/FreeCode.Core/Interfaces/IToolTyped.cs @@ -0,0 +1,12 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Models; + +namespace FreeCode.Core.Interfaces; + +public interface ITool : ITool where TInput : class +{ + Task> ExecuteAsync(TInput input, ToolExecutionContext context, CancellationToken ct = default); + Task ValidateInputAsync(TInput input); + Task CheckPermissionAsync(TInput input, ToolExecutionContext context); +} diff --git a/src/FreeCode.Core/Interfaces/IVoiceService.cs b/src/FreeCode.Core/Interfaces/IVoiceService.cs new file mode 100644 index 0000000..4b3411a --- /dev/null +++ b/src/FreeCode.Core/Interfaces/IVoiceService.cs @@ -0,0 +1,8 @@ +namespace FreeCode.Core.Interfaces; + +public interface IVoiceService +{ + Task StartAsync(CancellationToken ct = default); + Task StopAsync(CancellationToken ct = default); + Task RecognizeAsync(CancellationToken ct = default); +} diff --git a/src/FreeCode.Core/Models/ApiRequest.cs b/src/FreeCode.Core/Models/ApiRequest.cs new file mode 100644 index 0000000..6ed8823 --- /dev/null +++ b/src/FreeCode.Core/Models/ApiRequest.cs @@ -0,0 +1,9 @@ +using System.Text.Json; + +namespace FreeCode.Core.Models; + +public record ApiRequest( + string SystemPrompt, + IReadOnlyList Messages, + IReadOnlyList Tools, + string? Model = null); diff --git a/src/FreeCode.Core/Models/BackgroundTask.cs b/src/FreeCode.Core/Models/BackgroundTask.cs new file mode 100644 index 0000000..c504a71 --- /dev/null +++ b/src/FreeCode.Core/Models/BackgroundTask.cs @@ -0,0 +1,73 @@ +using FreeCode.Core.Enums; +using TaskStatus = FreeCode.Core.Enums.TaskStatus; + +namespace FreeCode.Core.Models; + +public abstract record BackgroundTask +{ + public required string TaskId { get; init; } + public abstract BackgroundTaskType TaskType { get; } + public TaskStatus Status { get; set; } = TaskStatus.Pending; + public DateTime? StartedAt { get; set; } + public DateTime? CompletedAt { get; set; } + public string? ErrorMessage { get; set; } + public bool IsBackgrounded { get; set; } = true; +} + +public sealed record LocalShellTask : BackgroundTask +{ + public override BackgroundTaskType TaskType => BackgroundTaskType.LocalShell; + public required string Command { get; init; } + public System.Diagnostics.ProcessStartInfo? ProcessStartInfo { get; init; } + public string? Stdout { get; set; } + public string? Stderr { get; set; } + public int? ExitCode { get; set; } +} + +public sealed record LocalAgentTask : BackgroundTask +{ + public override BackgroundTaskType TaskType => BackgroundTaskType.LocalAgent; + public required string Prompt { get; init; } + public string? Model { get; init; } + public string? AgentType { get; init; } + public string? WorkingDirectory { get; init; } + public List Messages { get; } = new(); +} + +public sealed record RemoteAgentTask : BackgroundTask +{ + public override BackgroundTaskType TaskType => BackgroundTaskType.RemoteAgent; + public required string SessionUrl { get; init; } + public string? Plan { get; set; } + public new string? Status { get; set; } +} + +public sealed record InProcessTeammateTask : BackgroundTask +{ + public override BackgroundTaskType TaskType => BackgroundTaskType.InProcessTeammate; + public required string AgentName { get; init; } + public string? AgentType { get; init; } + public string? Color { get; init; } + public required string WorkingDirectory { get; init; } +} + +public sealed record LocalWorkflowTask : BackgroundTask +{ + public override BackgroundTaskType TaskType => BackgroundTaskType.LocalWorkflow; + public required string WorkflowName { get; init; } + public required List Steps { get; init; } + public int CurrentStepIndex { get; set; } +} + +public sealed record MonitorMcpTask : BackgroundTask +{ + public override BackgroundTaskType TaskType => BackgroundTaskType.MonitorMcp; + public required string ServerName { get; init; } + public int ReconnectAttempt { get; set; } +} + +public sealed record DreamTask : BackgroundTask +{ + public override BackgroundTaskType TaskType => BackgroundTaskType.Dream; + public required string TriggerReason { get; init; } +} diff --git a/src/FreeCode.Core/Models/CommandContext.cs b/src/FreeCode.Core/Models/CommandContext.cs new file mode 100644 index 0000000..68b5d3b --- /dev/null +++ b/src/FreeCode.Core/Models/CommandContext.cs @@ -0,0 +1,3 @@ +namespace FreeCode.Core.Models; + +public record CommandContext(string WorkingDirectory, IServiceProvider Services); diff --git a/src/FreeCode.Core/Models/CommandResult.cs b/src/FreeCode.Core/Models/CommandResult.cs new file mode 100644 index 0000000..ef69c24 --- /dev/null +++ b/src/FreeCode.Core/Models/CommandResult.cs @@ -0,0 +1,3 @@ +namespace FreeCode.Core.Models; + +public record CommandResult(bool Success, string? Output = null); diff --git a/src/FreeCode.Core/Models/Companion.cs b/src/FreeCode.Core/Models/Companion.cs new file mode 100644 index 0000000..208a728 --- /dev/null +++ b/src/FreeCode.Core/Models/Companion.cs @@ -0,0 +1,5 @@ +using FreeCode.Core.Enums; + +namespace FreeCode.Core.Models; + +public sealed record Companion(Species Species, Eye Eye, Hat Hat, Rarity Rarity, string Name); diff --git a/src/FreeCode.Core/Models/CoordinatorTypes.cs b/src/FreeCode.Core/Models/CoordinatorTypes.cs new file mode 100644 index 0000000..ba4c830 --- /dev/null +++ b/src/FreeCode.Core/Models/CoordinatorTypes.cs @@ -0,0 +1,32 @@ +using FreeCode.Core.Enums; + +namespace FreeCode.Core.Models; + +public sealed record CoordinatorPromptContext( + string WorkingDirectory, + string? ScratchpadDirectory, + IReadOnlyList AllowedTools, + IReadOnlyList McpServerNames); + +public sealed record CoordinatorUserContext( + string WorkerToolsContext, + string SystemPrompt); + +public sealed record SpawnWorkerRequest( + string Description, + string Prompt, + string WorkerType, + string? Model = null, + string? TeamId = null); + +public sealed record WorkerHandle(string WorkerId, string Description, WorkerStatus Status); + +public sealed record WorkerResult(string WorkerId, string Summary, string? Output, object? Usage); + +public sealed record WorkerNotification( + string TaskId, string Status, string Summary, + string? Result, object? Usage); + +public sealed record TeamHandle(string TeamId, string Name, IReadOnlyList WorkerIds); + +public sealed record CreateTeamRequest(string Name, string Description, IReadOnlyList WorkerIds); diff --git a/src/FreeCode.Core/Models/McpTypes.cs b/src/FreeCode.Core/Models/McpTypes.cs new file mode 100644 index 0000000..49f9f4f --- /dev/null +++ b/src/FreeCode.Core/Models/McpTypes.cs @@ -0,0 +1,104 @@ +using System.Text.Json; +using FreeCode.Core.Enums; + +namespace FreeCode.Core.Models; + +public abstract record MCPServerConnection +{ + public required string Name { get; init; } + public string ConnectionType { get; init; } = ""; + public required ScopedMcpServerConfig Config { get; init; } + public bool IsConnected => this is Connected; + public bool IsFailed => this is Failed; + public bool NeedsAuth => this is NeedsAuthentication; + public bool IsPending => this is Pending; + public bool IsDisabled => this is Disabled; + + public sealed record Connected( + string Name, ScopedMcpServerConfig Config, object Client, + object Capabilities, object? ServerInfo, string? Instructions, + Func Cleanup) : MCPServerConnection { } + + public sealed record Failed( + string Name, ScopedMcpServerConfig Config, string? Error) : MCPServerConnection { } + + public sealed record NeedsAuthentication( + string Name, ScopedMcpServerConfig Config) : MCPServerConnection { } + + public sealed record Pending( + string Name, ScopedMcpServerConfig Config, + int? ReconnectAttempt = null, int? MaxReconnectAttempts = null) : MCPServerConnection { } + + public sealed record Disabled( + string Name, ScopedMcpServerConfig Config) : MCPServerConnection { } +} + +public abstract record ScopedMcpServerConfig +{ + public required ConfigScope Scope { get; init; } + public string? PluginSource { get; init; } +} + +public sealed record StdioServerConfig : ScopedMcpServerConfig +{ + public string Command { get; init; } = ""; + public IReadOnlyList Args { get; init; } = []; + public IReadOnlyDictionary? Env { get; init; } +} + +public sealed record SseServerConfig : ScopedMcpServerConfig +{ + public required string Url { get; init; } + public IReadOnlyDictionary? Headers { get; init; } + public McpOAuthConfig? OAuth { get; init; } +} + +public sealed record HttpServerConfig : ScopedMcpServerConfig +{ + public required string Url { get; init; } + public IReadOnlyDictionary? Headers { get; init; } + public McpOAuthConfig? OAuth { get; init; } +} + +public sealed record WebSocketServerConfig : ScopedMcpServerConfig +{ + public required string Url { get; init; } + public IReadOnlyDictionary? Headers { get; init; } +} + +public sealed record SseIdeServerConfig : ScopedMcpServerConfig +{ + public required string Url { get; init; } + public required string IdeName { get; init; } + public bool IdeRunningInWindows { get; init; } +} + +public sealed record WsIdeServerConfig : ScopedMcpServerConfig +{ + public required string Url { get; init; } + public required string IdeName { get; init; } + public string? AuthToken { get; init; } + public bool IdeRunningInWindows { get; init; } +} + +public sealed record SdkServerConfig : ScopedMcpServerConfig +{ + public required string ServerName { get; init; } +} + +public sealed record ClaudeAiProxyServerConfig : ScopedMcpServerConfig +{ + public required string Url { get; init; } + public required string Id { get; init; } +} + +public sealed record McpOAuthConfig +{ + public string? ClientId { get; init; } + public int? CallbackPort { get; init; } + public string? AuthServerMetadataUrl { get; init; } + public bool Xaa { get; init; } +} + +public sealed record ServerResource(string Uri, string Name, string? Description, string? MimeType); +public sealed record ResourceContent(string Uri, string Text, string? MimeType); diff --git a/src/FreeCode.Core/Models/Message.cs b/src/FreeCode.Core/Models/Message.cs new file mode 100644 index 0000000..60686f9 --- /dev/null +++ b/src/FreeCode.Core/Models/Message.cs @@ -0,0 +1,14 @@ +using System.Text.Json; +using FreeCode.Core.Enums; + +namespace FreeCode.Core.Models; + +public record Message +{ + public required string MessageId { get; init; } + public required MessageRole Role { get; init; } + public object? Content { get; init; } + public DateTime Timestamp { get; init; } = DateTime.UtcNow; + public string? ToolUseId { get; init; } + public string? ToolName { get; init; } +} diff --git a/src/FreeCode.Core/Models/MiscTypes.cs b/src/FreeCode.Core/Models/MiscTypes.cs new file mode 100644 index 0000000..796a40f --- /dev/null +++ b/src/FreeCode.Core/Models/MiscTypes.cs @@ -0,0 +1,50 @@ +using FreeCode.Core.Enums; + +namespace FreeCode.Core.Models; + +public sealed record SettingsJson +{ + public string? Model { get; init; } + public string? Theme { get; init; } + public string? OutputStyle { get; init; } + public bool Verbose { get; init; } +} + +public sealed record ToolPermissionContext +{ + public IReadOnlySet DeniedTools { get; init; } = new HashSet(); + public IReadOnlySet AllowedTools { get; init; } = new HashSet(); +} + +public sealed record BridgeEnvironment(string Id, string Name, SpawnMode SpawnMode, string? WorkingDirectory = null, IReadOnlyDictionary? Metadata = null); + +public sealed record WorkItem(string Id, string Type, System.Text.Json.JsonElement Payload, string? SessionToken = null); + +public sealed record SessionHandle(string SessionId, string SessionToken, string? Url = null); + +public sealed record SessionSpawnOptions(BridgeEnvironment Environment, string? Prompt = null, string? Model = null, string? AgentType = null); + +public sealed record PermissionResponse(bool IsAllowed, bool Persist, string? Reason = null); + +public sealed record AdditionalWorkingDirectory(string Path, bool Trusted); +public sealed record ModelSetting(string Id, string Name); + +public sealed record Notification(string Title, string Message, DateTime Timestamp); +public sealed record NotificationState(Notification? Current, IReadOnlyList Queue); + +public sealed record WorkflowStep(string Name, string Command, bool IsCritical); +public sealed record AgentDefinitionsResult(IReadOnlyList AgentNames); + +public sealed record FileHistoryState(IReadOnlyDictionary Snapshots); +public sealed record AttributionState(string? Source, string? Version); + +public sealed record TodoList(string Id, string Title, IReadOnlyList Items); +public sealed record TodoItem(string Id, string Content, bool IsCompleted, string Priority); + +public sealed record SpeculationState(string Status) +{ + public static readonly SpeculationState Idle = new("idle"); +} + +public sealed record FooterItem(string Label, string Value); +public sealed record AgentId(string Name, string Type); diff --git a/src/FreeCode.Core/Models/PermissionResult.cs b/src/FreeCode.Core/Models/PermissionResult.cs new file mode 100644 index 0000000..a3bc1c3 --- /dev/null +++ b/src/FreeCode.Core/Models/PermissionResult.cs @@ -0,0 +1,7 @@ +namespace FreeCode.Core.Models; + +public record PermissionResult(bool IsAllowed, string? Reason = null) +{ + public static PermissionResult Allowed() => new(true); + public static PermissionResult Denied(string reason) => new(false, reason); +} diff --git a/src/FreeCode.Core/Models/RemoteTypes.cs b/src/FreeCode.Core/Models/RemoteTypes.cs new file mode 100644 index 0000000..049c076 --- /dev/null +++ b/src/FreeCode.Core/Models/RemoteTypes.cs @@ -0,0 +1,8 @@ +using FreeCode.Core.Enums; + +namespace FreeCode.Core.Models; + +public abstract record RemoteEvent(DateTimeOffset Timestamp); +public sealed record RemoteConnectedEvent(DateTimeOffset Timestamp, string SessionId) : RemoteEvent(Timestamp); +public sealed record RemoteDisconnectedEvent(DateTimeOffset Timestamp, string Reason) : RemoteEvent(Timestamp); +public sealed record RemoteMessageEvent(DateTimeOffset Timestamp, string Message) : RemoteEvent(Timestamp); diff --git a/src/FreeCode.Core/Models/SDKMessage.cs b/src/FreeCode.Core/Models/SDKMessage.cs new file mode 100644 index 0000000..4db95db --- /dev/null +++ b/src/FreeCode.Core/Models/SDKMessage.cs @@ -0,0 +1,16 @@ +using System.Text.Json; +using FreeCode.Core.Enums; + +namespace FreeCode.Core.Models; + +public abstract record SDKMessage +{ + public sealed record UserMessage(Message Message) : SDKMessage; + public sealed record AssistantMessage(string Text, string MessageId) : SDKMessage; + public sealed record StreamingDelta(string Text) : SDKMessage; + public sealed record ToolUseStart(string ToolUseId, string ToolName, JsonElement Input) : SDKMessage; + public sealed record ToolUseResult(string ToolUseId, string Output, bool ShouldContinue, Message Message) : SDKMessage; + public sealed record CompactBoundary(string Reason) : SDKMessage; + public sealed record AssistantError(string Error) : SDKMessage; + public sealed record PermissionDenial(string ToolName, string ToolUseId) : SDKMessage; +} diff --git a/src/FreeCode.Core/Models/StateChangedEventArgs.cs b/src/FreeCode.Core/Models/StateChangedEventArgs.cs new file mode 100644 index 0000000..cf72058 --- /dev/null +++ b/src/FreeCode.Core/Models/StateChangedEventArgs.cs @@ -0,0 +1,3 @@ +namespace FreeCode.Core.Models; + +public sealed record StateChangedEventArgs(object OldState, object NewState); diff --git a/src/FreeCode.Core/Models/SubmitMessageOptions.cs b/src/FreeCode.Core/Models/SubmitMessageOptions.cs new file mode 100644 index 0000000..07fc351 --- /dev/null +++ b/src/FreeCode.Core/Models/SubmitMessageOptions.cs @@ -0,0 +1,7 @@ +namespace FreeCode.Core.Models; + +public record SubmitMessageOptions( + string? Model = null, + ToolPermissionContext? PermissionContext = null, + string? QuerySource = null, + bool IsSpeculation = false); diff --git a/src/FreeCode.Core/Models/TaskStateChangedEventArgs.cs b/src/FreeCode.Core/Models/TaskStateChangedEventArgs.cs new file mode 100644 index 0000000..c38b0d7 --- /dev/null +++ b/src/FreeCode.Core/Models/TaskStateChangedEventArgs.cs @@ -0,0 +1,6 @@ +using FreeCode.Core.Enums; +using TaskStatus = FreeCode.Core.Enums.TaskStatus; + +namespace FreeCode.Core.Models; + +public sealed record TaskStateChangedEventArgs(string TaskId, TaskStatus NewStatus); diff --git a/src/FreeCode.Core/Models/TokenUsage.cs b/src/FreeCode.Core/Models/TokenUsage.cs new file mode 100644 index 0000000..7da09ef --- /dev/null +++ b/src/FreeCode.Core/Models/TokenUsage.cs @@ -0,0 +1,3 @@ +namespace FreeCode.Core.Models; + +public record TokenUsage(int InputTokens, int OutputTokens, int CacheCreationTokens, int CacheReadTokens); diff --git a/src/FreeCode.Core/Models/ToolExecutionContext.cs b/src/FreeCode.Core/Models/ToolExecutionContext.cs new file mode 100644 index 0000000..ef3482f --- /dev/null +++ b/src/FreeCode.Core/Models/ToolExecutionContext.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using System.Text.Json; + +namespace FreeCode.Core.Models; + +public record ToolExecutionContext( + string WorkingDirectory, + PermissionMode PermissionMode, + IReadOnlyList AdditionalWorkingDirectories, + IPermissionEngine PermissionEngine, + ILspClientManager LspManager, + IBackgroundTaskManager TaskManager, + IServiceProvider Services); diff --git a/src/FreeCode.Core/Models/ToolResult.cs b/src/FreeCode.Core/Models/ToolResult.cs new file mode 100644 index 0000000..e4ef2b4 --- /dev/null +++ b/src/FreeCode.Core/Models/ToolResult.cs @@ -0,0 +1,6 @@ +using FreeCode.Core.Enums; +using System.Text.Json; + +namespace FreeCode.Core.Models; + +public record ToolResult(T Data, bool IsError = false, string? ErrorMessage = null, List? SideMessages = null); diff --git a/src/FreeCode.Core/Models/ValidationResult.cs b/src/FreeCode.Core/Models/ValidationResult.cs new file mode 100644 index 0000000..1975383 --- /dev/null +++ b/src/FreeCode.Core/Models/ValidationResult.cs @@ -0,0 +1,7 @@ +namespace FreeCode.Core.Models; + +public record ValidationResult(bool IsValid, IReadOnlyList Errors) +{ + public static ValidationResult Success() => new(true, []); + public static ValidationResult Failure(IEnumerable errors) => new(false, errors.ToList()); +} diff --git a/src/FreeCode.Engine/FreeCode.Engine.csproj b/src/FreeCode.Engine/FreeCode.Engine.csproj new file mode 100644 index 0000000..7c6df64 --- /dev/null +++ b/src/FreeCode.Engine/FreeCode.Engine.csproj @@ -0,0 +1,11 @@ + + + FreeCode.Engine + + + + + + + + diff --git a/src/FreeCode.Engine/QueryEngine.cs b/src/FreeCode.Engine/QueryEngine.cs new file mode 100644 index 0000000..aa7b7ee --- /dev/null +++ b/src/FreeCode.Engine/QueryEngine.cs @@ -0,0 +1,395 @@ +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using Microsoft.Extensions.Logging; + +namespace FreeCode.Engine; + +public sealed class QueryEngine( + IApiProviderRouter apiProviderRouter, + IToolRegistry toolRegistry, + IPermissionEngine permissionEngine, + IPromptBuilder promptBuilder, + ISessionMemoryService sessionMemoryService, + IFeatureFlagService featureFlagService, + Func>? toolExecutor, + ILogger logger) : IQueryEngine +{ + private readonly object _gate = new(); + private readonly List _messages = new(); + private CancellationTokenSource? _activeCts; + + public async IAsyncEnumerable SubmitMessageAsync(string content, SubmitMessageOptions? options = null, [EnumeratorCancellation] CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(content); + options ??= new SubmitMessageOptions(); + + var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + CancellationTokenSource? previous; + + lock (_gate) + { + previous = _activeCts; + _activeCts = linkedCts; + } + + previous?.Cancel(); + previous?.Dispose(); + + var userMessage = new Message + { + MessageId = Guid.NewGuid().ToString("N"), + Role = MessageRole.User, + Content = content + }; + + AppendMessage(userMessage); + yield return new SDKMessage.UserMessage(userMessage); + + try + { + var shouldContinue = true; + while (shouldContinue && !linkedCts.IsCancellationRequested) + { + var currentMessages = GetMessages(); + var toolPermissionContext = options.PermissionContext; + var tools = await toolRegistry.GetToolsAsync(toolPermissionContext).ConfigureAwait(false); + var systemPrompt = await promptBuilder.BuildAsync(currentMessages, toolPermissionContext, options).ConfigureAwait(false); + var request = new ApiRequest( + SystemPrompt: systemPrompt, + Messages: BuildApiMessages(currentMessages), + Tools: await BuildApiToolsAsync(tools, toolPermissionContext).ConfigureAwait(false), + Model: options.Model); + + var provider = apiProviderRouter.GetActiveProvider(); + var assistantText = new StringBuilder(); + var pendingToolUses = new List(); + var assistantMessageHandled = false; + var providerRequestedContinue = false; + + await foreach (var sdkMessage in provider.StreamAsync(request, linkedCts.Token).WithCancellation(linkedCts.Token).ConfigureAwait(false)) + { + linkedCts.Token.ThrowIfCancellationRequested(); + + switch (sdkMessage) + { + case SDKMessage.StreamingDelta streamingDelta: + assistantText.Append(streamingDelta.Text); + yield return streamingDelta; + break; + + case SDKMessage.AssistantMessage assistantMessage: + assistantMessageHandled = true; + assistantText.Clear(); + assistantText.Append(assistantMessage.Text); + AppendMessage(new Message + { + MessageId = assistantMessage.MessageId, + Role = MessageRole.Assistant, + Content = assistantMessage.Text + }); + yield return assistantMessage; + break; + + case SDKMessage.ToolUseStart toolUseStart: + pendingToolUses.Add(toolUseStart); + providerRequestedContinue = true; + yield return toolUseStart; + break; + + case SDKMessage.ToolUseResult toolUseResult: + providerRequestedContinue = toolUseResult.ShouldContinue; + AppendMessage(toolUseResult.Message); + yield return toolUseResult; + break; + + case SDKMessage.PermissionDenial permissionDenial: + providerRequestedContinue = false; + yield return permissionDenial; + break; + + case SDKMessage.AssistantError assistantError: + providerRequestedContinue = false; + yield return assistantError; + break; + + case SDKMessage.CompactBoundary compactBoundary: + yield return compactBoundary; + break; + + case SDKMessage.UserMessage userMessageResponse: + yield return userMessageResponse; + break; + } + } + + if (!assistantMessageHandled && (assistantText.Length > 0 || pendingToolUses.Count > 0)) + { + var assistantHistoryMessage = new Message + { + MessageId = Guid.NewGuid().ToString("N"), + Role = MessageRole.Assistant, + Content = BuildAssistantContent(assistantText.ToString(), pendingToolUses) + }; + AppendMessage(assistantHistoryMessage); + + if (assistantText.Length > 0) + { + yield return new SDKMessage.AssistantMessage(assistantText.ToString(), assistantHistoryMessage.MessageId); + } + } + + if (pendingToolUses.Count == 0) + { + shouldContinue = providerRequestedContinue; + break; + } + + shouldContinue = false; + foreach (var toolUse in pendingToolUses) + { + var result = await ExecuteToolAsync(toolUse, permissionEngine, toolPermissionContext, linkedCts.Token).ConfigureAwait(false); + if (!result.IsAllowed) + { + yield return new SDKMessage.PermissionDenial(toolUse.ToolName, toolUse.ToolUseId); + } + + var toolResultMessage = new Message + { + MessageId = Guid.NewGuid().ToString("N"), + Role = MessageRole.Tool, + Content = result.Output, + ToolUseId = toolUse.ToolUseId, + ToolName = toolUse.ToolName + }; + + AppendMessage(toolResultMessage); + yield return new SDKMessage.ToolUseResult(toolUse.ToolUseId, result.Output, result.ShouldContinue, toolResultMessage); + shouldContinue = shouldContinue || result.ShouldContinue; + } + + logger.LogDebug("Completed tool round with {ToolCount} tool calls.", pendingToolUses.Count); + } + + PostQueryProcessing(); + logger.LogDebug("Query completed with {FlagCount} enabled feature flags.", featureFlagService.GetEnabledFlags().Count); + } + finally + { + lock (_gate) + { + if (ReferenceEquals(_activeCts, linkedCts)) + { + _activeCts = null; + } + } + + linkedCts.Dispose(); + } + } + + public Task CancelAsync() + { + CancellationTokenSource? cts; + lock (_gate) + { + cts = _activeCts; + _activeCts = null; + } + + cts?.Cancel(); + cts?.Dispose(); + return Task.CompletedTask; + } + + public IReadOnlyList GetMessages() + { + lock (_gate) + { + return _messages.ToArray(); + } + } + + public TokenUsage GetCurrentUsage() + { + lock (_gate) + { + var inputTokens = 0; + var outputTokens = 0; + + foreach (var message in _messages) + { + var tokenEstimate = EstimateTokens(message.Content); + if (message.Role == MessageRole.Assistant) + { + outputTokens += tokenEstimate; + } + else + { + inputTokens += tokenEstimate; + } + } + + return new TokenUsage(inputTokens, outputTokens, 0, 0); + } + } + + private async Task<(string Output, bool IsAllowed, bool ShouldContinue)> ExecuteToolAsync(SDKMessage.ToolUseStart toolUse, IPermissionEngine permissionEngineInstance, ToolPermissionContext? permissionContext, CancellationToken ct) + { + if (toolExecutor is null) + { + return ($"Tool execution is unavailable for '{toolUse.ToolName}'.", true, false); + } + + try + { + return await toolExecutor(toolUse.ToolName, toolUse.Input, permissionEngineInstance, permissionContext, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Tool execution failed for {ToolName}.", toolUse.ToolName); + return ($"Tool '{toolUse.ToolName}' failed: {ex.Message}", true, false); + } + } + + private async Task> BuildApiToolsAsync(IReadOnlyList tools, ToolPermissionContext? permissionContext) + { + var payload = new List(tools.Count); + foreach (var tool in tools) + { + payload.Add(new + { + name = tool.Name, + description = await tool.GetDescriptionAsync(permissionContext).ConfigureAwait(false), + input_schema = tool.GetInputSchema() + }); + } + + return payload; + } + + private static IReadOnlyList BuildApiMessages(IReadOnlyList messages) + { + var apiMessages = new List(messages.Count); + foreach (var message in messages) + { + switch (message.Role) + { + case MessageRole.User: + apiMessages.Add(new + { + role = "user", + content = FormatApiContent(message.Content) + }); + break; + + case MessageRole.Assistant: + apiMessages.Add(new + { + role = "assistant", + content = FormatApiContent(message.Content) + }); + break; + + case MessageRole.Tool: + apiMessages.Add(new + { + role = "user", + content = new object[] + { + new + { + type = "tool_result", + tool_use_id = message.ToolUseId, + content = message.Content?.ToString() ?? string.Empty + } + } + }); + break; + + case MessageRole.System: + apiMessages.Add(new + { + role = "system", + content = FormatApiContent(message.Content) + }); + break; + } + } + + return apiMessages; + } + + private static object FormatApiContent(object? content) + { + return content switch + { + null => string.Empty, + JsonElement jsonElement => JsonSerializer.Deserialize(jsonElement.GetRawText(), JsonSerializerOptions.Web) ?? jsonElement.ToString(), + _ => content + }; + } + + private static object BuildAssistantContent(string assistantText, IReadOnlyList toolUses) + { + if (toolUses.Count == 0) + { + return assistantText; + } + + var blocks = new List(toolUses.Count + (string.IsNullOrWhiteSpace(assistantText) ? 0 : 1)); + if (!string.IsNullOrWhiteSpace(assistantText)) + { + blocks.Add(new + { + type = "text", + text = assistantText + }); + } + + foreach (var toolUse in toolUses) + { + blocks.Add(new + { + type = "tool_use", + id = toolUse.ToolUseId, + name = toolUse.ToolName, + input = JsonSerializer.Deserialize(toolUse.Input.GetRawText(), JsonSerializerOptions.Web) + }); + } + + return JsonSerializer.SerializeToElement(blocks, JsonSerializerOptions.Web); + } + + private void PostQueryProcessing() + { + if (featureFlagService.IsEnabled("EXTRACT_MEMORIES")) + { + _ = sessionMemoryService.TryExtractAsync(GetMessages()); + } + } + + private void AppendMessage(Message message) + { + lock (_gate) + { + _messages.Add(message); + } + } + + private static int EstimateTokens(object? content) + => content switch + { + null => 0, + string text => Math.Max(1, text.Length / 4), + JsonElement jsonElement => Math.Max(1, jsonElement.ToString().Length / 4), + _ => Math.Max(1, (content.ToString()?.Length ?? 0) / 4), + }; +} diff --git a/src/FreeCode.Engine/ServiceCollectionExtensions.cs b/src/FreeCode.Engine/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..14d04a6 --- /dev/null +++ b/src/FreeCode.Engine/ServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode.Engine; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddEngine(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/FreeCode.Engine/SystemPromptBuilder.cs b/src/FreeCode.Engine/SystemPromptBuilder.cs new file mode 100644 index 0000000..9c2ad16 --- /dev/null +++ b/src/FreeCode.Engine/SystemPromptBuilder.cs @@ -0,0 +1,167 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using Microsoft.Extensions.Logging; + +namespace FreeCode.Engine; + +public sealed class SystemPromptBuilder( + IToolRegistry toolRegistry, + ICommandRegistry commandRegistry, + ISessionMemoryService sessionMemoryService, + IFeatureFlagService featureFlagService, + ICompanionService companionService, + ILogger logger) : IPromptBuilder +{ + public async Task BuildAsync(IReadOnlyList messages, ToolPermissionContext? permissionContext, SubmitMessageOptions options) + { + ArgumentNullException.ThrowIfNull(messages); + ArgumentNullException.ThrowIfNull(options); + + var builder = new StringBuilder(); + + AppendSegment(builder, "base_instructions", BaseInstructions); + AppendSegment(builder, "tool_descriptions", await BuildToolDescriptionsAsync(permissionContext).ConfigureAwait(false)); + AppendSegment(builder, "command_descriptions", await BuildCommandDescriptionsAsync().ConfigureAwait(false)); + AppendSegment(builder, "session_memory", await BuildSessionMemoryAsync().ConfigureAwait(false)); + AppendSegment(builder, "context_info", await BuildContextInfoAsync(messages, options).ConfigureAwait(false)); + + if (featureFlagService.IsEnabled("BUDDY")) + { + AppendSegment(builder, "companion_intro", BuildCompanionIntro(messages, options)); + } + + return builder.ToString().TrimEnd(); + } + + private async Task BuildToolDescriptionsAsync(ToolPermissionContext? permissionContext) + { + var tools = await toolRegistry.GetToolsAsync(permissionContext).ConfigureAwait(false); + var builder = new StringBuilder(); + foreach (var tool in tools.OrderBy(tool => tool.Name, StringComparer.Ordinal)) + { + builder.AppendLine($"- name: {tool.Name}"); + builder.AppendLine($" category: {tool.Category}"); + builder.AppendLine($" description: {await tool.GetDescriptionAsync(permissionContext).ConfigureAwait(false)}"); + builder.AppendLine(" input_schema:"); + builder.AppendLine(Indent(JsonSerializer.Serialize(tool.GetInputSchema(), new JsonSerializerOptions { WriteIndented = true }), 4)); + } + + return builder.Length == 0 ? "No tools available." : builder.ToString().TrimEnd(); + } + + private async Task BuildCommandDescriptionsAsync() + { + var commands = await commandRegistry.GetEnabledCommandsAsync().ConfigureAwait(false); + var builder = new StringBuilder(); + foreach (var command in commands.OrderBy(command => command.Name, StringComparer.Ordinal)) + { + builder.AppendLine($"/{command.Name} - {command.Description}"); + } + + return builder.Length == 0 ? "No slash commands available." : builder.ToString().TrimEnd(); + } + + private async Task BuildSessionMemoryAsync() + { + var memory = await sessionMemoryService.GetCurrentMemoryAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(memory)) + { + return ""; + } + + var builder = new StringBuilder(); + builder.AppendLine(""); + builder.AppendLine(memory.Trim()); + builder.AppendLine(""); + return builder.ToString().TrimEnd(); + } + + private async Task BuildContextInfoAsync(IReadOnlyList messages, SubmitMessageOptions options) + { + var branch = await RunGitCommandAsync("rev-parse --abbrev-ref HEAD").ConfigureAwait(false); + var status = await RunGitCommandAsync("status --porcelain").ConfigureAwait(false); + + var builder = new StringBuilder(); + builder.AppendLine($"working_directory: {Environment.CurrentDirectory}"); + builder.AppendLine($"git_branch: {(string.IsNullOrWhiteSpace(branch) ? "unknown" : branch)}"); + builder.AppendLine("git_status:"); + builder.AppendLine(Indent(string.IsNullOrWhiteSpace(status) ? "clean" : status, 2)); + builder.AppendLine($"os: {GetEnvironmentName()}"); + builder.AppendLine($"dotnet_version: {Environment.Version}"); + builder.AppendLine($"message_count: {messages.Count}"); + builder.AppendLine($"selected_model: {options.Model ?? "default"}"); + builder.AppendLine($"query_source: {options.QuerySource ?? "interactive"}"); + builder.AppendLine($"is_speculation: {options.IsSpeculation}"); + return builder.ToString().TrimEnd(); + } + + private string BuildCompanionIntro(IReadOnlyList messages, SubmitMessageOptions options) + { + var companion = companionService.Create($"{options.Model ?? "default"}:{messages.Count}:{DateTime.UtcNow:yyyyMMddHH}"); + return $"Companion active: {companion.Name} is a {companion.Rarity} {companion.Species} with {companion.Eye} eyes and a {companion.Hat} hat."; + } + + private async Task RunGitCommandAsync(string arguments) + { + try + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "git", + Arguments = arguments, + WorkingDirectory = Environment.CurrentDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + } + }; + + if (!process.Start()) + { + return string.Empty; + } + + var output = await process.StandardOutput.ReadToEndAsync().ConfigureAwait(false); + await process.WaitForExitAsync().ConfigureAwait(false); + return output.Trim(); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to run git command: {Arguments}", arguments); + return string.Empty; + } + } + + private static void AppendSegment(StringBuilder builder, string segmentName, string content) + { + builder.Append('<').Append(segmentName).AppendLine(">" ); + builder.AppendLine(content); + builder.Append("\n"); + } + + private static string Indent(string value, int spaces) + { + var padding = new string(' ', spaces); + return string.Join(Environment.NewLine, value.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None).Select(line => padding + line)); + } + + private static string GetEnvironmentName() + => OperatingSystem.IsMacOS() ? "macos" : OperatingSystem.IsLinux() ? "linux" : OperatingSystem.IsWindows() ? "windows" : Environment.OSVersion.Platform.ToString(); + + private const string BaseInstructions = """ +You are an expert coding assistant. + +Follow the repository's existing architecture and conventions before introducing new patterns. +Use tools when they reduce guesswork, prefer concrete evidence from the workspace, and keep edits minimal but complete. +When changing code, preserve naming, formatting, imports, and error-handling style already used in the project. +Default to ASCII unless the file clearly requires Unicode. +Avoid speculative answers when a tool can confirm the real state of the codebase. +If a task needs multiple steps, finish the full implementation, then verify the result with diagnostics, tests, or build commands as appropriate. +"""; +} diff --git a/src/FreeCode.Features/FeatureFlagService.cs b/src/FreeCode.Features/FeatureFlagService.cs new file mode 100644 index 0000000..7c84c08 --- /dev/null +++ b/src/FreeCode.Features/FeatureFlagService.cs @@ -0,0 +1,32 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Configuration; + +namespace FreeCode.Features; + +public sealed class FeatureFlagService : FreeCode.Core.Interfaces.IFeatureFlagService +{ + private readonly ConcurrentDictionary _flags = new(StringComparer.OrdinalIgnoreCase); + + public FeatureFlagService(IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + var section = configuration.GetSection("FeatureFlags"); + foreach (var child in section.GetChildren()) + { + if (bool.TryParse(child.Value, out var enabled)) + { + _flags[child.Key] = enabled; + } + } + } + + public bool IsEnabled(string featureFlag) + { + ArgumentNullException.ThrowIfNull(featureFlag); + return _flags.TryGetValue(featureFlag, out var enabled) && enabled; + } + + public IReadOnlySet GetEnabledFlags() + => _flags.Where(static pair => pair.Value).Select(static pair => pair.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/FreeCode.Features/FeatureFlags.cs b/src/FreeCode.Features/FeatureFlags.cs new file mode 100644 index 0000000..96e416f --- /dev/null +++ b/src/FreeCode.Features/FeatureFlags.cs @@ -0,0 +1,140 @@ +namespace FreeCode.Features; + +public static class FeatureFlags +{ + public const string Ultraplan = "ULTRAPLAN"; + public const string Ultrathink = "ULTRATHINK"; + public const string VoiceMode = "VOICE_MODE"; + public const string TokenBudget = "TOKEN_BUDGET"; + public const string HistoryPicker = "HISTORY_PICKER"; + public const string MessageActions = "MESSAGE_ACTIONS"; + public const string QuickSearch = "QUICK_SEARCH"; + public const string ShotStats = "SHOT_STATS"; + + public const string BuiltinExplorePlanAgents = "BUILTIN_EXPLORE_PLAN_AGENTS"; + public const string VerificationAgent = "VERIFICATION_AGENT"; + public const string AgentTriggers = "AGENT_TRIGGERS"; + public const string AgentTriggersRemote = "AGENT_TRIGGERS_REMOTE"; + public const string ExtractMemories = "EXTRACT_MEMORIES"; + public const string CompactionReminders = "COMPACTION_REMINDERS"; + public const string CachedMicrocompact = "CACHED_MICROCOMPACT"; + public const string TeamMem = "TEAMMEM"; + + public const string BridgeMode = "BRIDGE_MODE"; + public const string BashClassifier = "BASH_CLASSIFIER"; + public const string PromptCacheBreakDetection = "PROMPT_CACHE_BREAK_DETECTION"; + + public const string Buddy = "BUDDY"; + public const string V2Todo = "V2_TODO"; + public const string EnableLspTool = "ENABLE_LSP_TOOL"; + public const string ContextCollapse = "CONTEXT_COLLAPSE"; + public const string TokenBudgetWarning = "TOKEN_BUDGET_WARNING"; + public const string MemoryAutoExtract = "MEMORY_AUTO_EXTRACT"; + public const string CompactHistory = "COMPACT_HISTORY"; + public const string PermissionClassifier = "PERMISSION_CLASSIFIER"; + public const string AsyncToolExecution = "ASYNC_TOOL_EXECUTION"; + public const string Speculation = "SPECULATION"; + public const string PromptSuggestion = "PROMPT_SUGGESTION"; + public const string SkillImprovement = "SKILL_IMPROVEMENT"; + public const string AgentSwarm = "ENABLE_AGENT_SWARMS"; + public const string WorkflowTool = "WORKFLOW_TOOL"; + public const string MonitorTool = "MONITOR_TOOL"; + public const string McpSkills = "MCP_SKILLS"; + public const string ChicagoMcp = "CHICAGO_MCP"; + public const string EnableDeskApi = "ENABLE_DESK_API"; + public const string Heapdump = "HEAPDUMP"; + public const string EffortFlag = "EFFORT"; + public const string Advisor = "ADVISOR"; + public const string ThinkingBudget = "THINKING_BUDGET"; + public const string RemoteEnv = "REMOTE_ENV"; + public const string NotifyTool = "NOTIFY_TOOL"; + public const string CaptureTool = "CAPTURE_TOOL"; + public const string ReplayUserMessages = "REPLAY_USER_MESSAGES"; + public const string TelemetryFlush = "TELEMETRY_FLUSH"; + public const string StripUnusedImports = "STRIP_UNUSED_IMPORTS"; + public const string ToolResultStorage = "TOOL_RESULT_STORAGE"; + public const string McpStreaming = "MCP_STREAMING"; + public const string PlanMode = "PLAN_MODE"; + public const string PlanModeV2 = "PLAN_MODE_V2"; + public const string RemoteControl = "REMOTE_CONTROL"; + public const string ConsentedRemoteControl = "CONSENTED_REMOTE_CONTROL"; + public const string CcrV2 = "CCR_V2"; + public const string EnableSandbox = "ENABLE_SANDBOX"; + public const string DiffBasedEdit = "DIFF_BASED_EDIT"; + public const string OutputTruncation = "OUTPUT_TRUNCATION"; + public const string WebSearchTool = "WEB_SEARCH_TOOL"; + public const string PythonTool = "PYTHON_TOOL"; + public const string NotebookEditTool = "NOTEBOOK_EDIT_TOOL"; + public const string DedupeToolCalls = "DEDUPE_TOOL_CALLS"; + public const string IdeIntegration = "IDE_INTEGRATION"; + public const string RateLimitUi = "RATE_LIMIT_UI"; + public const string TokenCounting = "TOKEN_COUNTING"; + + public static readonly string[] All = + [ + Ultraplan, + Ultrathink, + VoiceMode, + TokenBudget, + HistoryPicker, + MessageActions, + QuickSearch, + ShotStats, + BuiltinExplorePlanAgents, + VerificationAgent, + AgentTriggers, + AgentTriggersRemote, + ExtractMemories, + CompactionReminders, + CachedMicrocompact, + TeamMem, + BridgeMode, + BashClassifier, + PromptCacheBreakDetection, + Buddy, + V2Todo, + EnableLspTool, + ContextCollapse, + TokenBudgetWarning, + MemoryAutoExtract, + CompactHistory, + PermissionClassifier, + AsyncToolExecution, + Speculation, + PromptSuggestion, + SkillImprovement, + AgentSwarm, + WorkflowTool, + MonitorTool, + McpSkills, + ChicagoMcp, + EnableDeskApi, + Heapdump, + EffortFlag, + Advisor, + ThinkingBudget, + RemoteEnv, + NotifyTool, + CaptureTool, + ReplayUserMessages, + TelemetryFlush, + StripUnusedImports, + ToolResultStorage, + McpStreaming, + PlanMode, + PlanModeV2, + RemoteControl, + ConsentedRemoteControl, + CcrV2, + EnableSandbox, + DiffBasedEdit, + OutputTruncation, + WebSearchTool, + PythonTool, + NotebookEditTool, + DedupeToolCalls, + IdeIntegration, + RateLimitUi, + TokenCounting, + ]; +} diff --git a/src/FreeCode.Features/FreeCode.Features.csproj b/src/FreeCode.Features/FreeCode.Features.csproj new file mode 100644 index 0000000..e329e0c --- /dev/null +++ b/src/FreeCode.Features/FreeCode.Features.csproj @@ -0,0 +1,10 @@ + + + FreeCode.Features + + + + + + + diff --git a/src/FreeCode.Features/ServiceCollectionExtensions.cs b/src/FreeCode.Features/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..918d4a7 --- /dev/null +++ b/src/FreeCode.Features/ServiceCollectionExtensions.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode.Features; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddFeatures(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/src/FreeCode.Lsp/FreeCode.Lsp.csproj b/src/FreeCode.Lsp/FreeCode.Lsp.csproj new file mode 100644 index 0000000..a2c655a --- /dev/null +++ b/src/FreeCode.Lsp/FreeCode.Lsp.csproj @@ -0,0 +1,9 @@ + + + FreeCode.Lsp + + + + + + diff --git a/src/FreeCode.Lsp/LspClientManager.cs b/src/FreeCode.Lsp/LspClientManager.cs new file mode 100644 index 0000000..7490340 --- /dev/null +++ b/src/FreeCode.Lsp/LspClientManager.cs @@ -0,0 +1,332 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; + +namespace FreeCode.Lsp; + +public sealed class LspClientManager : ILspClientManager +{ + private readonly ConcurrentDictionary _serversByExtension = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _serversByName = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _openedFiles = new(StringComparer.OrdinalIgnoreCase); + private readonly LspDiagnosticRegistry _diagnostics = new(); + private readonly string _configPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".free-code", "config.json"); + + public bool IsConnected => _serversByName.Values.Any(server => server.State == LspServerState.Running); + + public void RegisterServer(string serverName, ProcessStartInfo startInfo, params string[] extensions) + { + var instance = new LspServerInstance(serverName, startInfo, extensions); + instance.DiagnosticsPublished += OnDiagnosticsPublished; + _serversByName[serverName] = instance; + + foreach (var extension in instance.FileExtensions) + { + _serversByExtension[extension] = instance; + } + } + + public Task InitializeAsync(CancellationToken ct = default) + { + LoadConfiguredServers(); + return Task.CompletedTask; + } + + public async Task ShutdownAsync() + { + foreach (var server in _serversByName.Values.Distinct()) + { + await server.ShutdownAsync().ConfigureAwait(false); + } + + _serversByExtension.Clear(); + _serversByName.Clear(); + _openedFiles.Clear(); + } + + public object? GetServerForFile(string filePath) + { + var extension = Path.GetExtension(filePath).ToLowerInvariant(); + if (_serversByExtension.TryGetValue(extension, out var server)) + { + return server; + } + + return null; + } + + public IReadOnlyDictionary GetAllServers() + => _serversByName; + + public async Task EnsureServerStartedAsync(string filePath) + { + if (GetServerForFile(filePath) is not LspServerInstance server) + { + return null; + } + + if (server.State != LspServerState.Running) + { + await server.StartAsync().ConfigureAwait(false); + } + + return server; + } + + public async Task SendRequestAsync(string filePath, string method, object? parameters) + { + var server = await EnsureServerStartedAsync(filePath).ConfigureAwait(false) as LspServerInstance; + return server is null ? default : await server.SendRequestAsync(method, parameters).ConfigureAwait(false); + } + + public Task GoToDefinitionAsync(string filePath, int line, int character) + => SendRequestAsync(filePath, "textDocument/definition", new + { + textDocument = new { uri = ToFileUri(filePath) }, + position = new { line = Math.Max(0, line - 1), character } + }); + + public async Task FindReferencesAsync(string filePath, int line, int character) + => await SendRequestAsync(filePath, "textDocument/references", new + { + textDocument = new { uri = ToFileUri(filePath) }, + position = new { line = Math.Max(0, line - 1), character }, + context = new { includeDeclaration = true } + }).ConfigureAwait(false) ?? []; + + public Task HoverAsync(string filePath, int line, int character) + => SendRequestAsync(filePath, "textDocument/hover", new + { + textDocument = new { uri = ToFileUri(filePath) }, + position = new { line = Math.Max(0, line - 1), character } + }); + + public async Task DocumentSymbolsAsync(string filePath) + => await SendRequestAsync(filePath, "textDocument/documentSymbol", new + { + textDocument = new { uri = ToFileUri(filePath) } + }).ConfigureAwait(false) ?? []; + + public async Task WorkspaceSymbolsAsync(string query) + { + var results = new List(); + foreach (var server in _serversByName.Values.Where(s => s.State == LspServerState.Running)) + { + try + { + var symbols = await server.SendRequestAsync("workspace/symbol", new { query }).ConfigureAwait(false); + if (symbols is not null) + { + results.AddRange(symbols); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"LSP workspace symbol query failed for '{server.Name}': {ex.Message}"); + } + } + + return results.ToArray(); + } + + public async Task GetDiagnosticsAsync(string filePath) + { + var fileUri = ToFileUri(filePath); + var diagnostics = _diagnostics.GetDiagnostics(fileUri); + if (diagnostics.Count > 0) + { + return diagnostics.ToArray(); + } + + return []; + } + + public Task PrepareRenameAsync(string filePath, int line, int character) + => SendRequestAsync(filePath, "textDocument/prepareRename", new + { + textDocument = new { uri = ToFileUri(filePath) }, + position = new { line = Math.Max(0, line - 1), character } + }); + + public Task RenameAsync(string filePath, int line, int character, string newName) + => SendRequestAsync(filePath, "textDocument/rename", new + { + textDocument = new { uri = ToFileUri(filePath) }, + position = new { line = Math.Max(0, line - 1), character }, + newName + }); + + public async Task GetCodeActionsAsync(string filePath, int line, int character) + => await SendRequestAsync(filePath, "textDocument/codeAction", new + { + textDocument = new { uri = ToFileUri(filePath) }, + range = new + { + start = new { line = Math.Max(0, line - 1), character }, + end = new { line = Math.Max(0, line - 1), character } + }, + context = new { diagnostics = Array.Empty() } + }).ConfigureAwait(false) ?? []; + + public async Task OpenFileAsync(string filePath, string content) + { + var server = await EnsureServerStartedAsync(filePath).ConfigureAwait(false) as LspServerInstance; + if (server is null) + { + return; + } + + await server.SendNotificationAsync("textDocument/didOpen", new + { + textDocument = new + { + uri = ToFileUri(filePath), + languageId = GetLanguageId(filePath), + version = 1, + text = content + } + }).ConfigureAwait(false); + _openedFiles[filePath] = content; + } + + public async Task ChangeFileAsync(string filePath, string content) + { + var server = await EnsureServerStartedAsync(filePath).ConfigureAwait(false) as LspServerInstance; + if (server is null) + { + return; + } + + await server.SendNotificationAsync("textDocument/didChange", new + { + textDocument = new { uri = ToFileUri(filePath), version = 1 }, + contentChanges = new[] { new { text = content } } + }).ConfigureAwait(false); + _openedFiles[filePath] = content; + } + + public async Task SaveFileAsync(string filePath) + { + var server = await EnsureServerStartedAsync(filePath).ConfigureAwait(false) as LspServerInstance; + if (server is null) + { + return; + } + + await server.SendNotificationAsync("textDocument/didSave", new { textDocument = new { uri = ToFileUri(filePath) } }).ConfigureAwait(false); + } + + public async Task CloseFileAsync(string filePath) + { + if (await EnsureServerStartedAsync(filePath).ConfigureAwait(false) is not LspServerInstance server) + { + return; + } + + await server.SendNotificationAsync("textDocument/didClose", new { textDocument = new { uri = ToFileUri(filePath) } }).ConfigureAwait(false); + _openedFiles.TryRemove(filePath, out _); + } + + private static string ToFileUri(string filePath) => new Uri(Path.GetFullPath(filePath)).AbsoluteUri; + + private void LoadConfiguredServers() + { + if (!File.Exists(_configPath)) + { + return; + } + + using var stream = File.OpenRead(_configPath); + using var document = JsonDocument.Parse(stream); + var root = document.RootElement; + if (!root.TryGetProperty("lspServers", out var serversElement) && !root.TryGetProperty("LspServers", out serversElement)) + { + return; + } + + foreach (var server in serversElement.EnumerateObject()) + { + if (!server.Value.TryGetProperty("command", out var commandElement)) + { + continue; + } + + var psi = new ProcessStartInfo + { + FileName = commandElement.GetString() ?? string.Empty, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + if (server.Value.TryGetProperty("args", out var argsElement) && argsElement.ValueKind == JsonValueKind.Array) + { + foreach (var arg in argsElement.EnumerateArray()) + { + psi.ArgumentList.Add(arg.GetString() ?? string.Empty); + } + } + + if (server.Value.TryGetProperty("env", out var envElement) && envElement.ValueKind == JsonValueKind.Object) + { + foreach (var entry in envElement.EnumerateObject()) + { + psi.Environment[entry.Name] = entry.Value.GetString() ?? string.Empty; + } + } + + if (server.Value.TryGetProperty("workingDirectory", out var workingDirectoryElement)) + { + psi.WorkingDirectory = workingDirectoryElement.GetString() ?? string.Empty; + } + + var extensions = new List(); + if (server.Value.TryGetProperty("extensions", out var extensionsElement) && extensionsElement.ValueKind == JsonValueKind.Array) + { + extensions.AddRange(extensionsElement.EnumerateArray().Select(x => NormalizeExtension(x.GetString()))); + } + + if (extensions.Count == 0 && server.Value.TryGetProperty("extension", out var extensionElement)) + { + extensions.Add(NormalizeExtension(extensionElement.GetString())); + } + + RegisterServer(server.Name, psi, extensions.Count == 0 ? [".txt"] : extensions.ToArray()); + } + } + + private void OnDiagnosticsPublished(string uri, IReadOnlyList diagnostics) + { + _diagnostics.UpdateDiagnostics(uri, diagnostics); + } + + private static string GetLanguageId(string filePath) + { + return Path.GetExtension(filePath).ToLowerInvariant() switch + { + ".cs" => "csharp", + ".csx" => "csharp", + ".ts" => "typescript", + ".tsx" => "typescriptreact", + ".js" => "javascript", + ".jsx" => "javascriptreact", + ".json" => "json", + ".xml" => "xml", + _ => "plaintext" + }; + } + + private static string NormalizeExtension(string? extension) + { + if (string.IsNullOrWhiteSpace(extension)) + { + return string.Empty; + } + + return extension.StartsWith('.') ? extension.ToLowerInvariant() : "." + extension.ToLowerInvariant(); + } +} diff --git a/src/FreeCode.Lsp/LspDiagnosticRegistry.cs b/src/FreeCode.Lsp/LspDiagnosticRegistry.cs new file mode 100644 index 0000000..94e94c4 --- /dev/null +++ b/src/FreeCode.Lsp/LspDiagnosticRegistry.cs @@ -0,0 +1,73 @@ +namespace FreeCode.Lsp; + +public sealed class LspDiagnosticRegistry +{ + private readonly object _gate = new(); + private readonly Dictionary> _baseline = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _current = new(StringComparer.OrdinalIgnoreCase); + + public void SaveBaseline(string filePath, IReadOnlyList diagnostics) + { + lock (_gate) + { + _baseline[filePath] = diagnostics.ToList(); + } + } + + public void UpdateDiagnostics(string filePath, IReadOnlyList diagnostics) + { + lock (_gate) + { + _current[filePath] = diagnostics.ToList(); + } + } + + public IReadOnlyList GetDiagnostics(string filePath) + { + lock (_gate) + { + return _current.TryGetValue(filePath, out var diagnostics) ? diagnostics.ToList() : []; + } + } + + public IReadOnlyList GetNewDiagnostics(string filePath, IReadOnlyList diagnostics) + { + lock (_gate) + { + if (!_baseline.TryGetValue(filePath, out var baseline)) + { + return diagnostics; + } + + return diagnostics.Where(diag => !baseline.Any(existing => AreEquivalent(existing, diag))).ToList(); + } + } + + public void ClearBaseline(string filePath) + { + lock (_gate) + { + _baseline.Remove(filePath); + } + } + + public void ClearStaleDiagnostics(IEnumerable activeFiles) + { + lock (_gate) + { + var active = new HashSet(activeFiles, StringComparer.OrdinalIgnoreCase); + foreach (var key in _current.Keys.Where(key => !active.Contains(key)).ToList()) + { + _current.Remove(key); + } + } + } + + private static bool AreEquivalent(Diagnostic left, Diagnostic right) + { + return string.Equals(left.Message, right.Message, StringComparison.Ordinal) + && left.Severity == right.Severity + && Equals(left.Range, right.Range) + && string.Equals(left.Source, right.Source, StringComparison.Ordinal); + } +} diff --git a/src/FreeCode.Lsp/LspServerInstance.cs b/src/FreeCode.Lsp/LspServerInstance.cs new file mode 100644 index 0000000..d143db6 --- /dev/null +++ b/src/FreeCode.Lsp/LspServerInstance.cs @@ -0,0 +1,381 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Threading.Channels; +using FreeCode.Core.Enums; + +namespace FreeCode.Lsp; + +public sealed class LspServerInstance : IAsyncDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly ProcessStartInfo _startInfo; + private readonly ConcurrentDictionary> _pending = new(); + private readonly Channel _responses = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); + private readonly CancellationTokenSource _cts = new(); + private readonly SemaphoreSlim _sendLock = new(1, 1); + private Process? _process; + private int _requestCounter; + + public LspServerInstance(string name, ProcessStartInfo startInfo, params string[] fileExtensions) + { + Name = name; + _startInfo = startInfo; + FileExtensions = fileExtensions.Select(NormalizeExtension).ToArray(); + } + + public string Name { get; } + public IReadOnlyList FileExtensions { get; } + public LspServerState State { get; private set; } = LspServerState.Stopped; + public event Action>? DiagnosticsPublished; + + public async Task StartAsync(CancellationToken ct = default) + { + if (State is LspServerState.Starting or LspServerState.Running) + { + return; + } + + State = LspServerState.Starting; + _startInfo.UseShellExecute = false; + _startInfo.RedirectStandardInput = true; + _startInfo.RedirectStandardOutput = true; + _startInfo.RedirectStandardError = true; + _startInfo.CreateNoWindow = true; + + var process = new Process { StartInfo = _startInfo, EnableRaisingEvents = true }; + if (!process.Start()) + { + State = LspServerState.Error; + throw new InvalidOperationException($"Failed to start LSP server '{Name}'."); + } + + _process = process; + _ = Task.Run(() => ReadLoopAsync(process, _cts.Token), CancellationToken.None); + _ = Task.Run(() => DrainErrorAsync(process, _cts.Token), CancellationToken.None); + + await SendRequestAsync("initialize", new + { + processId = Environment.ProcessId, + rootUri = (string?)null, + capabilities = new + { + textDocument = new + { + definition = new { }, + references = new { }, + hover = new { }, + documentSymbol = new { }, + rename = new { prepareSupport = true }, + codeAction = new { } + } + } + }, ct).ConfigureAwait(false); + + await SendNotificationAsync("initialized", new { }, ct).ConfigureAwait(false); + State = LspServerState.Running; + } + + public async Task SendRequestAsync(string method, object? parameters, CancellationToken ct = default) + { + var process = _process ?? throw new InvalidOperationException($"LSP server '{Name}' has not started."); + var id = Interlocked.Increment(ref _requestCounter).ToString(System.Globalization.CultureInfo.InvariantCulture); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pending[id] = tcs; + + await _sendLock.WaitAsync(ct).ConfigureAwait(false); + try + { + await WriteMessageAsync(process.StandardInput.BaseStream, new { jsonrpc = "2.0", id, method, @params = parameters }, ct).ConfigureAwait(false); + } + finally + { + _sendLock.Release(); + } + + using var registration = ct.Register(() => tcs.TrySetCanceled(ct)); + var result = await tcs.Task.ConfigureAwait(false); + return result is null ? default : result.Value.Deserialize(JsonOptions); + } + + public Task SendNotificationAsync(string method, object? parameters, CancellationToken ct = default) + { + var process = _process ?? throw new InvalidOperationException($"LSP server '{Name}' has not started."); + return SendWithLockAsync(process.StandardInput.BaseStream, new { jsonrpc = "2.0", method, @params = parameters }, ct); + } + + public async Task ShutdownAsync(CancellationToken ct = default) + { + if (_process is null) + { + State = LspServerState.Stopped; + return; + } + + try + { + await SendRequestAsync("shutdown", new { }, ct).ConfigureAwait(false); + await SendNotificationAsync("exit", new { }, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + Console.Error.WriteLine($"LSP shutdown request failed for '{Name}': {ex.Message}"); + } + + try + { + if (!_process.HasExited) + { + _process.Kill(entireProcessTree: true); + } + } + catch (InvalidOperationException) + { + if (_process is { HasExited: false }) + { + throw; + } + } + + _cts.Cancel(); + _process.Dispose(); + _process = null; + State = LspServerState.Stopped; + await Task.CompletedTask.ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + await ShutdownAsync().ConfigureAwait(false); + _cts.Dispose(); + _sendLock.Dispose(); + } + + private async Task ReadLoopAsync(Process process, CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested && !process.HasExited) + { + var message = await ReadMessageAsync(process.StandardOutput.BaseStream, ct).ConfigureAwait(false); + if (message is null) + { + break; + } + + await _responses.Writer.WriteAsync(message, ct).ConfigureAwait(false); + await DispatchResponseAsync(message).ConfigureAwait(false); + } + } + catch (Exception) + { + State = LspServerState.Error; + } + finally + { + _responses.Writer.TryComplete(); + } + } + + private async Task DispatchResponseAsync(string message) + { + using var document = JsonDocument.Parse(message); + var root = document.RootElement; + + if (root.TryGetProperty("method", out var methodElement)) + { + if (string.Equals(methodElement.GetString(), "textDocument/publishDiagnostics", StringComparison.OrdinalIgnoreCase)) + { + var diagnostics = ParseDiagnostics(root); + if (root.TryGetProperty("params", out var paramsElement) && paramsElement.TryGetProperty("uri", out var uriElement)) + { + DiagnosticsPublished?.Invoke(uriElement.GetString() ?? string.Empty, diagnostics); + } + } + + return; + } + + if (!root.TryGetProperty("id", out var idElement)) + { + return; + } + + var id = idElement.ToString(); + if (!_pending.TryRemove(id, out var tcs)) + { + return; + } + + if (root.TryGetProperty("error", out var errorElement)) + { + tcs.TrySetException(new InvalidOperationException(errorElement.ToString())); + return; + } + + JsonElement? result = root.TryGetProperty("result", out var resultElement) ? resultElement.Clone() : null; + tcs.TrySetResult(result); + await Task.CompletedTask.ConfigureAwait(false); + } + + private static IReadOnlyList ParseDiagnostics(JsonElement root) + { + var diagnostics = new List(); + if (!root.TryGetProperty("params", out var paramsElement) || + !paramsElement.TryGetProperty("diagnostics", out var diagnosticsElement) || + diagnosticsElement.ValueKind != JsonValueKind.Array) + { + return diagnostics; + } + + foreach (var item in diagnosticsElement.EnumerateArray()) + { + diagnostics.Add(new Diagnostic( + item.TryGetProperty("message", out var messageElement) ? messageElement.GetString() : null, + item.TryGetProperty("severity", out var severityElement) ? severityElement.GetInt32() : 0, + ParseRange(item.TryGetProperty("range", out var rangeElement) ? rangeElement : default), + item.TryGetProperty("source", out var sourceElement) ? sourceElement.GetString() : null)); + } + + return diagnostics; + } + + private static Range ParseRange(JsonElement rangeElement) + => new(ParsePosition(rangeElement.GetProperty("start")), ParsePosition(rangeElement.GetProperty("end"))); + + private static Position ParsePosition(JsonElement positionElement) + => new(positionElement.TryGetProperty("line", out var lineElement) ? lineElement.GetInt32() : 0, + positionElement.TryGetProperty("character", out var characterElement) ? characterElement.GetInt32() : 0); + + private async Task SendWithLockAsync(Stream stream, object payload, CancellationToken ct) + { + await _sendLock.WaitAsync(ct).ConfigureAwait(false); + try + { + await WriteMessageAsync(stream, payload, ct).ConfigureAwait(false); + } + finally + { + _sendLock.Release(); + } + } + + private static async Task WriteMessageAsync(Stream stream, object payload, CancellationToken ct) + { + var json = JsonSerializer.Serialize(payload, JsonOptions); + var bytes = Encoding.UTF8.GetBytes(json); + var header = Encoding.ASCII.GetBytes($"Content-Length: {bytes.Length}\r\n\r\n"); + await stream.WriteAsync(header, ct).ConfigureAwait(false); + await stream.WriteAsync(bytes, ct).ConfigureAwait(false); + await stream.FlushAsync(ct).ConfigureAwait(false); + } + + private static async Task ReadMessageAsync(Stream stream, CancellationToken ct) + { + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + while (true) + { + var line = await ReadLineAsync(stream, ct).ConfigureAwait(false); + if (line is null) + { + return null; + } + + if (line.Length == 0) + { + break; + } + + var separator = line.IndexOf(':'); + if (separator > 0) + { + headers[line[..separator].Trim()] = line[(separator + 1)..].Trim(); + } + } + + if (!headers.TryGetValue("Content-Length", out var lengthText) || !int.TryParse(lengthText, out var length)) + { + return null; + } + + var buffer = new byte[length]; + var offset = 0; + while (offset < length) + { + var read = await stream.ReadAsync(buffer.AsMemory(offset, length - offset), ct).ConfigureAwait(false); + if (read == 0) + { + break; + } + + offset += read; + } + + return Encoding.UTF8.GetString(buffer, 0, offset); + } + + private static async Task ReadLineAsync(Stream stream, CancellationToken ct) + { + var buffer = new List(); + while (true) + { + var value = await stream.ReadByteAsync(ct).ConfigureAwait(false); + if (value < 0) + { + return buffer.Count == 0 ? null : Encoding.UTF8.GetString(buffer.ToArray()); + } + + if (value == '\n') + { + break; + } + + if (value != '\r') + { + buffer.Add((byte)value); + } + } + + return Encoding.UTF8.GetString(buffer.ToArray()); + } + + private static async Task DrainErrorAsync(Process process, CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested && !process.HasExited) + { + var line = await process.StandardError.ReadLineAsync().ConfigureAwait(false); + if (line is null) + { + break; + } + } + } + catch (Exception) + { + return; + } + } + + private static string NormalizeExtension(string extension) + { + if (string.IsNullOrWhiteSpace(extension)) + { + return string.Empty; + } + + return extension.StartsWith('.') ? extension.ToLowerInvariant() : "." + extension.ToLowerInvariant(); + } +} + +internal static class StreamExtensions +{ + public static async ValueTask ReadByteAsync(this Stream stream, CancellationToken ct) + { + var buffer = new byte[1]; + var read = await stream.ReadAsync(buffer.AsMemory(0, 1), ct).ConfigureAwait(false); + return read == 0 ? -1 : buffer[0]; + } +} diff --git a/src/FreeCode.Lsp/LspTypes.cs b/src/FreeCode.Lsp/LspTypes.cs new file mode 100644 index 0000000..b28da8b --- /dev/null +++ b/src/FreeCode.Lsp/LspTypes.cs @@ -0,0 +1,21 @@ +namespace FreeCode.Lsp; + +public sealed record Location(string Uri, Range Range); + +public sealed record Range(Position Start, Position End); + +public sealed record Position(int Line, int Character); + +public sealed record Hover(string? Content); + +public sealed record Symbol(string Name, string Kind, string? ContainerName); + +public sealed record Diagnostic(string? Message, int Severity, Range Range, string? Source); + +public sealed record PrepareRenameResult(bool CanRename, Range? Range); + +public sealed record WorkspaceEdit(Dictionary>? Changes); + +public sealed record TextEdit(Range Range, string NewText); + +public sealed record CodeAction(string Title, string? Kind); diff --git a/src/FreeCode.Lsp/ServiceCollectionExtensions.cs b/src/FreeCode.Lsp/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..f863071 --- /dev/null +++ b/src/FreeCode.Lsp/ServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode.Lsp; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddFreeCodeLsp(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + return services; + } +} diff --git a/src/FreeCode.Mcp/FreeCode.Mcp.csproj b/src/FreeCode.Mcp/FreeCode.Mcp.csproj new file mode 100644 index 0000000..587824b --- /dev/null +++ b/src/FreeCode.Mcp/FreeCode.Mcp.csproj @@ -0,0 +1,10 @@ + + + FreeCode.Mcp + + + + + + + diff --git a/src/FreeCode.Mcp/JsonRpcTypes.cs b/src/FreeCode.Mcp/JsonRpcTypes.cs new file mode 100644 index 0000000..847d06d --- /dev/null +++ b/src/FreeCode.Mcp/JsonRpcTypes.cs @@ -0,0 +1,13 @@ +using System.Text.Json; + +namespace FreeCode.Mcp; + +public abstract record JsonRpcMessage; + +public sealed record JsonRpcRequest(string Id, string Method, object? Params) : JsonRpcMessage; + +public sealed record JsonRpcNotification(string Method, object? Params) : JsonRpcMessage; + +public sealed record JsonRpcResponse(string Id, JsonElement? Result, JsonRpcError? Error) : JsonRpcMessage; + +public sealed record JsonRpcError(int Code, string Message); diff --git a/src/FreeCode.Mcp/McpClient.cs b/src/FreeCode.Mcp/McpClient.cs new file mode 100644 index 0000000..1316528 --- /dev/null +++ b/src/FreeCode.Mcp/McpClient.cs @@ -0,0 +1,207 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using System.Threading.Channels; + +namespace FreeCode.Mcp; + +public sealed class McpClient : IAsyncDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly IMcpTransport _transport; + private readonly ConcurrentDictionary> _pendingRequests = new(); + private readonly Channel _incoming = Channel.CreateUnbounded(); + private readonly CancellationTokenSource _cts = new(); + private readonly Task _dispatchLoop; + private int _requestCounter; + private readonly SemaphoreSlim _sendLock = new(1, 1); + + public McpClient(IMcpTransport transport) + { + _transport = transport; + _dispatchLoop = Task.Run(DispatchLoopAsync); + } + + public bool IsConnected { get; private set; } + public ServerCapabilities Capabilities { get; private set; } = new(); + public ServerInfo? ServerInfo { get; private set; } + + public async Task ConnectAsync(CancellationToken ct = default) + { + await _transport.StartAsync(ct).ConfigureAwait(false); + + var initResult = await SendRequestAsync("initialize", new + { + protocolVersion = "2024-11-05", + clientInfo = new { name = "free-code", version = "10.0.0" }, + capabilities = new + { + roots = new { }, + sampling = new { }, + elicitation = new { } + } + }, ct).ConfigureAwait(false); + + Capabilities = initResult?.Capabilities ?? new ServerCapabilities(); + ServerInfo = initResult?.ServerInfo ?? new ServerInfo("unknown", "unknown"); + + await SendNotificationAsync("initialized", new { }, ct).ConfigureAwait(false); + IsConnected = true; + } + + public async Task ListToolsAsync(CancellationToken ct = default) + { + return await SendRequestAsync("tools/list", new { }, ct).ConfigureAwait(false) ?? new ListToolsResult([]); + } + + public async Task CallToolAsync(string toolName, object? parameters, CancellationToken ct = default) + { + return await SendRequestAsync("tools/call", new { name = toolName, arguments = parameters }, ct).ConfigureAwait(false) + ?? new CallToolResult(JsonDocument.Parse("null").RootElement.Clone(), false); + } + + public async Task ListResourcesAsync(CancellationToken ct = default) + { + return await SendRequestAsync("resources/list", new { }, ct).ConfigureAwait(false) ?? new ListResourcesResult([]); + } + + public async Task ListPromptsAsync(CancellationToken ct = default) + { + return await SendRequestAsync("prompts/list", new { }, ct).ConfigureAwait(false) ?? new ListPromptsResult([]); + } + + public async Task ReadResourceAsync(string resourceUri, CancellationToken ct = default) + { + return await SendRequestAsync("resources/read", new { uri = resourceUri }, ct).ConfigureAwait(false) ?? new ReadResourceResult([]); + } + + public async Task DisconnectAsync() + { + IsConnected = false; + + try + { + if (_transport.IncomingLines.Completion.IsCompletedSuccessfully is false) + { + await SendRequestAsync("shutdown", null, CancellationToken.None).ConfigureAwait(false); + await SendNotificationAsync("exit", null, CancellationToken.None).ConfigureAwait(false); + } + } + catch (Exception) + { + /* best-effort shutdown notification */ + } + + _cts.Cancel(); + await _transport.DisposeAsync().ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + await DisconnectAsync().ConfigureAwait(false); + _cts.Dispose(); + } + + private async Task SendRequestAsync(string method, object? parameters, CancellationToken ct) + { + var id = Interlocked.Increment(ref _requestCounter).ToString(System.Globalization.CultureInfo.InvariantCulture); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pendingRequests[id] = tcs; + + await _sendLock.WaitAsync(ct).ConfigureAwait(false); + try + { + await _transport.SendLineAsync(JsonSerializer.Serialize(new JsonRpcRequest(id, method, parameters), JsonOptions), ct).ConfigureAwait(false); + } + finally + { + _sendLock.Release(); + } + + using var registration = ct.Register(() => tcs.TrySetCanceled(ct)); + var result = await tcs.Task.ConfigureAwait(false); + if (result is null) + { + return default; + } + + return result.Value.Deserialize(JsonOptions); + } + + private async Task SendNotificationAsync(string method, object? parameters, CancellationToken ct) + { + await _sendLock.WaitAsync(ct).ConfigureAwait(false); + try + { + await _transport.SendLineAsync(JsonSerializer.Serialize(new JsonRpcNotification(method, parameters), JsonOptions), ct).ConfigureAwait(false); + } + finally + { + _sendLock.Release(); + } + } + + private async Task DispatchLoopAsync() + { + await foreach (var line in _transport.IncomingLines.ReadAllAsync(_cts.Token).ConfigureAwait(false)) + { + JsonRpcMessage? message = ParseMessage(line); + if (message is null) + { + continue; + } + + if (message is JsonRpcResponse response) + { + if (_pendingRequests.TryRemove(response.Id, out var tcs)) + { + if (response.Error is not null) + { + tcs.TrySetException(new InvalidOperationException($"MCP error {response.Error.Code}: {response.Error.Message}")); + } + else + { + tcs.TrySetResult(response.Result); + } + } + } + } + } + + private static JsonRpcMessage? ParseMessage(string line) + { + using var document = JsonDocument.Parse(line); + var root = document.RootElement; + + if (root.TryGetProperty("method", out var methodElement)) + { + var method = methodElement.GetString() ?? string.Empty; + var hasId = root.TryGetProperty("id", out var idElement); + if (hasId) + { + object? parameters = root.TryGetProperty("params", out var paramsElement) ? paramsElement.Clone() : null; + return new JsonRpcRequest(idElement.ToString(), method, parameters); + } + + object? notificationParams = root.TryGetProperty("params", out var notificationParamsElement) ? notificationParamsElement.Clone() : null; + return new JsonRpcNotification(method, notificationParams); + } + + if (root.TryGetProperty("id", out var responseId)) + { + JsonElement? result = root.TryGetProperty("result", out var resultElement) ? resultElement.Clone() : null; + JsonRpcError? error = null; + if (root.TryGetProperty("error", out var errorElement)) + { + error = new JsonRpcError( + errorElement.TryGetProperty("code", out var codeElement) ? codeElement.GetInt32() : -1, + errorElement.TryGetProperty("message", out var messageElement) ? messageElement.GetString() ?? string.Empty : string.Empty); + } + + return new JsonRpcResponse(responseId.ToString(), result, error); + } + + return null; + } + + private sealed record InitializeResult(ServerCapabilities? Capabilities, ServerInfo? ServerInfo); +} diff --git a/src/FreeCode.Mcp/McpClientManager.cs b/src/FreeCode.Mcp/McpClientManager.cs new file mode 100644 index 0000000..ca2592f --- /dev/null +++ b/src/FreeCode.Mcp/McpClientManager.cs @@ -0,0 +1,624 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net.Http.Headers; +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using FreeCode.State; + +namespace FreeCode.Mcp; + +public sealed class McpClientManager : IMcpClientManager +{ + private readonly IAppStateStore? _appStateStore; + private readonly ConcurrentDictionary _connections = new(StringComparer.OrdinalIgnoreCase); + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly string _configPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".free-code", "config.json"); + + public McpClientManager(IAppStateStore? appStateStore = null) + { + _appStateStore = appStateStore; + } + + public async Task ConnectServersAsync(CancellationToken ct = default) + { + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + foreach (var entry in LoadConfiguredConnections()) + { + _connections[entry.Name] = entry; + if (entry.Client is McpClient client && !client.IsConnected) + { + try + { + await client.ConnectAsync(ct).ConfigureAwait(false); + _connections[entry.Name] = entry with { Connection = CreateConnectedConnection(entry.Name, entry.Connection.Config, client) }; + } + catch (Exception ex) + { + _connections[entry.Name] = entry with { Connection = CreateFailedConnection(entry.Name, entry.Connection.Config, ex.Message) }; + } + } + } + + UpdateAppState(); + } + finally + { + _gate.Release(); + } + } + + public async Task ConnectServerAsync(string serverName, CancellationToken ct = default) + { + var entry = LoadConfiguredConnections().FirstOrDefault(connection => string.Equals(connection.Name, serverName, StringComparison.OrdinalIgnoreCase)); + if (entry is null) + { + throw new KeyNotFoundException($"MCP server '{serverName}' was not found in configuration."); + } + + _connections[entry.Name] = entry; + if (entry.Client is McpClient client) + { + await client.ConnectAsync(ct).ConfigureAwait(false); + _connections[entry.Name] = entry with { Connection = CreateConnectedConnection(entry.Name, entry.Connection.Config, client) }; + } + + UpdateAppState(); + } + + public async Task> GetToolsAsync() + { + var tools = new List(); + foreach (var managed in _connections.Values) + { + if (managed.Client is not McpClient client || !client.IsConnected) + { + continue; + } + + var result = await client.ListToolsAsync().ConfigureAwait(false); + tools.AddRange(result.Tools.Select(tool => new McpToolWrapper(managed.Name, client, tool).ToTool())); + } + + return tools; + } + + public async Task> GetCommandsAsync() + { + var commands = new List(); + foreach (var managed in _connections.Values) + { + if (managed.Client is not McpClient client || !client.IsConnected) + { + continue; + } + + var prompts = await client.ListPromptsAsync().ConfigureAwait(false); + commands.AddRange(prompts.Prompts.Select(prompt => new McpPromptCommandWrapper(managed.Name, client, prompt))); + } + + return commands; + } + + public async Task> ListResourcesAsync(string? serverName = null, CancellationToken ct = default) + { + var results = new List(); + foreach (var managed in EnumerateConnections(serverName)) + { + if (managed.Client is not McpClient client || !client.IsConnected) + { + continue; + } + + var list = await client.ListResourcesAsync(ct).ConfigureAwait(false); + results.AddRange(list.Resources.Select(r => new global::FreeCode.Core.Models.ServerResource(r.Uri, r.Name, r.Description, r.MimeType))); + } + + return results; + } + + public async Task ReadResourceAsync(string serverName, string resourceUri, CancellationToken ct = default) + { + var managed = _connections.GetValueOrDefault(serverName) ?? throw new KeyNotFoundException($"MCP server '{serverName}' was not found."); + if (managed.Client is not McpClient client) + { + throw new InvalidOperationException($"MCP server '{serverName}' is not connected."); + } + + var result = await client.ReadResourceAsync(resourceUri, ct).ConfigureAwait(false); + var content = result.Contents.FirstOrDefault(x => string.Equals(x.Uri, resourceUri, StringComparison.OrdinalIgnoreCase)); + return content is null ? new global::FreeCode.Core.Models.ResourceContent(resourceUri, string.Empty, null) : new global::FreeCode.Core.Models.ResourceContent(content.Uri, content.Text, content.MimeType); + } + + public Task DisconnectServerAsync(string serverName) + { + if (_connections.TryGetValue(serverName, out var managed) && managed.Client is McpClient client) + { + _connections[serverName] = managed with { Connection = CreatePendingConnection(serverName, managed.Connection.Config) }; + return client.DisconnectAsync(); + } + + return Task.CompletedTask; + } + + public async Task ReconnectServerAsync(string serverName) + { + if (!_connections.TryGetValue(serverName, out var managed)) + { + return; + } + + await DisconnectServerAsync(serverName).ConfigureAwait(false); + var refreshed = CreateManagedConnection(serverName, managed.Connection.Config); + _connections[serverName] = refreshed; + if (refreshed.Client is McpClient client) + { + await client.ConnectAsync().ConfigureAwait(false); + _connections[serverName] = refreshed with { Connection = CreateConnectedConnection(serverName, refreshed.Connection.Config, client) }; + } + + UpdateAppState(); + } + + public IReadOnlyList GetConnections() + { + return _connections.Values.Select(v => v.Connection).ToList(); + } + + public async Task AuthenticateServerAsync(string serverName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(serverName); + + if (!_connections.TryGetValue(serverName, out var managed)) + { + throw new KeyNotFoundException($"MCP server '{serverName}' is not configured."); + } + + var config = managed.Connection.Config; + var oauthUrl = config is StdioServerConfig stdio && stdio.Env is not null + && stdio.Env.TryGetValue("MCP_OAUTH_URL", out var url) + ? url + : null; + + if (string.IsNullOrWhiteSpace(oauthUrl)) + { + throw new InvalidOperationException($"MCP server '{serverName}' does not have OAuth configuration. Set MCP_OAUTH_URL in the server's env config."); + } + + var startInfo = new ProcessStartInfo + { + FileName = OperatingSystem.IsMacOS() ? "open" + : OperatingSystem.IsWindows() ? "cmd" + : "xdg-open", + UseShellExecute = false, + RedirectStandardOutput = false, + RedirectStandardError = false, + }; + + if (OperatingSystem.IsWindows()) + { + startInfo.ArgumentList.Add("/c"); + startInfo.ArgumentList.Add("start"); + startInfo.ArgumentList.Add(""); + startInfo.ArgumentList.Add(oauthUrl); + } + else + { + startInfo.ArgumentList.Add(oauthUrl); + } + + using var process = Process.Start(startInfo); + + await Task.Delay(500, CancellationToken.None).ConfigureAwait(false); + } + + public async Task ReloadAsync() + { + foreach (var connection in _connections.Keys.ToList()) + { + await ReconnectServerAsync(connection).ConfigureAwait(false); + } + } + + private IEnumerable LoadConfiguredConnections() + { + var diskConnections = LoadConfiguredConnectionsFromDisk().ToList(); + if (diskConnections.Count > 0) + { + foreach (var connection in diskConnections) + { + yield return connection; + } + + yield break; + } + + var state = _appStateStore?.GetState(); + if (state is not AppState appState) + { + yield break; + } + + foreach (var connection in appState.Mcp.Clients) + { + yield return CreateManagedConnection(connection.Name, connection.Config, connection); + } + } + + private IEnumerable LoadConfiguredConnectionsFromDisk() + { + if (!File.Exists(_configPath)) + { + yield break; + } + + using var stream = File.OpenRead(_configPath); + using var document = JsonDocument.Parse(stream); + if (!document.RootElement.TryGetProperty("mcpServers", out var serversElement) && !document.RootElement.TryGetProperty("McpServers", out serversElement)) + { + yield break; + } + + foreach (var server in serversElement.EnumerateObject()) + { + var config = ParseMcpServerConfig(server.Value); + if (config is null) + { + continue; + } + + yield return CreateManagedConnection(server.Name, config); + } + } + + private ManagedConnection CreateManagedConnection(string name, ScopedMcpServerConfig config, MCPServerConnection? existing = null) + { + var transport = CreateTransport(config); + var client = new McpClient(transport); + var connection = existing ?? CreatePendingConnection(name, config); + return new ManagedConnection(name, config, client, connection); + } + + private static IMcpTransport CreateTransport(ScopedMcpServerConfig config) + { + return config switch + { + StdioServerConfig stdio => new StdioTransport(CreateProcessStartInfo(stdio)), + SseServerConfig sse => new SseTransport(CreateHttpClient(sse.Headers), sse.Url), + HttpServerConfig http => new StreamableHttpTransport(CreateHttpClient(http.Headers), http.Url), + SseIdeServerConfig sseIde => new SseTransport(CreateHttpClient(), sseIde.Url), + WsIdeServerConfig wsIde => new WebSocketTransport(wsIde.Url, wsIde.AuthToken), + WebSocketServerConfig ws => new WebSocketTransport(ws.Url, null, ws.Headers), + ClaudeAiProxyServerConfig proxy => new StreamableHttpTransport(CreateProxyHttpClient(proxy), proxy.Url), + _ => throw new NotSupportedException($"Unsupported MCP config type: {config.GetType().Name}") + }; + } + + private static HttpClient CreateHttpClient(IReadOnlyDictionary? headers = null) + { + var client = new HttpClient(); + ApplyHeaders(client, headers); + return client; + } + + private static HttpClient CreateProxyHttpClient(ClaudeAiProxyServerConfig proxy) + { + var client = new HttpClient(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + client.DefaultRequestHeaders.Add("x-claude-ai-proxy-id", proxy.Id); + return client; + } + + private static void ApplyHeaders(HttpClient client, IReadOnlyDictionary? headers) + { + if (headers is null) + { + return; + } + + foreach (var (key, value) in headers) + { + client.DefaultRequestHeaders.TryAddWithoutValidation(key, value); + } + } + + private static ProcessStartInfo CreateProcessStartInfo(StdioServerConfig stdio) + { + var psi = new ProcessStartInfo + { + FileName = stdio.Command, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + foreach (var arg in stdio.Args) + { + psi.ArgumentList.Add(arg); + } + + if (stdio.Env is not null) + { + foreach (var (key, value) in stdio.Env) + { + psi.Environment[key] = value; + } + } + + return psi; + } + + private static ScopedMcpServerConfig? ParseMcpServerConfig(JsonElement element) + { + if (element.TryGetProperty("command", out var commandElement)) + { + var args = new List(); + if (element.TryGetProperty("args", out var argsElement) && argsElement.ValueKind == JsonValueKind.Array) + { + args.AddRange(argsElement.EnumerateArray().Select(x => x.GetString() ?? string.Empty)); + } + + var env = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (element.TryGetProperty("env", out var envElement) && envElement.ValueKind == JsonValueKind.Object) + { + foreach (var prop in envElement.EnumerateObject()) + { + env[prop.Name] = prop.Value.GetString() ?? string.Empty; + } + } + + return new StdioServerConfig + { + Scope = ParseScope(element), + PluginSource = GetOptionalString(element, "pluginSource"), + Command = commandElement.GetString() ?? string.Empty, + Args = args, + Env = env.Count == 0 ? null : env + }; + } + + if (!element.TryGetProperty("url", out var urlElement)) + { + return null; + } + + var url = urlElement.GetString() ?? string.Empty; + var scope = ParseScope(element); + var pluginSource = GetOptionalString(element, "pluginSource"); + var headers = ParseStringDictionary(element, "headers"); + var oauth = ParseOAuthConfig(element); + var type = (GetOptionalString(element, "transport") + ?? GetOptionalString(element, "type") + ?? InferRemoteTransportType(url)) + .ToLowerInvariant(); + + return type switch + { + "sse" => new SseServerConfig + { + Scope = scope, + PluginSource = pluginSource, + Url = url, + Headers = headers, + OAuth = oauth + }, + "http" or "streamablehttp" or "streamable-http" => new HttpServerConfig + { + Scope = scope, + PluginSource = pluginSource, + Url = url, + Headers = headers, + OAuth = oauth + }, + "sseide" or "sse-ide" => new SseIdeServerConfig + { + Scope = scope, + PluginSource = pluginSource, + Url = url, + IdeName = GetOptionalString(element, "ideName") ?? "IDE", + IdeRunningInWindows = GetOptionalBool(element, "ideRunningInWindows") + }, + "wside" or "wside" or "ws-ide" => new WsIdeServerConfig + { + Scope = scope, + PluginSource = pluginSource, + Url = url, + IdeName = GetOptionalString(element, "ideName") ?? "IDE", + AuthToken = GetOptionalString(element, "authToken"), + IdeRunningInWindows = GetOptionalBool(element, "ideRunningInWindows") + }, + "websocket" or "ws" => new WebSocketServerConfig + { + Scope = scope, + PluginSource = pluginSource, + Url = url, + Headers = headers + }, + "claudeaiproxy" or "claude-ai-proxy" => new ClaudeAiProxyServerConfig + { + Scope = scope, + PluginSource = pluginSource, + Url = url, + Id = GetOptionalString(element, "id") ?? string.Empty + }, + _ => new HttpServerConfig + { + Scope = scope, + PluginSource = pluginSource, + Url = url, + Headers = headers, + OAuth = oauth + } + }; + } + + private static ConfigScope ParseScope(JsonElement element) + { + var raw = GetOptionalString(element, "scope"); + return Enum.TryParse(raw, true, out var scope) ? scope : ConfigScope.User; + } + + private static string? GetOptionalString(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + } + + private static bool GetOptionalBool(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var property) + && property.ValueKind is JsonValueKind.True or JsonValueKind.False + && property.GetBoolean(); + } + + private static IReadOnlyDictionary? ParseStringDictionary(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Object) + { + return null; + } + + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in property.EnumerateObject()) + { + values[item.Name] = item.Value.GetString() ?? item.Value.ToString(); + } + + return values.Count == 0 ? null : values; + } + + private static McpOAuthConfig? ParseOAuthConfig(JsonElement element) + { + if (!element.TryGetProperty("oauth", out var oauthElement) || oauthElement.ValueKind != JsonValueKind.Object) + { + return null; + } + + return new McpOAuthConfig + { + ClientId = GetOptionalString(oauthElement, "clientId"), + CallbackPort = oauthElement.TryGetProperty("callbackPort", out var callbackPort) && callbackPort.TryGetInt32(out var port) ? port : null, + AuthServerMetadataUrl = GetOptionalString(oauthElement, "authServerMetadataUrl"), + Xaa = GetOptionalBool(oauthElement, "xaa") + }; + } + + private static string InferRemoteTransportType(string url) + { + if (url.StartsWith("ws://", StringComparison.OrdinalIgnoreCase) + || url.StartsWith("wss://", StringComparison.OrdinalIgnoreCase)) + { + return "websocket"; + } + + return url.Contains("/sse", StringComparison.OrdinalIgnoreCase) ? "sse" : "http"; + } + + private static MCPServerConnection.Pending CreatePendingConnection(string name, ScopedMcpServerConfig config) + => new(name, config) { Name = name, Config = config }; + + private static MCPServerConnection.Failed CreateFailedConnection(string name, ScopedMcpServerConfig config, string? error) + => new(name, config, error) { Name = name, Config = config }; + + private static MCPServerConnection.Connected CreateConnectedConnection(string name, ScopedMcpServerConfig config, McpClient client) + => new(name, config, client, client.Capabilities, client.ServerInfo, null, () => client.DisposeAsync().AsTask()) { Name = name, Config = config }; + + private IEnumerable EnumerateConnections(string? serverName) + { + return serverName is null ? _connections.Values : _connections.TryGetValue(serverName, out var connection) ? [connection] : []; + } + + private void UpdateAppState() + { + if (_appStateStore is null) + { + return; + } + + _appStateStore.Update(state => + { + if (state is AppState appState) + { + return ReplaceMcpServers(appState, GetConnections()); + } + + return state; + }); + } + + private static AppState ReplaceMcpServers(AppState state, IReadOnlyList connections) + { + return state with + { + Mcp = state.Mcp with + { + Clients = connections.ToArray() + } + }; + } + + private sealed record ManagedConnection(string Name, ScopedMcpServerConfig Config, McpClient Client, MCPServerConnection Connection); + + private sealed class McpPromptCommandWrapper : ICommand + { + private readonly string _serverName; + private readonly McpClient _client; + private readonly McpPromptDefinition _prompt; + + public McpPromptCommandWrapper(string serverName, McpClient client, McpPromptDefinition prompt) + { + _serverName = serverName; + _client = client; + _prompt = prompt; + } + + public string Name => $"mcp-prompt-{Sanitize(_serverName)}-{Sanitize(_prompt.Name)}"; + public string[]? Aliases => null; + public string Description => _prompt.Description ?? $"MCP prompt '{_prompt.Name}' from {_serverName}."; + public CommandCategory Category => CommandCategory.Prompt; + public CommandAvailability Availability => CommandAvailability.Always; + public bool IsEnabled() => _client.IsConnected; + + public Task ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default) + { + var promptText = BuildPromptText(args); + return Task.FromResult(new CommandResult(true, promptText)); + } + + private string BuildPromptText(string? args) + { + var lines = new List + { + $"MCP prompt '{_prompt.Name}' from server '{_serverName}'." + }; + + if (!string.IsNullOrWhiteSpace(_prompt.Description)) + { + lines.Add(_prompt.Description!); + } + + if (_prompt.Arguments is { Count: > 0 }) + { + lines.Add("Arguments:"); + lines.AddRange(_prompt.Arguments.Select(argument => + $"- {argument.Name}{(argument.Required ? " (required)" : string.Empty)}{(string.IsNullOrWhiteSpace(argument.Description) ? string.Empty : $": {argument.Description}")}")); + } + + if (!string.IsNullOrWhiteSpace(args)) + { + lines.Add($"Provided args: {args}"); + } + + return string.Join(Environment.NewLine, lines); + } + } + + private static string Sanitize(string value) + => new string(value.ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : '-').ToArray()).Trim('-'); +} diff --git a/src/FreeCode.Mcp/McpToolWrapper.cs b/src/FreeCode.Mcp/McpToolWrapper.cs new file mode 100644 index 0000000..360f337 --- /dev/null +++ b/src/FreeCode.Mcp/McpToolWrapper.cs @@ -0,0 +1,54 @@ +using System.Text.Json; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using ITool = FreeCode.Core.Interfaces.ITool; + +namespace FreeCode.Mcp; + +public sealed class McpToolWrapper +{ + private readonly string _serverName; + private readonly McpClient _client; + private readonly McpToolDefinition _definition; + + public McpToolWrapper(string serverName, McpClient client, McpToolDefinition definition) + { + _serverName = serverName; + _client = client; + _definition = definition; + } + + public string Name => $"mcp__{Sanitize(_serverName)}__{_definition.Name}"; + + public ITool ToTool() => new ToolAdapter(_serverName, _client, _definition); + + private static string Sanitize(string value) + { + var chars = value.ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : '_').ToArray(); + return new string(chars); + } + + private sealed class ToolAdapter : ITool + { + private readonly string _serverName; + private readonly McpClient _client; + private readonly McpToolDefinition _definition; + + public ToolAdapter(string serverName, McpClient client, McpToolDefinition definition) + { + _serverName = serverName; + _client = client; + _definition = definition; + } + + public string Name => $"mcp__{Sanitize(_serverName)}__{_definition.Name}"; + public string[]? Aliases => null; + public string? SearchHint => _definition.Description; + public FreeCode.Core.Enums.ToolCategory Category => FreeCode.Core.Enums.ToolCategory.Mcp; + public bool IsEnabled() => _client.IsConnected; + public JsonElement GetInputSchema() => _definition.InputSchema.Clone(); + public Task GetDescriptionAsync(object? input = null) => Task.FromResult(_definition.Description ?? _definition.Name); + public bool IsConcurrencySafe(object input) => !_definition.HasDestructiveBehavior; + public bool IsReadOnly(object input) => !_definition.HasDestructiveBehavior; + } +} diff --git a/src/FreeCode.Mcp/McpTypes.cs b/src/FreeCode.Mcp/McpTypes.cs new file mode 100644 index 0000000..d1340df --- /dev/null +++ b/src/FreeCode.Mcp/McpTypes.cs @@ -0,0 +1,43 @@ +using System.Text.Json; + +namespace FreeCode.Mcp; + +public sealed record ServerCapabilities +{ + public bool Tools { get; init; } = true; + public bool Resources { get; init; } = true; + public bool Prompts { get; init; } + public bool Logging { get; init; } +} + +public sealed record ServerInfo(string Name, string Version); + +public sealed record ListToolsResult(List Tools); + +public sealed record McpToolDefinition( + string Name, + string? Description, + JsonElement InputSchema, + bool HasDestructiveBehavior); + +public sealed record CallToolResult(JsonElement Content, bool HasBinaryContent); + +public sealed record ListResourcesResult(List Resources); + +public sealed record ReadResourceResult(List Contents); + +public sealed record ListPromptsResult(List Prompts); + +public sealed record McpPromptDefinition( + string Name, + string? Description, + List? Arguments); + +public sealed record McpPromptArgument( + string Name, + string? Description, + bool Required); + +public sealed record ServerResource(string Uri, string Name, string? Description, string? MimeType); + +public sealed record ResourceContent(string Uri, string Text, string? MimeType); diff --git a/src/FreeCode.Mcp/ServiceCollectionExtensions.cs b/src/FreeCode.Mcp/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..1bd83d4 --- /dev/null +++ b/src/FreeCode.Mcp/ServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode.Mcp; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddFreeCodeMcp(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + return services; + } +} diff --git a/src/FreeCode.Mcp/SseTransport.cs b/src/FreeCode.Mcp/SseTransport.cs new file mode 100644 index 0000000..6de1b96 --- /dev/null +++ b/src/FreeCode.Mcp/SseTransport.cs @@ -0,0 +1,180 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading.Channels; + +namespace FreeCode.Mcp; + +public sealed class SseTransport : IMcpTransport +{ + private readonly HttpClient _httpClient; + private readonly string _url; + private readonly Channel _incoming = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = true + }); + private readonly SemaphoreSlim _writeLock = new(1, 1); + private readonly CancellationTokenSource _cts = new(); + private Task? _listenTask; + private string? _messageEndpoint; + + public SseTransport(HttpClient httpClient, string url) + { + _httpClient = httpClient; + _url = url; + } + + public ChannelReader IncomingLines => _incoming.Reader; + + public Task StartAsync(CancellationToken ct = default) + { + if (_listenTask is not null) + { + return Task.CompletedTask; + } + + var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token); + _listenTask = Task.Run(() => ListenAsync(linkedCts.Token), CancellationToken.None); + return Task.CompletedTask; + } + + public async Task SendLineAsync(string line, CancellationToken ct = default) + { + var endpoint = _messageEndpoint ?? throw new InvalidOperationException("SSE transport is not connected."); + + await _writeLock.WaitAsync(ct).ConfigureAwait(false); + try + { + using var content = JsonContent.Create(ParseJsonPayload(line)); + using var response = await _httpClient.PostAsync(endpoint, content, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + finally + { + _writeLock.Release(); + } + } + + private async Task ListenAsync(CancellationToken ct) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, _url); + request.Headers.Accept.Add(new("text/event-stream")); + using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + using var reader = new StreamReader(stream); + + string? eventName = null; + var dataLines = new List(); + + while (!ct.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(ct).ConfigureAwait(false); + if (line is null) + { + break; + } + + if (line.Length == 0) + { + await ProcessEventAsync(eventName, dataLines, ct).ConfigureAwait(false); + eventName = null; + dataLines.Clear(); + continue; + } + + if (line.StartsWith(':')) + { + continue; + } + + if (line.StartsWith("event:", StringComparison.OrdinalIgnoreCase)) + { + eventName = line[6..].Trim(); + continue; + } + + if (line.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + dataLines.Add(line[5..].TrimStart()); + } + } + + if (dataLines.Count > 0) + { + await ProcessEventAsync(eventName, dataLines, ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + } + finally + { + _incoming.Writer.TryComplete(); + } + } + + private async Task ProcessEventAsync(string? eventName, List dataLines, CancellationToken ct) + { + if (dataLines.Count == 0) + { + return; + } + + var payload = string.Join("\n", dataLines); + if (string.Equals(eventName, "endpoint", StringComparison.OrdinalIgnoreCase)) + { + _messageEndpoint = BuildEndpoint(payload); + return; + } + + if (!string.IsNullOrWhiteSpace(payload)) + { + await _incoming.Writer.WriteAsync(payload, ct).ConfigureAwait(false); + } + } + + private string BuildEndpoint(string payload) + { + if (Uri.TryCreate(payload, UriKind.Absolute, out var absolute)) + { + return absolute.ToString(); + } + + if (Uri.TryCreate(new Uri(_url), payload, out var relative)) + { + return relative.ToString(); + } + + return payload; + } + + private static object? ParseJsonPayload(string line) + { + using var document = JsonDocument.Parse(line); + return document.RootElement.Clone(); + } + + public async ValueTask DisposeAsync() + { + _cts.Cancel(); + + if (_listenTask is not null) + { + try + { + await _listenTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + /* listener cancelled during shutdown */ + } + } + + _writeLock.Dispose(); + _cts.Dispose(); + } +} diff --git a/src/FreeCode.Mcp/StdioTransport.cs b/src/FreeCode.Mcp/StdioTransport.cs new file mode 100644 index 0000000..bc63106 --- /dev/null +++ b/src/FreeCode.Mcp/StdioTransport.cs @@ -0,0 +1,223 @@ +using System.Diagnostics; +using System.Text; +using System.Threading.Channels; + +namespace FreeCode.Mcp; + +public interface IMcpTransport : IAsyncDisposable +{ + ChannelReader IncomingLines { get; } + Task StartAsync(CancellationToken ct = default); + Task SendLineAsync(string line, CancellationToken ct = default); +} + +public sealed class StdioTransport : IMcpTransport +{ + private readonly ProcessStartInfo _startInfo; + private readonly Channel _incoming = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleWriter = true, + SingleReader = true + }); + private readonly SemaphoreSlim _writeLock = new(1, 1); + private readonly CancellationTokenSource _cts = new(); + private Process? _process; + + public StdioTransport(ProcessStartInfo startInfo) + { + _startInfo = startInfo; + } + + public ChannelReader IncomingLines => _incoming.Reader; + + public async Task StartAsync(CancellationToken ct = default) + { + if (_process is not null) + { + return; + } + + _startInfo.UseShellExecute = false; + _startInfo.RedirectStandardInput = true; + _startInfo.RedirectStandardOutput = true; + _startInfo.RedirectStandardError = true; + + var process = new Process { StartInfo = _startInfo, EnableRaisingEvents = true }; + if (!process.Start()) + { + throw new InvalidOperationException("Failed to start MCP process."); + } + + _process = process; + _ = Task.Run(() => ReadLoopAsync(process, _cts.Token), CancellationToken.None); + _ = Task.Run(() => DrainErrorAsync(process, _cts.Token), CancellationToken.None); + await Task.CompletedTask.ConfigureAwait(false); + } + + public async Task SendLineAsync(string line, CancellationToken ct = default) + { + var process = _process ?? throw new InvalidOperationException("Transport is not started."); + await _writeLock.WaitAsync(ct).ConfigureAwait(false); + try + { + var payload = Encoding.UTF8.GetBytes(line); + var header = Encoding.ASCII.GetBytes($"Content-Length: {payload.Length}\r\n\r\n"); + await process.StandardInput.BaseStream.WriteAsync(header, ct).ConfigureAwait(false); + await process.StandardInput.BaseStream.WriteAsync(payload, ct).ConfigureAwait(false); + await process.StandardInput.BaseStream.FlushAsync(ct).ConfigureAwait(false); + } + finally + { + _writeLock.Release(); + } + } + + private async Task ReadLoopAsync(Process process, CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested && !process.HasExited) + { + var message = await ReadMessageAsync(process.StandardOutput.BaseStream, ct).ConfigureAwait(false); + if (message is null) + { + break; + } + + await _incoming.Writer.WriteAsync(message, ct).ConfigureAwait(false); + } + } + catch (Exception) + { + return; + } + finally + { + _incoming.Writer.TryComplete(); + } + } + + private static async Task DrainErrorAsync(Process process, CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested && !process.HasExited) + { + var line = await process.StandardError.ReadLineAsync().ConfigureAwait(false); + if (line is null) + { + break; + } + } + } + catch (Exception) + { + return; + } + } + + private static async Task ReadMessageAsync(Stream stream, CancellationToken ct) + { + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + while (true) + { + var line = await ReadLineAsync(stream, ct).ConfigureAwait(false); + if (line is null) + { + return null; + } + + if (line.Length == 0) + { + break; + } + + var separator = line.IndexOf(':'); + if (separator > 0) + { + headers[line[..separator].Trim()] = line[(separator + 1)..].Trim(); + } + } + + if (!headers.TryGetValue("Content-Length", out var lengthText) || !int.TryParse(lengthText, out var length)) + { + return null; + } + + var buffer = new byte[length]; + var offset = 0; + while (offset < length) + { + var read = await stream.ReadAsync(buffer.AsMemory(offset, length - offset), ct).ConfigureAwait(false); + if (read == 0) + { + break; + } + + offset += read; + } + + return Encoding.UTF8.GetString(buffer, 0, offset); + } + + private static async Task ReadLineAsync(Stream stream, CancellationToken ct) + { + var buffer = new List(); + while (true) + { + var value = await stream.ReadByteAsync(ct).ConfigureAwait(false); + if (value < 0) + { + return buffer.Count == 0 ? null : Encoding.UTF8.GetString(buffer.ToArray()); + } + + if (value == '\n') + { + break; + } + + if (value != '\r') + { + buffer.Add((byte)value); + } + } + + return Encoding.UTF8.GetString(buffer.ToArray()); + } + + public async ValueTask DisposeAsync() + { + _cts.Cancel(); + + try + { + if (_process is { HasExited: false }) + { + _process.StandardInput.Close(); + if (!_process.WaitForExit(5000)) + { + _process.Kill(entireProcessTree: true); + } + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: MCP transport shutdown failed: {ex.Message}"); + } + + _process?.Dispose(); + _writeLock.Dispose(); + _cts.Dispose(); + await Task.CompletedTask.ConfigureAwait(false); + } +} + +internal static class StreamExtensions +{ + public static async ValueTask ReadByteAsync(this Stream stream, CancellationToken ct) + { + var buffer = new byte[1]; + var read = await stream.ReadAsync(buffer.AsMemory(0, 1), ct).ConfigureAwait(false); + return read == 0 ? -1 : buffer[0]; + } +} diff --git a/src/FreeCode.Mcp/StreamableHttpTransport.cs b/src/FreeCode.Mcp/StreamableHttpTransport.cs new file mode 100644 index 0000000..d1a70a8 --- /dev/null +++ b/src/FreeCode.Mcp/StreamableHttpTransport.cs @@ -0,0 +1,129 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading.Channels; + +namespace FreeCode.Mcp; + +public sealed class StreamableHttpTransport : IMcpTransport +{ + private readonly HttpClient _httpClient; + private readonly string _url; + private readonly Channel _incoming = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = true + }); + private readonly SemaphoreSlim _writeLock = new(1, 1); + private string? _sessionId; + + public StreamableHttpTransport(HttpClient httpClient, string url) + { + _httpClient = httpClient; + _url = url; + } + + public ChannelReader IncomingLines => _incoming.Reader; + + public Task StartAsync(CancellationToken ct = default) => Task.CompletedTask; + + public async Task SendLineAsync(string line, CancellationToken ct = default) + { + await _writeLock.WaitAsync(ct).ConfigureAwait(false); + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, _url); + request.Headers.Accept.Add(new("application/json")); + request.Headers.Accept.Add(new("text/event-stream")); + + if (!string.IsNullOrWhiteSpace(_sessionId)) + { + request.Headers.Add("Mcp-Session-Id", _sessionId); + } + + request.Content = JsonContent.Create(ParseJsonPayload(line)); + + using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + CaptureSessionId(response); + await ReadResponseAsync(response, ct).ConfigureAwait(false); + } + finally + { + _writeLock.Release(); + } + } + + private void CaptureSessionId(HttpResponseMessage response) + { + if (response.Headers.TryGetValues("Mcp-Session-Id", out var values)) + { + _sessionId = values.FirstOrDefault(); + } + } + + private async Task ReadResponseAsync(HttpResponseMessage response, CancellationToken ct) + { + var mediaType = response.Content.Headers.ContentType?.MediaType; + if (string.Equals(mediaType, "text/event-stream", StringComparison.OrdinalIgnoreCase)) + { + await ReadSseResponseAsync(response, ct).ConfigureAwait(false); + return; + } + + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(json)) + { + await _incoming.Writer.WriteAsync(json, ct).ConfigureAwait(false); + } + } + + private async Task ReadSseResponseAsync(HttpResponseMessage response, CancellationToken ct) + { + await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + using var reader = new StreamReader(stream); + var dataLines = new List(); + + while (!ct.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(ct).ConfigureAwait(false); + if (line is null) + { + break; + } + + if (line.Length == 0) + { + if (dataLines.Count > 0) + { + await _incoming.Writer.WriteAsync(string.Join("\n", dataLines), ct).ConfigureAwait(false); + dataLines.Clear(); + } + + continue; + } + + if (line.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + dataLines.Add(line[5..].TrimStart()); + } + } + + if (dataLines.Count > 0) + { + await _incoming.Writer.WriteAsync(string.Join("\n", dataLines), ct).ConfigureAwait(false); + } + } + + private static object? ParseJsonPayload(string line) + { + using var document = JsonDocument.Parse(line); + return document.RootElement.Clone(); + } + + public ValueTask DisposeAsync() + { + _incoming.Writer.TryComplete(); + _writeLock.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/src/FreeCode.Mcp/WebSocketTransport.cs b/src/FreeCode.Mcp/WebSocketTransport.cs new file mode 100644 index 0000000..73936f3 --- /dev/null +++ b/src/FreeCode.Mcp/WebSocketTransport.cs @@ -0,0 +1,128 @@ +using System.Net.WebSockets; +using System.Text; +using System.Threading.Channels; + +namespace FreeCode.Mcp; + +public sealed class WebSocketTransport : IMcpTransport +{ + private readonly ClientWebSocket _webSocket = new(); + private readonly string _url; + private readonly Channel _incoming = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = true + }); + private readonly SemaphoreSlim _writeLock = new(1, 1); + private readonly CancellationTokenSource _cts = new(); + private Task? _receiveTask; + + public WebSocketTransport(string url, string? authToken = null, IReadOnlyDictionary? headers = null) + { + _url = url; + + if (!string.IsNullOrWhiteSpace(authToken)) + { + _webSocket.Options.SetRequestHeader("Authorization", $"Bearer {authToken}"); + } + + if (headers is not null) + { + foreach (var (key, value) in headers) + { + _webSocket.Options.SetRequestHeader(key, value); + } + } + } + + public ChannelReader IncomingLines => _incoming.Reader; + + public async Task StartAsync(CancellationToken ct = default) + { + if (_webSocket.State == WebSocketState.Open || _webSocket.State == WebSocketState.Connecting) + { + return; + } + + await _webSocket.ConnectAsync(new Uri(_url), ct).ConfigureAwait(false); + var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token); + _receiveTask = Task.Run(() => ReceiveLoopAsync(linkedCts.Token), CancellationToken.None); + } + + public async Task SendLineAsync(string line, CancellationToken ct = default) + { + if (_webSocket.State != WebSocketState.Open) + { + throw new InvalidOperationException("WebSocket transport is not connected."); + } + + await _writeLock.WaitAsync(ct).ConfigureAwait(false); + try + { + var buffer = Encoding.UTF8.GetBytes(line); + await _webSocket.SendAsync(buffer, WebSocketMessageType.Text, true, ct).ConfigureAwait(false); + } + finally + { + _writeLock.Release(); + } + } + + private async Task ReceiveLoopAsync(CancellationToken ct) + { + var buffer = new byte[8192]; + + try + { + while (!ct.IsCancellationRequested && _webSocket.State == WebSocketState.Open) + { + using var ms = new MemoryStream(); + WebSocketReceiveResult result; + do + { + result = await _webSocket.ReceiveAsync(buffer, ct).ConfigureAwait(false); + if (result.MessageType == WebSocketMessageType.Close) + { + return; + } + + await ms.WriteAsync(buffer.AsMemory(0, result.Count), ct).ConfigureAwait(false); + } while (!result.EndOfMessage); + + var message = Encoding.UTF8.GetString(ms.ToArray()); + if (!string.IsNullOrWhiteSpace(message)) + { + await _incoming.Writer.WriteAsync(message, ct).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + } + finally + { + _incoming.Writer.TryComplete(); + } + } + + public async ValueTask DisposeAsync() + { + _cts.Cancel(); + + if (_webSocket.State == WebSocketState.Open || _webSocket.State == WebSocketState.CloseReceived) + { + try + { + await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None).ConfigureAwait(false); + } + catch (WebSocketException) + { + /* connection already closed */ + } + } + + _writeLock.Dispose(); + _webSocket.Dispose(); + _cts.Dispose(); + } +} diff --git a/src/FreeCode.Plugins/FreeCode.Plugins.csproj b/src/FreeCode.Plugins/FreeCode.Plugins.csproj new file mode 100644 index 0000000..214e9fb --- /dev/null +++ b/src/FreeCode.Plugins/FreeCode.Plugins.csproj @@ -0,0 +1,10 @@ + + + FreeCode.Plugins + + + + + + + diff --git a/src/FreeCode.Plugins/PluginLoadContext.cs b/src/FreeCode.Plugins/PluginLoadContext.cs new file mode 100644 index 0000000..758845e --- /dev/null +++ b/src/FreeCode.Plugins/PluginLoadContext.cs @@ -0,0 +1,37 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace FreeCode.Plugins; + +public sealed class PluginLoadContext : AssemblyLoadContext +{ + private readonly AssemblyDependencyResolver _resolver; + private readonly string _pluginDirectory; + + public PluginLoadContext(string assemblyPath) + : base(isCollectible: true) + { + ArgumentException.ThrowIfNullOrWhiteSpace(assemblyPath); + + _resolver = new AssemblyDependencyResolver(assemblyPath); + _pluginDirectory = Path.GetDirectoryName(Path.GetFullPath(assemblyPath)) ?? AppContext.BaseDirectory; + } + + protected override Assembly? Load(AssemblyName assemblyName) + { + var localPath = Path.Combine(_pluginDirectory, $"{assemblyName.Name}.dll"); + if (File.Exists(localPath)) + { + return LoadFromAssemblyPath(localPath); + } + + var resolvedPath = _resolver.ResolveAssemblyToPath(assemblyName); + return resolvedPath is null ? null : LoadFromAssemblyPath(resolvedPath); + } + + protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) + { + var resolvedPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName); + return resolvedPath is null ? IntPtr.Zero : LoadUnmanagedDllFromPath(resolvedPath); + } +} diff --git a/src/FreeCode.Plugins/PluginManager.cs b/src/FreeCode.Plugins/PluginManager.cs new file mode 100644 index 0000000..00c9813 --- /dev/null +++ b/src/FreeCode.Plugins/PluginManager.cs @@ -0,0 +1,314 @@ +using System.Reflection; +using System.Text.Json; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Plugins; + +public sealed class PluginManager : IPluginManager +{ + private sealed record PluginHandle(LoadedPlugin Plugin, PluginLoadContext LoadContext); + + private readonly object _gate = new(); + private readonly Dictionary _loadedPlugins = new(StringComparer.OrdinalIgnoreCase); + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true }; + + public async Task LoadPluginsAsync() + { + var pluginRoot = GetPluginRoot(); + + lock (_gate) + { + UnloadAllLocked(); + } + + if (!Directory.Exists(pluginRoot)) + { + return; + } + + foreach (var pluginDirectory in Directory.EnumerateDirectories(pluginRoot)) + { + var plugin = TryLoadPlugin(pluginDirectory); + if (plugin is null) + { + continue; + } + + lock (_gate) + { + _loadedPlugins[plugin.Plugin.Id] = plugin; + } + + await Task.CompletedTask.ConfigureAwait(false); + } + } + + public Task UnloadPluginAsync(string pluginId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pluginId); + + PluginHandle? handle; + lock (_gate) + { + if (!_loadedPlugins.TryGetValue(pluginId, out handle)) + { + return Task.CompletedTask; + } + + _loadedPlugins.Remove(pluginId); + } + + handle.LoadContext.Unload(); + return Task.CompletedTask; + } + + public IReadOnlyList GetLoadedPlugins() + { + lock (_gate) + { + return _loadedPlugins.Values.Select(handle => (object)handle.Plugin).ToArray(); + } + } + + public IReadOnlyList GetPluginCommands() + { + var commands = new List(); + + lock (_gate) + { + foreach (var handle in _loadedPlugins.Values) + { + if (!handle.Plugin.IsEnabled) + { + continue; + } + + try + { + var assembly = handle.LoadContext.LoadFromAssemblyPath(handle.Plugin.AssemblyPath); + foreach (var type in assembly.GetTypes()) + { + if (!typeof(ICommand).IsAssignableFrom(type) || !type.IsClass || type.IsAbstract) + { + continue; + } + + if (Activator.CreateInstance(type) is ICommand command) + { + commands.Add(command); + } + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Failed to load commands from plugin '{handle.Plugin.Id}': {ex.Message}"); + } + } + } + + return commands; + } + + public Task InstallPluginAsync(string source) + { + ArgumentException.ThrowIfNullOrWhiteSpace(source); + return InstallPluginInternalAsync(source); + } + + public Task UninstallPluginAsync(string pluginId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pluginId); + + PluginHandle? handle; + lock (_gate) + { + if (!_loadedPlugins.TryGetValue(pluginId, out handle)) + { + return Task.CompletedTask; + } + + _loadedPlugins.Remove(pluginId); + } + + handle.LoadContext.Unload(); + + if (Directory.Exists(handle.Plugin.Source)) + { + foreach (var file in Directory.EnumerateFiles(handle.Plugin.Source, "*", SearchOption.AllDirectories)) + { + try + { + File.Delete(file); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Failed to delete plugin file '{file}': {ex.Message}"); + } + } + + try + { + Directory.Delete(handle.Plugin.Source, recursive: true); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Failed to delete plugin directory '{handle.Plugin.Source}': {ex.Message}"); + } + } + + return Task.CompletedTask; + } + + public Task EnablePluginAsync(string pluginId) + { + return UpdateEnabledStateAsync(pluginId, true); + } + + public Task DisablePluginAsync(string pluginId) + { + return UpdateEnabledStateAsync(pluginId, false); + } + + public Task RefreshAsync() + => LoadPluginsAsync(); + + public IReadOnlyList GetLoadedPluginDetails() + { + lock (_gate) + { + return _loadedPlugins.Values.Select(handle => handle.Plugin).ToArray(); + } + } + + private Task UpdateEnabledStateAsync(string pluginId, bool isEnabled) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pluginId); + + lock (_gate) + { + if (_loadedPlugins.TryGetValue(pluginId, out var handle)) + { + _loadedPlugins[pluginId] = handle with { Plugin = handle.Plugin with { IsEnabled = isEnabled } }; + } + } + + return Task.CompletedTask; + } + + private async Task InstallPluginInternalAsync(string source) + { + var pluginRoot = GetPluginRoot(); + Directory.CreateDirectory(pluginRoot); + + var sourcePath = Path.GetFullPath(source); + if (!Directory.Exists(sourcePath)) + { + throw new DirectoryNotFoundException(sourcePath); + } + + var pluginId = Path.GetFileName(sourcePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + var targetPath = Path.Combine(pluginRoot, pluginId); + if (Directory.Exists(targetPath)) + { + Directory.Delete(targetPath, true); + } + + CopyDirectory(sourcePath, targetPath); + await LoadPluginsAsync().ConfigureAwait(false); + + lock (_gate) + { + if (_loadedPlugins.TryGetValue(pluginId, out var handle)) + { + return handle.Plugin; + } + } + + throw new InvalidOperationException($"Failed to load plugin '{pluginId}'."); + } + + private static string GetPluginRoot() + { + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(userProfile, ".free-code", "plugins"); + } + + private static PluginHandle? TryLoadPlugin(string pluginDirectory) + { + var manifest = TryReadManifest(pluginDirectory); + var assemblyPath = FindPluginAssembly(pluginDirectory); + if (assemblyPath is null) + { + return null; + } + + var loadContext = new PluginLoadContext(assemblyPath); + _ = loadContext.LoadFromAssemblyPath(assemblyPath); + + var id = Path.GetFileName(pluginDirectory); + var plugin = new LoadedPlugin + { + Id = id, + Name = manifest?.Name ?? id, + Version = manifest?.Version ?? "1.0.0", + Source = pluginDirectory, + AssemblyPath = assemblyPath, + IsEnabled = true, + Manifest = manifest ?? new PluginManifest { Name = id, Version = "1.0.0" } + }; + + return new PluginHandle(plugin, loadContext); + } + + private static string? FindPluginAssembly(string pluginDirectory) + { + var dlls = Directory.EnumerateFiles(pluginDirectory, "*.dll", SearchOption.TopDirectoryOnly) + .OrderBy(path => Path.GetFileName(path), StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return dlls.FirstOrDefault(); + } + + private static PluginManifest? TryReadManifest(string pluginDirectory) + { + var manifestPath = Path.Combine(pluginDirectory, "plugin.json"); + if (!File.Exists(manifestPath)) + { + return null; + } + + try + { + var json = File.ReadAllText(manifestPath); + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch (JsonException) + { + return null; + } + } + + private void UnloadAllLocked() + { + var handles = _loadedPlugins.Values.ToArray(); + _loadedPlugins.Clear(); + + foreach (var handle in handles) + { + handle.LoadContext.Unload(); + } + } + + private static void CopyDirectory(string sourceDirectory, string destinationDirectory) + { + Directory.CreateDirectory(destinationDirectory); + foreach (var file in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(sourceDirectory, file); + var destinationFile = Path.Combine(destinationDirectory, relative); + Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!); + File.Copy(file, destinationFile, overwrite: true); + } + } +} diff --git a/src/FreeCode.Plugins/PluginTypes.cs b/src/FreeCode.Plugins/PluginTypes.cs new file mode 100644 index 0000000..65ce8c1 --- /dev/null +++ b/src/FreeCode.Plugins/PluginTypes.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using FreeCode.Core.Models; +using FreeCode.Skills; + +namespace FreeCode.Plugins; + +public sealed record LoadedPlugin +{ + public required string Id { get; init; } + + public required string Name { get; init; } + + public required string Version { get; init; } + + public required string Source { get; init; } + + public required string AssemblyPath { get; init; } + + public bool IsEnabled { get; init; } + + public PluginManifest Manifest { get; init; } = new(); +} + +public sealed record PluginManifest +{ + public string? Name { get; init; } + + public string? Version { get; init; } + + public IReadOnlyList Skills { get; init; } = []; + + public IReadOnlyList Commands { get; init; } = []; + + public IReadOnlyDictionary McpServers { get; init; } = new Dictionary(); +} diff --git a/src/FreeCode.Plugins/ServiceCollectionExtensions.cs b/src/FreeCode.Plugins/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..d6fa37a --- /dev/null +++ b/src/FreeCode.Plugins/ServiceCollectionExtensions.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode.Plugins; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddPlugins(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/src/FreeCode.Services/AuthService.cs b/src/FreeCode.Services/AuthService.cs new file mode 100644 index 0000000..f95facb --- /dev/null +++ b/src/FreeCode.Services/AuthService.cs @@ -0,0 +1,228 @@ +using System.Diagnostics; +using System.Net; +using System.Text.Json; +using FreeCode.Core.Interfaces; + +namespace FreeCode.Services; + +public sealed class AuthService(ISecureTokenStorage tokenStorage) : IAuthService +{ + private const string TokenKey = "oauth_token"; + private const string ProviderKey = "oauth_provider"; + private const string UserTypeKey = "auth_user_type"; + private readonly object _gate = new(); + + public event EventHandler? AuthStateChanged; + + public bool IsAuthenticated => !string.IsNullOrWhiteSpace(tokenStorage.Get(TokenKey)); + + public bool IsClaudeAiUser => string.Equals(tokenStorage.Get(UserTypeKey), "claude", StringComparison.OrdinalIgnoreCase); + + public bool IsInternalUser => string.Equals(tokenStorage.Get(UserTypeKey), "internal", StringComparison.OrdinalIgnoreCase); + + public async Task LoginAsync(string provider = "anthropic") + { + provider = string.IsNullOrWhiteSpace(provider) ? "anthropic" : provider.Trim(); + var callbackPort = GetCallbackPort(); + var callbackUrl = $"http://localhost:{callbackPort}/callback/"; + var authUrl = BuildAuthUrl(provider, callbackUrl); + + using var listener = new HttpListener(); + listener.Prefixes.Add(callbackUrl); + listener.Start(); + + OpenBrowser(authUrl); + + var context = await listener.GetContextAsync().ConfigureAwait(false); + var request = context.Request; + + var token = GetQueryValue(request.Url?.Query, "token") + ?? GetQueryValue(request.Url?.Query, "access_token") + ?? GetQueryValue(request.Url?.Query, "oauth_token"); + var code = GetQueryValue(request.Url?.Query, "code"); + + if (!string.IsNullOrWhiteSpace(code) && string.IsNullOrWhiteSpace(token)) + { + token = await ExchangeCodeForTokenAsync(code, callbackUrl, provider).ConfigureAwait(false); + } + + if (string.IsNullOrWhiteSpace(token)) + { + await WriteResponseAsync(context.Response, "Login failed. No access token was returned.").ConfigureAwait(false); + throw new InvalidOperationException($"OAuth login failed for provider '{provider}'."); + } + + tokenStorage.Set(TokenKey, token); + tokenStorage.Set(ProviderKey, provider); + tokenStorage.Set(UserTypeKey, ResolveUserType(provider)); + + await WriteResponseAsync(context.Response, "Login complete. You can return to the terminal.").ConfigureAwait(false); + RaiseAuthStateChanged(); + } + + public Task LogoutAsync() + { + tokenStorage.Remove(TokenKey); + tokenStorage.Remove(ProviderKey); + tokenStorage.Remove(UserTypeKey); + RaiseAuthStateChanged(); + return Task.CompletedTask; + } + + public Task GetOAuthTokenAsync() + => Task.FromResult(tokenStorage.Get(TokenKey)); + + public Task RefreshTokenAsync() + { + var token = tokenStorage.Get(TokenKey); + if (string.IsNullOrWhiteSpace(token)) + { + return Task.FromResult(false); + } + + tokenStorage.Set(TokenKey, token); + return Task.FromResult(true); + } + + private static int GetCallbackPort() + { + var envPort = Environment.GetEnvironmentVariable("FREE_CODE_OAUTH_CALLBACK_PORT"); + if (int.TryParse(envPort, out var port) && port is > 0 and < 65536) + { + return port; + } + + return 38465; + } + + private static string BuildAuthUrl(string provider, string callbackUrl) + { + var overrideUrl = Environment.GetEnvironmentVariable($"FREE_CODE_OAUTH_URL_{provider.ToUpperInvariant()}") + ?? Environment.GetEnvironmentVariable("FREE_CODE_OAUTH_URL"); + + if (!string.IsNullOrWhiteSpace(overrideUrl)) + { + return overrideUrl.Replace("{callback}", Uri.EscapeDataString(callbackUrl), StringComparison.OrdinalIgnoreCase); + } + + var baseUri = provider.Equals("openai", StringComparison.OrdinalIgnoreCase) + ? "https://auth.openai.com/oauth/authorize" + : "https://claude.ai/oauth/authorize"; + + return $"{baseUri}?response_type=code&redirect_uri={Uri.EscapeDataString(callbackUrl)}&client_id=free-code"; + } + + private static string ResolveUserType(string provider) + => provider.Equals("internal", StringComparison.OrdinalIgnoreCase) + ? "internal" + : provider.Equals("claude", StringComparison.OrdinalIgnoreCase) + ? "claude" + : provider.Equals("anthropic", StringComparison.OrdinalIgnoreCase) + ? "claude" + : "external"; + + private static async Task ExchangeCodeForTokenAsync(string code, string redirectUri, string provider) + { + var tokenUrl = provider.Equals("openai", StringComparison.OrdinalIgnoreCase) + ? "https://auth.openai.com/oauth/token" + : "https://claude.ai/oauth/token"; + + using var client = new HttpClient(); + var body = new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = redirectUri, + ["client_id"] = "free-code" + }; + + using var response = await client.PostAsync(tokenUrl, new FormUrlEncodedContent(body)).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + return null; + } + + var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + using var document = JsonDocument.Parse(json); + return document.RootElement.TryGetProperty("access_token", out var tokenElement) + ? tokenElement.GetString() + : null; + } + + private static string? GetQueryValue(string? query, string key) + { + if (string.IsNullOrWhiteSpace(query)) + { + return null; + } + + var trimmed = query.StartsWith('?') ? query[1..] : query; + foreach (var segment in trimmed.Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var parts = segment.Split('=', 2); + if (parts.Length != 2) + { + continue; + } + + var segmentKey = Uri.UnescapeDataString(parts[0]); + if (!string.Equals(segmentKey, key, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + return Uri.UnescapeDataString(parts[1]); + } + + return null; + } + + private static void OpenBrowser(string url) + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = OperatingSystem.IsMacOS() ? "open" + : OperatingSystem.IsWindows() ? "cmd" + : "xdg-open", + UseShellExecute = false, + RedirectStandardOutput = false, + RedirectStandardError = false, + }; + + if (OperatingSystem.IsWindows()) + { + startInfo.ArgumentList.Add("/c"); + startInfo.ArgumentList.Add("start"); + startInfo.ArgumentList.Add(""); + startInfo.ArgumentList.Add(url); + } + else + { + startInfo.ArgumentList.Add(url); + } + + using var process = Process.Start(startInfo); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to open browser: {ex.Message}"); + } + } + + private static async Task WriteResponseAsync(HttpListenerResponse response, string message) + { + response.StatusCode = (int)HttpStatusCode.OK; + response.ContentType = "text/html; charset=utf-8"; + + var payload = $"

{WebUtility.HtmlEncode(message)}

"; + var buffer = System.Text.Encoding.UTF8.GetBytes(payload); + response.ContentLength64 = buffer.Length; + + await response.OutputStream.WriteAsync(buffer).ConfigureAwait(false); + response.OutputStream.Close(); + } + + private void RaiseAuthStateChanged() => AuthStateChanged?.Invoke(this, EventArgs.Empty); +} diff --git a/src/FreeCode.Services/CompanionService.cs b/src/FreeCode.Services/CompanionService.cs new file mode 100644 index 0000000..f3b10d8 --- /dev/null +++ b/src/FreeCode.Services/CompanionService.cs @@ -0,0 +1,150 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Services; + +public sealed class CompanionService : ICompanionService +{ + public Companion Create(string seed) + { + var config = LoadCompanionConfig(); + var rng = new Mulberry32(HashSeed(seed)); + var species = Pick(rng, Enum.GetValues()); + var eye = Pick(rng, Enum.GetValues()); + var hat = Pick(rng, Enum.GetValues()); + var rarity = PickWeightedRarity(rng); + var name = string.IsNullOrWhiteSpace(config?.Name) + ? BuildName(rng) + : config.Name!; + + return new Companion(species, eye, hat, rarity, name); + } + + public Task GetCompanionAsync(string seed, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(Create(seed)); + } + + public Task GetCompanionResponseAsync(string seed, string prompt, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var companion = Create(seed); + return Task.FromResult($"{companion.Name} ({companion.Species}) says: {prompt}"); + } + + public Task UpdateCompanionAsync(string json, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var path = GetConfigPath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, json); + return Task.CompletedTask; + } + + private static Rarity PickWeightedRarity(Mulberry32 rng) + { + var roll = rng.NextDouble(); + return roll switch + { + < 0.45 => Rarity.Common, + < 0.70 => Rarity.Uncommon, + < 0.87 => Rarity.Rare, + < 0.96 => Rarity.Epic, + _ => Rarity.Legendary, + }; + } + + private static T Pick(Mulberry32 rng, T[] values) where T : struct, Enum + => values[rng.Next(values.Length)]; + + private static string BuildName(Mulberry32 rng) + { + ReadOnlySpan prefixes = ["L", "M", "N", "P", "R", "S", "T", "V", "Z"]; + ReadOnlySpan vowels = ["a", "e", "i", "o", "u", "ia", "oo", "ai"]; + ReadOnlySpan suffixes = ["lo", "mi", "ra", "to", "vi", "na", "so", "ra", "ly"]; + + var prefix = prefixes[rng.Next(prefixes.Length)]; + var vowel = vowels[rng.Next(vowels.Length)]; + var suffix = suffixes[rng.Next(suffixes.Length)]; + var extra = rng.NextDouble() > 0.7 ? suffixes[rng.Next(suffixes.Length)] : string.Empty; + + return string.Create(prefix.Length + vowel.Length + suffix.Length + extra.Length, (prefix, vowel, suffix, extra), static (span, state) => + { + var index = 0; + state.prefix.AsSpan().CopyTo(span[index..]); + index += state.prefix.Length; + state.vowel.AsSpan().CopyTo(span[index..]); + index += state.vowel.Length; + state.suffix.AsSpan().CopyTo(span[index..]); + index += state.suffix.Length; + state.extra.AsSpan().CopyTo(span[index..]); + }); + } + + private static CompanionConfig? LoadCompanionConfig() + { + var path = GetConfigPath(); + if (!File.Exists(path)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(File.ReadAllText(path), SourceGenerationContext.Default.CompanionConfig); + } + catch (JsonException) + { + return null; + } + } + + private static string GetConfigPath() + => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".free-code", "companion.json"); + + internal sealed record CompanionConfig(string? Name); + + private static uint HashSeed(string seed) + { + unchecked + { + var hash = 2166136261u; + foreach (var ch in seed ?? string.Empty) + { + hash ^= ch; + hash *= 16777619u; + } + + return hash == 0 ? 1u : hash; + } + } + + private sealed class Mulberry32 + { + private uint _state; + + public Mulberry32(uint seed) + { + _state = seed; + } + + public uint NextUInt() + { + unchecked + { + _state += 0x6D2B79F5u; + var t = _state; + t = (t ^ (t >> 15)) * (t | 1u); + t ^= t + ((t ^ (t >> 7)) * (t | 61u)); + return t ^ (t >> 14); + } + } + + public int Next(int maxExclusive) => (int)(NextUInt() % (uint)maxExclusive); + + public double NextDouble() => NextUInt() / (double)uint.MaxValue; + } +} diff --git a/src/FreeCode.Services/CoordinatorService.cs b/src/FreeCode.Services/CoordinatorService.cs new file mode 100644 index 0000000..0b281c0 --- /dev/null +++ b/src/FreeCode.Services/CoordinatorService.cs @@ -0,0 +1,300 @@ +using System.Text; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Services; + +public sealed class CoordinatorService : ICoordinatorService +{ + private readonly Lock _sync = new(); + private readonly Dictionary _workers = new(StringComparer.Ordinal); + private readonly Dictionary _workerResults = new(StringComparer.Ordinal); + private readonly Dictionary _teams = new(StringComparer.Ordinal); + private readonly IQueryEngine? _queryEngine; + + public CoordinatorService(IQueryEngine? queryEngine = null) + { + _queryEngine = queryEngine; + } + + public bool IsCoordinatorMode => true; + + public string? MatchSessionMode(SessionMode? sessionMode) + => sessionMode == SessionMode.Coordinator ? "coordinator" : null; + + public string BuildCoordinatorSystemPrompt(CoordinatorPromptContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var builder = new StringBuilder(); + builder.AppendLine("You are operating in coordinator mode."); + builder.AppendLine($"Working directory: {context.WorkingDirectory}"); + builder.AppendLine($"Scratchpad directory: {context.ScratchpadDirectory ?? "(none)"}"); + builder.AppendLine($"Allowed tools: {FormatList(context.AllowedTools)}"); + builder.AppendLine($"MCP servers: {FormatList(context.McpServerNames)}"); + builder.Append("Coordinate workers, distribute tasks, and consolidate results for the user."); + return builder.ToString(); + } + + public CoordinatorUserContext BuildWorkerContext(IReadOnlyList mcpClients, string? scratchpadDirectory = null) + { + ArgumentNullException.ThrowIfNull(mcpClients); + + var clientDescriptions = mcpClients.Count == 0 + ? "MCP clients: none" + : $"MCP clients: {string.Join(", ", mcpClients.Select(DescribeClient))}"; + + var systemPrompt = new StringBuilder() + .AppendLine("You are a worker managed by the coordinator.") + .AppendLine(clientDescriptions) + .Append($"Scratchpad directory: {scratchpadDirectory ?? "(none)"}") + .ToString(); + + return new CoordinatorUserContext(clientDescriptions, systemPrompt); + } + + public Task SpawnWorkerAsync(SpawnWorkerRequest request, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + ct.ThrowIfCancellationRequested(); + + var workerId = Guid.NewGuid().ToString("N"); + var worker = new WorkerHandle(workerId, request.Description, WorkerStatus.Running); + lock (_sync) + { + _workers[workerId] = worker; + } + + _ = Task.Run(async () => + { + try + { + if (_queryEngine is not null) + { + var result = new StringBuilder(); + var options = string.IsNullOrWhiteSpace(request.Model) ? null : new SubmitMessageOptions(Model: request.Model); + + await foreach (var msg in _queryEngine.SubmitMessageAsync(request.Prompt, options, CancellationToken.None).ConfigureAwait(false)) + { + if (msg is SDKMessage.AssistantMessage assistant && !string.IsNullOrEmpty(assistant.Text)) + { + result.AppendLine(assistant.Text); + } + } + + lock (_sync) + { + _workerResults[workerId] = result.ToString(); + if (_workers.TryGetValue(workerId, out var existingWorker)) + { + _workers[workerId] = existingWorker with { Status = WorkerStatus.Completed }; + } + } + } + else + { + lock (_sync) + { + _workerResults[workerId] = $"Worker completed: {request.Description}"; + if (_workers.TryGetValue(workerId, out var existingWorker)) + { + _workers[workerId] = existingWorker with { Status = WorkerStatus.Completed }; + } + } + } + } + catch (Exception ex) + { + lock (_sync) + { + _workerResults[workerId] = $"Worker failed: {ex.Message}"; + if (_workers.TryGetValue(workerId, out var existingWorker)) + { + _workers[workerId] = existingWorker with { Status = WorkerStatus.Failed }; + } + } + } + }, CancellationToken.None); + + return Task.FromResult(worker); + } + + public Task SendMessageAsync(string workerId, string message, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workerId); + ArgumentNullException.ThrowIfNull(message); + ct.ThrowIfCancellationRequested(); + + if (_queryEngine is null) + { + return Task.CompletedTask; + } + + lock (_sync) + { + if (_workers.TryGetValue(workerId, out var worker)) + { + _workers[workerId] = worker with { Status = WorkerStatus.Running }; + } + } + + return ForwardMessageAsync(workerId, message, options: null, ct); + } + + public Task StopWorkerAsync(string workerId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workerId); + ct.ThrowIfCancellationRequested(); + + lock (_sync) + { + if (_workers.TryGetValue(workerId, out var worker)) + { + _workers[workerId] = worker with { Status = WorkerStatus.Stopped }; + } + } + + return Task.CompletedTask; + } + + public Task GetWorkerResultAsync(string workerId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workerId); + + lock (_sync) + { + if (_workers.TryGetValue(workerId, out var worker) && worker.Status == WorkerStatus.Completed) + { + var output = _workerResults.GetValueOrDefault(workerId); + return Task.FromResult(new WorkerResult(worker.WorkerId, $"Worker {worker.Description} completed.", output, null)); + } + } + + return Task.FromResult(null); + } + + public Task CreateTeamAsync(CreateTeamRequest request, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + ct.ThrowIfCancellationRequested(); + + var team = new TeamHandle(Guid.NewGuid().ToString("N"), request.Name, request.WorkerIds.ToArray()); + lock (_sync) + { + _teams[team.TeamId] = team; + } + + return Task.FromResult(team); + } + + public Task DeleteTeamAsync(string teamId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(teamId); + ct.ThrowIfCancellationRequested(); + + lock (_sync) + { + _teams.Remove(teamId); + } + + return Task.CompletedTask; + } + + public Task SendTeamMessageAsync(string teamId, string message, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(teamId); + ArgumentNullException.ThrowIfNull(message); + ct.ThrowIfCancellationRequested(); + + TeamHandle? team; + lock (_sync) + { + _teams.TryGetValue(teamId, out team); + } + + var workerIds = team?.WorkerIds ?? ParseWorkerIds(teamId); + if (workerIds.Count == 0) + { + return Task.CompletedTask; + } + + return SendTeamMessagesAsync(workerIds, message, ct); + } + + private async Task SendTeamMessagesAsync(IReadOnlyList workerIds, string message, CancellationToken ct) + { + foreach (var workerId in workerIds) + { + ct.ThrowIfCancellationRequested(); + await SendMessageAsync(workerId, message, ct).ConfigureAwait(false); + } + } + + private async Task ForwardMessageAsync(string workerId, string message, SubmitMessageOptions? options, CancellationToken ct) + { + if (_queryEngine is null) + { + return; + } + + var result = new StringBuilder(); + + try + { + await foreach (var msg in _queryEngine.SubmitMessageAsync(message, options, ct).ConfigureAwait(false)) + { + if (msg is SDKMessage.AssistantMessage assistant && !string.IsNullOrEmpty(assistant.Text)) + { + result.AppendLine(assistant.Text); + } + } + + lock (_sync) + { + _workerResults[workerId] = result.ToString(); + if (_workers.TryGetValue(workerId, out var existingWorker)) + { + _workers[workerId] = existingWorker with { Status = WorkerStatus.Completed }; + } + } + } + catch (OperationCanceledException) + { + lock (_sync) + { + if (_workers.TryGetValue(workerId, out var existingWorker)) + { + _workers[workerId] = existingWorker with { Status = WorkerStatus.Stopped }; + } + } + + throw; + } + catch (Exception ex) + { + lock (_sync) + { + _workerResults[workerId] = $"Worker failed: {ex.Message}"; + if (_workers.TryGetValue(workerId, out var existingWorker)) + { + _workers[workerId] = existingWorker with { Status = WorkerStatus.Failed }; + } + } + + throw; + } + } + + private static string DescribeClient(object client) + => client?.ToString() ?? "unknown"; + + private static IReadOnlyList ParseWorkerIds(string value) + => value + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + private static string FormatList(IReadOnlyList values) + => values.Count == 0 ? "none" : string.Join(", ", values); +} diff --git a/src/FreeCode.Services/FreeCode.Services.csproj b/src/FreeCode.Services/FreeCode.Services.csproj new file mode 100644 index 0000000..1a8ce5c --- /dev/null +++ b/src/FreeCode.Services/FreeCode.Services.csproj @@ -0,0 +1,9 @@ + + + FreeCode.Services + + + + + + diff --git a/src/FreeCode.Services/KeychainTokenStorage.cs b/src/FreeCode.Services/KeychainTokenStorage.cs new file mode 100644 index 0000000..504b0bc --- /dev/null +++ b/src/FreeCode.Services/KeychainTokenStorage.cs @@ -0,0 +1,183 @@ +using System.Diagnostics; +using System.Collections.Concurrent; +using FreeCode.Core.Interfaces; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace FreeCode.Services; + +public sealed class KeychainTokenStorage : ISecureTokenStorage +{ + private static readonly ConcurrentDictionary FallbackStorage = new(StringComparer.Ordinal); + private static readonly string StorageRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".free-code", "tokens"); + + public string? Get(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + if (!OperatingSystem.IsMacOS()) + { + return FallbackStorage.TryGetValue(key, out var fallback) ? fallback : null; + } + + var output = RunSecurityCommand(["find-generic-password", "-s", GetServiceName(key), "-w"], out var exitCode); + if (exitCode == 0) + { + return string.IsNullOrWhiteSpace(output) ? null : output.TrimEnd(); + } + + return ReadEncryptedFile(key); + } + + public void Set(string key, string? value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + if (value is null) + { + Remove(key); + return; + } + + if (!OperatingSystem.IsMacOS()) + { + FallbackStorage[key] = value; + WriteEncryptedFile(key, value); + return; + } + + RunSecurityCommand(["add-generic-password", "-U", "-s", GetServiceName(key), "-p", value], out _); + } + + public void Remove(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + if (!OperatingSystem.IsMacOS()) + { + FallbackStorage.TryRemove(key, out _); + DeleteEncryptedFile(key); + return; + } + + RunSecurityCommand(["delete-generic-password", "-s", GetServiceName(key)], out _); + } + + public IReadOnlyList List() + { + var keys = new HashSet(StringComparer.Ordinal); + foreach (var key in FallbackStorage.Keys) + { + keys.Add(key); + } + + if (Directory.Exists(StorageRoot)) + { + foreach (var file in Directory.EnumerateFiles(StorageRoot, "*.json", SearchOption.TopDirectoryOnly)) + { + var key = Path.GetFileNameWithoutExtension(file); + if (!string.IsNullOrWhiteSpace(key)) + { + keys.Add(key); + } + } + } + + return keys.OrderBy(key => key, StringComparer.Ordinal).ToArray(); + } + + private static string GetServiceName(string key) => $"free-code-{key}"; + + private static string RunSecurityCommand(IReadOnlyList arguments, out int exitCode) + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = "security", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = Process.Start(startInfo); + if (process is null) + { + exitCode = -1; + return string.Empty; + } + + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + exitCode = process.ExitCode; + return string.IsNullOrWhiteSpace(output) ? error : output; + } + catch + { + exitCode = -1; + return string.Empty; + } + } + + private static string GetFilePath(string key) + => Path.Combine(StorageRoot, $"{Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key))).ToLowerInvariant()}.json"); + + private static void WriteEncryptedFile(string key, string value) + { + try + { + Directory.CreateDirectory(StorageRoot); + var payload = JsonSerializer.Serialize(new StoragePayload(key, value), SourceGenerationContext.Default.StoragePayload); + File.WriteAllText(GetFilePath(key), payload); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to write encrypted token storage: {ex.Message}"); + } + } + + private static string? ReadEncryptedFile(string key) + { + var path = GetFilePath(key); + if (!File.Exists(path)) + { + return null; + } + + try + { + var payload = JsonSerializer.Deserialize(File.ReadAllText(path), SourceGenerationContext.Default.StoragePayload); + return payload?.Key == key ? payload.Value : null; + } + catch + { + return null; + } + } + + private static void DeleteEncryptedFile(string key) + { + try + { + var path = GetFilePath(key); + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to delete encrypted token storage: {ex.Message}"); + } + } + + internal sealed record StoragePayload(string Key, string Value); +} diff --git a/src/FreeCode.Services/NotificationService.cs b/src/FreeCode.Services/NotificationService.cs new file mode 100644 index 0000000..3e2ad74 --- /dev/null +++ b/src/FreeCode.Services/NotificationService.cs @@ -0,0 +1,81 @@ +using System.Diagnostics; +using FreeCode.Core.Interfaces; + +namespace FreeCode.Services; + +public sealed class NotificationService : INotificationService +{ + public Task NotifyAsync(string title, string message, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(title); + ArgumentException.ThrowIfNullOrWhiteSpace(message); + ct.ThrowIfCancellationRequested(); + + try + { + if (OperatingSystem.IsMacOS()) + { + RunProcess("osascript", ["-e", $"display notification \"{EscapeAppleScript(message)}\" with title \"{EscapeAppleScript(title)}\""]); + } + else if (OperatingSystem.IsLinux()) + { + RunProcess("notify-send", [title, message]); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Notification failed: {ex.Message}"); + } + + return Task.CompletedTask; + } + + public Task PlaySoundAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + try + { + if (OperatingSystem.IsMacOS()) + { + RunProcess("afplay", ["/System/Library/Sounds/Glass.aiff"]); + } + else if (OperatingSystem.IsLinux()) + { + RunProcess("sh", ["-lc", "printf '\a'"]); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Notification failed: {ex.Message}"); + } + + return Task.CompletedTask; + } + + public bool IsSupportedTerminal() + => !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("TERM_PROGRAM")); + + private static void RunProcess(string fileName, IReadOnlyList args) + { + var psi = new ProcessStartInfo + { + FileName = fileName, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + foreach (var arg in args) + { + psi.ArgumentList.Add(arg); + } + + using var process = Process.Start(psi); + process?.WaitForExit(2000); + } + + private static string EscapeAppleScript(string value) + => value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal); +} diff --git a/src/FreeCode.Services/PermissionEngine.cs b/src/FreeCode.Services/PermissionEngine.cs new file mode 100644 index 0000000..02ef7f5 --- /dev/null +++ b/src/FreeCode.Services/PermissionEngine.cs @@ -0,0 +1,42 @@ +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Services; + +public sealed class PermissionEngine : IPermissionEngine +{ + public Task CheckAsync(string toolName, object input, ToolExecutionContext context) + { + ArgumentException.ThrowIfNullOrWhiteSpace(toolName); + + var tool = context.Services.GetService(typeof(ITool)) as ITool; + var isReadOnly = tool?.IsReadOnly(input) ?? false; + + if (context.PermissionMode == PermissionMode.AutoAccept) + { + return Task.FromResult(new PermissionResult(IsAllowed: true)); + } + + if (context.PermissionMode == PermissionMode.BypassPermissions) + { + return Task.FromResult(new PermissionResult(IsAllowed: true)); + } + + if (context.PermissionMode == PermissionMode.Plan) + { + return Task.FromResult(new PermissionResult( + IsAllowed: isReadOnly, + Reason: isReadOnly ? null : $"Tool '{toolName}' is not allowed in plan mode.")); + } + + if (isReadOnly) + { + return Task.FromResult(new PermissionResult(IsAllowed: true)); + } + + return Task.FromResult(new PermissionResult( + IsAllowed: false, + Reason: $"Tool '{toolName}' requires confirmation because it is not read-only.")); + } +} diff --git a/src/FreeCode.Services/RateLimitService.cs b/src/FreeCode.Services/RateLimitService.cs new file mode 100644 index 0000000..2c0e74a --- /dev/null +++ b/src/FreeCode.Services/RateLimitService.cs @@ -0,0 +1,190 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Text.Json; +using FreeCode.Core.Interfaces; + +namespace FreeCode.Services; + +public sealed class RateLimitService : IRateLimitService +{ + private sealed record UsageWindow(DateTimeOffset WindowStart, int RequestCount, int TokenCount); + + private static readonly TimeSpan DefaultWindow = TimeSpan.FromMinutes(1); + private readonly ConcurrentDictionary _usage = new(StringComparer.OrdinalIgnoreCase); + private readonly object _gate = new(); + private readonly int _maxRequests; + private readonly int _maxTokens; + private readonly TimeSpan _window; + + public RateLimitService() + { + _maxRequests = ReadIntSetting("FREE_CODE_RATE_LIMIT_REQUESTS", 60); + _maxTokens = ReadIntSetting("FREE_CODE_RATE_LIMIT_TOKENS", 120_000); + _window = ReadWindowSetting("FREE_CODE_RATE_LIMIT_WINDOW_SECONDS", DefaultWindow); + } + + public bool CanProceed(IDictionary headers) + { + if (GetRetryAfter(headers) is { } delay && delay > TimeSpan.Zero) + { + return false; + } + + foreach (var key in RemainingKeys) + { + if (!TryGetHeaderValue(headers, key, out var value)) + { + continue; + } + + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var remaining) && remaining <= 0) + { + return false; + } + } + + return true; + } + + public TimeSpan? GetRetryAfter(IDictionary headers) + { + ArgumentNullException.ThrowIfNull(headers); + + foreach (var key in RetryAfterKeys) + { + if (!TryGetHeaderValue(headers, key, out var value) || string.IsNullOrWhiteSpace(value)) + { + continue; + } + + if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds) && seconds >= 0) + { + return TimeSpan.FromSeconds(seconds); + } + + if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var resetAt)) + { + var delay = resetAt - DateTimeOffset.UtcNow; + return delay > TimeSpan.Zero ? delay : TimeSpan.Zero; + } + } + + return null; + } + + public Task RecordUsageAsync(string bucket, int tokenCount, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(bucket); + ct.ThrowIfCancellationRequested(); + + lock (_gate) + { + var now = DateTimeOffset.UtcNow; + var window = GetWindow(bucket, now); + if (now - window.WindowStart >= _window) + { + window = new UsageWindow(now, 0, 0); + } + + _usage[bucket] = window with + { + RequestCount = window.RequestCount + 1, + TokenCount = window.TokenCount + Math.Max(0, tokenCount) + }; + } + + return Task.CompletedTask; + } + + public Task CheckRateLimitAsync(string bucket, int tokenCost, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(bucket); + ct.ThrowIfCancellationRequested(); + + var now = DateTimeOffset.UtcNow; + var window = GetWindow(bucket, now); + if (now - window.WindowStart >= _window) + { + return Task.FromResult(true); + } + + var requestOk = window.RequestCount < _maxRequests; + var tokenOk = window.TokenCount + Math.Max(0, tokenCost) <= _maxTokens; + return Task.FromResult(requestOk && tokenOk); + } + + public Task GetRemainingAsync(string bucket, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(bucket); + ct.ThrowIfCancellationRequested(); + + var window = GetWindow(bucket, DateTimeOffset.UtcNow); + var remaining = Math.Max(0, Math.Min(_maxRequests - window.RequestCount, _maxTokens - window.TokenCount)); + return Task.FromResult(remaining); + } + + private static bool TryGetHeaderValue(IDictionary headers, string key, out string value) + { + foreach (var pair in headers) + { + if (!string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + value = pair.Value; + return true; + } + + value = string.Empty; + return false; + } + + private UsageWindow GetWindow(string bucket, DateTimeOffset now) + { + lock (_gate) + { + if (_usage.TryGetValue(bucket, out var window)) + { + if (now - window.WindowStart < _window) + { + return window; + } + } + + var fresh = new UsageWindow(now, 0, 0); + _usage[bucket] = fresh; + return fresh; + } + } + + private static int ReadIntSetting(string key, int fallback) + => int.TryParse(Environment.GetEnvironmentVariable(key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) && value > 0 + ? value + : fallback; + + private static TimeSpan ReadWindowSetting(string key, TimeSpan fallback) + => int.TryParse(Environment.GetEnvironmentVariable(key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds) && seconds > 0 + ? TimeSpan.FromSeconds(seconds) + : fallback; + + private static readonly string[] RemainingKeys = + [ + "x-ratelimit-remaining", + "x-ratelimit-remaining-requests", + "x-ratelimit-remaining-tokens", + "anthropic-ratelimit-remaining-requests", + "anthropic-ratelimit-remaining-tokens", + "rate-limit-remaining" + ]; + + private static readonly string[] RetryAfterKeys = + [ + "retry-after", + "x-ratelimit-reset-after", + "x-ratelimit-reset-requests", + "x-ratelimit-reset-tokens", + "anthropic-ratelimit-reset-requests", + "anthropic-ratelimit-reset-tokens" + ]; +} diff --git a/src/FreeCode.Services/RemoteSessionManager.cs b/src/FreeCode.Services/RemoteSessionManager.cs new file mode 100644 index 0000000..624d14a --- /dev/null +++ b/src/FreeCode.Services/RemoteSessionManager.cs @@ -0,0 +1,171 @@ +using System.Net.WebSockets; +using System.Runtime.CompilerServices; +using System.Text; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Services; + +public sealed class RemoteSessionManager : IRemoteSessionManager +{ + private readonly SemaphoreSlim _sync = new(1, 1); + private ClientWebSocket? _socket; + private string? _endpoint; + private string? _sessionId; + private bool _isConnected; + + public async Task ConnectAsync(string endpoint, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); + + await _sync.WaitAsync(ct).ConfigureAwait(false); + try + { + await DisconnectInternalAsync(CancellationToken.None).ConfigureAwait(false); + + var socket = new ClientWebSocket(); + await socket.ConnectAsync(new Uri(endpoint), ct).ConfigureAwait(false); + + _socket = socket; + _endpoint = endpoint; + _sessionId = Guid.NewGuid().ToString("N"); + _isConnected = socket.State == WebSocketState.Open; + } + finally + { + _sync.Release(); + } + } + + public async Task DisconnectAsync(CancellationToken ct = default) + { + await _sync.WaitAsync(ct).ConfigureAwait(false); + try + { + await DisconnectInternalAsync(ct).ConfigureAwait(false); + } + finally + { + _sync.Release(); + } + } + + public async IAsyncEnumerable ReadEventsAsync([EnumeratorCancellation] CancellationToken ct = default) + { + ClientWebSocket? socket; + string? sessionId; + + await _sync.WaitAsync(ct).ConfigureAwait(false); + try + { + socket = _socket; + sessionId = _sessionId; + if (!_isConnected || socket is null || socket.State != WebSocketState.Open) + { + yield break; + } + } + finally + { + _sync.Release(); + } + + if (!string.IsNullOrWhiteSpace(sessionId)) + { + yield return new RemoteConnectedEvent(DateTimeOffset.UtcNow, sessionId); + } + + var buffer = new byte[4096]; + var builder = new StringBuilder(); + + while (!ct.IsCancellationRequested) + { + WebSocketReceiveResult result; + + try + { + result = await socket.ReceiveAsync(new ArraySegment(buffer), ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + yield break; + } + catch (WebSocketException) + { + await DisconnectAsync(CancellationToken.None).ConfigureAwait(false); + yield break; + } + catch (ObjectDisposedException) + { + await DisconnectAsync(CancellationToken.None).ConfigureAwait(false); + yield break; + } + + if (result.MessageType == WebSocketMessageType.Close) + { + var reason = result.CloseStatusDescription ?? "Remote endpoint closed the connection."; + await DisconnectAsync(CancellationToken.None).ConfigureAwait(false); + yield return new RemoteDisconnectedEvent(DateTimeOffset.UtcNow, reason); + yield break; + } + + if (result.Count > 0) + { + builder.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); + } + + if (!result.EndOfMessage) + { + continue; + } + + var message = builder.ToString(); + builder.Clear(); + + if (!string.IsNullOrEmpty(message)) + { + yield return new RemoteMessageEvent(DateTimeOffset.UtcNow, message); + } + } + } + + private async Task DisconnectInternalAsync(CancellationToken ct) + { + var socket = _socket; + + _socket = null; + _isConnected = false; + _endpoint = null; + _sessionId = null; + + if (socket is null) + { + return; + } + + try + { + if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disconnect", ct).ConfigureAwait(false); + } + else if (socket.State == WebSocketState.CloseSent) + { + await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Disconnect", ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (!ct.CanBeCanceled || ct == CancellationToken.None) + { + } + catch (WebSocketException) + { + } + catch (ObjectDisposedException) + { + } + finally + { + socket.Dispose(); + } + } +} diff --git a/src/FreeCode.Services/ServiceCollectionExtensions.cs b/src/FreeCode.Services/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..f383c23 --- /dev/null +++ b/src/FreeCode.Services/ServiceCollectionExtensions.cs @@ -0,0 +1,23 @@ +using FreeCode.Core.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode.Services; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddServices(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/FreeCode.Services/SessionMemoryService.cs b/src/FreeCode.Services/SessionMemoryService.cs new file mode 100644 index 0000000..da21ae8 --- /dev/null +++ b/src/FreeCode.Services/SessionMemoryService.cs @@ -0,0 +1,241 @@ +using System.Text; +using System.Text.Json; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Services; + +public sealed class SessionMemoryService : ISessionMemoryService +{ + private const int TokenThreshold = 4000; + private const int ToolCallThreshold = 8; + private readonly object _gate = new(); + private readonly string _memoryRoot; + private readonly List _entries = new(); + private string? _currentMemory; + + public SessionMemoryService() + { + _memoryRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".free-code", "memory"); + Directory.CreateDirectory(_memoryRoot); + LoadMemoryFromDisk(); + } + + public Task GetCurrentMemoryAsync() + { + lock (_gate) + { + return Task.FromResult(_currentMemory); + } + } + + public Task TryExtractAsync(IReadOnlyList messages) + { + ArgumentNullException.ThrowIfNull(messages); + + var estimatedTokens = EstimateTokens(messages); + var toolCalls = messages.Count(message => !string.IsNullOrWhiteSpace(message.ToolUseId) || !string.IsNullOrWhiteSpace(message.ToolName)); + + if (estimatedTokens < TokenThreshold && toolCalls < ToolCallThreshold) + { + return Task.CompletedTask; + } + + var memory = BuildMemorySummary(messages, estimatedTokens, toolCalls); + lock (_gate) + { + _currentMemory = memory; + var entry = new MemoryEntry + { + Id = Guid.NewGuid().ToString("N"), + Type = MemoryType.Session, + SessionId = "current", + Content = memory, + CreatedAt = DateTimeOffset.UtcNow, + Tags = ["session", "summary"] + }; + + _entries.Add(entry); + SaveEntry(entry); + } + + return Task.CompletedTask; + } + + public Task AddMemoryAsync(string sessionId, string content, IEnumerable? tags = null, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(content); + ct.ThrowIfCancellationRequested(); + + var entry = new MemoryEntry + { + Id = Guid.NewGuid().ToString("N"), + Type = MemoryType.Session, + SessionId = sessionId, + Content = content, + CreatedAt = DateTimeOffset.UtcNow, + Tags = tags?.Where(tag => !string.IsNullOrWhiteSpace(tag)).Select(tag => tag.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray() ?? [] + }; + + lock (_gate) + { + _entries.Add(entry); + _currentMemory = content; + SaveEntry(entry); + } + + return Task.CompletedTask; + } + + public Task> SearchMemoryAsync(string keyword, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(keyword); + ct.ThrowIfCancellationRequested(); + + lock (_gate) + { + var results = _entries + .Where(entry => entry.Content.Contains(keyword, StringComparison.OrdinalIgnoreCase) + || entry.Tags.Any(tag => tag.Contains(keyword, StringComparison.OrdinalIgnoreCase))) + .OrderByDescending(entry => entry.CreatedAt) + .ToArray(); + + return Task.FromResult>(results); + } + } + + public Task ClearMemoryAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_gate) + { + _entries.Clear(); + _currentMemory = null; + if (Directory.Exists(_memoryRoot)) + { + foreach (var file in Directory.EnumerateFiles(_memoryRoot, "*.json", SearchOption.TopDirectoryOnly)) + { + try + { + File.Delete(file); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to delete memory file '{file}': {ex.Message}"); + } + } + } + } + + return Task.CompletedTask; + } + + private static int EstimateTokens(IReadOnlyList messages) + { + var tokens = 0; + foreach (var message in messages) + { + tokens += EstimateTokens(message.Content); + tokens += string.IsNullOrWhiteSpace(message.ToolUseId) ? 0 : 8; + tokens += string.IsNullOrWhiteSpace(message.ToolName) ? 0 : 4; + } + + return tokens; + } + + private static int EstimateTokens(object? content) + => content switch + { + null => 0, + string text => Math.Max(1, text.Length / 4), + JsonElement element => Math.Max(1, element.ToString().Length / 4), + _ => Math.Max(1, (content.ToString()?.Length ?? 0) / 4), + }; + + private static string BuildMemorySummary(IReadOnlyList messages, int estimatedTokens, int toolCalls) + { + var builder = new StringBuilder(); + builder.AppendLine($"Session summary ({estimatedTokens} tokens, {toolCalls} tool calls):"); + + foreach (var message in messages.TakeLast(8)) + { + builder.Append("- "); + builder.Append(message.Role); + if (!string.IsNullOrWhiteSpace(message.ToolName)) + { + builder.Append($" [{message.ToolName}]"); + } + + var text = message.Content?.ToString(); + if (!string.IsNullOrWhiteSpace(text)) + { + builder.Append(": "); + builder.Append(text.Length > 160 ? text[..160] + "…" : text); + } + + builder.AppendLine(); + } + + return builder.ToString().TrimEnd(); + } + + private void LoadMemoryFromDisk() + { + if (!Directory.Exists(_memoryRoot)) + { + return; + } + + foreach (var path in Directory.EnumerateFiles(_memoryRoot, "*.json", SearchOption.TopDirectoryOnly)) + { + try + { + var entry = JsonSerializer.Deserialize(File.ReadAllText(path), SourceGenerationContext.Default.MemoryEntry); + if (entry is null) + { + continue; + } + + _entries.Add(entry); + _currentMemory ??= entry.Content; + } + catch (JsonException) + { + continue; + } + } + } + + private void SaveEntry(MemoryEntry entry) + { + try + { + Directory.CreateDirectory(_memoryRoot); + var path = Path.Combine(_memoryRoot, $"{entry.CreatedAt.UtcDateTime:yyyyMMddHHmmssfff}-{entry.Id}.json"); + File.WriteAllText(path, JsonSerializer.Serialize(entry, SourceGenerationContext.Default.MemoryEntry)); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to save memory entry '{entry.Id}': {ex.Message}"); + } + } + + public sealed record MemoryEntry + { + public required string Id { get; init; } + public required MemoryType Type { get; init; } + public required string SessionId { get; init; } + public required string Content { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public IReadOnlyList Tags { get; init; } = []; + } + + public enum MemoryType + { + Session, + Dream, + Team + } +} diff --git a/src/FreeCode.Services/SourceGenerationContext.cs b/src/FreeCode.Services/SourceGenerationContext.cs new file mode 100644 index 0000000..4079777 --- /dev/null +++ b/src/FreeCode.Services/SourceGenerationContext.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace FreeCode.Services; + +[JsonSourceGenerationOptions(WriteIndented = true, PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(SessionMemoryService.MemoryEntry))] +[JsonSerializable(typeof(KeychainTokenStorage.StoragePayload))] +[JsonSerializable(typeof(CompanionService.CompanionConfig))] +internal sealed partial class SourceGenerationContext : JsonSerializerContext +{ +} diff --git a/src/FreeCode.Services/VoiceService.cs b/src/FreeCode.Services/VoiceService.cs new file mode 100644 index 0000000..ad48266 --- /dev/null +++ b/src/FreeCode.Services/VoiceService.cs @@ -0,0 +1,221 @@ +using FreeCode.Core.Interfaces; +using System.Diagnostics; + +namespace FreeCode.Services; + +public sealed class VoiceService : IVoiceService +{ + private readonly object _gate = new(); + private Process? _process; + private string? _recordingPath; + + public bool IsAvailable => FindRecorderCommand() is not null; + + public Task StartAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + if (!IsAvailable) + { + return Task.CompletedTask; + } + + lock (_gate) + { + if (_process is { HasExited: false }) + { + return Task.CompletedTask; + } + + _recordingPath = Path.Combine(Path.GetTempPath(), $"free-code-voice-{Guid.NewGuid():N}.wav"); + _process = StartRecorder(_recordingPath); + } + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_gate) + { + if (_process is null) + { + return Task.CompletedTask; + } + + try + { + if (!_process.HasExited) + { + _process.Kill(entireProcessTree: true); + _process.WaitForExit(2000); + } + } + catch (InvalidOperationException) + { + if (!_process.HasExited) + { + throw; + } + } + finally + { + _process.Dispose(); + _process = null; + } + } + + return Task.CompletedTask; + } + + public Task RecognizeAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var recordingPath = _recordingPath; + if (string.IsNullOrWhiteSpace(recordingPath) || !File.Exists(recordingPath)) + { + return Task.FromResult(null); + } + + var transcription = Environment.GetEnvironmentVariable("FREE_CODE_VOICE_TRANSCRIPTION")?.Trim(); + if (!string.IsNullOrWhiteSpace(transcription) && File.Exists(transcription)) + { + return Task.FromResult(File.ReadAllText(transcription)); + } + + return Task.FromResult(TranscribeWithExternalCommand(recordingPath)); + } + + public Task StartRecordingAsync(CancellationToken ct = default) => StartAsync(ct); + + public Task StopRecordingAsync(CancellationToken ct = default) + { + return StopAsync(ct).ContinueWith(_ => RecognizeAsync(ct), ct).Unwrap(); + } + + public Task TranscribeAsync(string audioPath, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(audioPath); + ct.ThrowIfCancellationRequested(); + return Task.FromResult(TranscribeWithExternalCommand(audioPath)); + } + + private static Process? StartRecorder(string outputPath) + { + var recorder = FindRecorderCommand(); + if (recorder is null) + { + return null; + } + + var (fileName, args) = recorder.Value.command switch + { + "sox" => ("sox", $"-q -d \"{outputPath}\""), + "ffmpeg" => ("ffmpeg", $"-y -f avfoundation -i :0 \"{outputPath}\""), + _ => (recorder.Value.command, recorder.Value.args) + }; + + var psi = new ProcessStartInfo + { + FileName = fileName, + Arguments = args, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + try + { + return Process.Start(psi); + } + catch + { + return null; + } + } + + private static string TranscribeWithExternalCommand(string audioPath) + { + var whisper = Environment.GetEnvironmentVariable("FREE_CODE_WHISPER_COMMAND"); + if (!string.IsNullOrWhiteSpace(whisper)) + { + var psi = new ProcessStartInfo + { + FileName = whisper, + Arguments = $"\"{audioPath}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + try + { + using var process = Process.Start(psi); + if (process is null) + { + return string.Empty; + } + + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + return output.Trim(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Voice transcription failed: {ex.Message}"); + } + } + + return File.ReadAllText(audioPath); + } + + private static (string command, string args)? FindRecorderCommand() + { + foreach (var candidate in new[] { "sox", "ffmpeg" }) + { + if (CommandExists(candidate)) + { + return candidate switch + { + "sox" => ("sox", string.Empty), + "ffmpeg" => ("ffmpeg", string.Empty), + _ => null + }; + } + } + + return null; + } + + private static bool CommandExists(string command) + { + try + { + var psi = new ProcessStartInfo + { + FileName = OperatingSystem.IsWindows() ? "where" : "sh", + Arguments = OperatingSystem.IsWindows() ? command : $"-lc \"command -v {command}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + using var process = Process.Start(psi); + if (process is null) + { + return false; + } + + process.WaitForExit(1000); + return process.ExitCode == 0; + } + catch + { + return false; + } + } +} diff --git a/src/FreeCode.Skills/FreeCode.Skills.csproj b/src/FreeCode.Skills/FreeCode.Skills.csproj new file mode 100644 index 0000000..3beb078 --- /dev/null +++ b/src/FreeCode.Skills/FreeCode.Skills.csproj @@ -0,0 +1,10 @@ + + + FreeCode.Skills + + + + + + + diff --git a/src/FreeCode.Skills/ServiceCollectionExtensions.cs b/src/FreeCode.Skills/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..ab61bfb --- /dev/null +++ b/src/FreeCode.Skills/ServiceCollectionExtensions.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode.Skills; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddSkills(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/src/FreeCode.Skills/SkillLoader.cs b/src/FreeCode.Skills/SkillLoader.cs new file mode 100644 index 0000000..474d49e --- /dev/null +++ b/src/FreeCode.Skills/SkillLoader.cs @@ -0,0 +1,406 @@ +using FreeCode.Core.Interfaces; + +namespace FreeCode.Skills; + +public sealed class SkillLoader : ISkillLoader +{ + private readonly object _gate = new(); + private readonly Dictionary> _cache = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _skills = new(StringComparer.OrdinalIgnoreCase); + + public Task> LoadSkillsAsync(string? directory = null) + { + var skills = LoadSkillDefinitions(directory).Cast().ToArray(); + return Task.FromResult>(skills); + } + + public Task ReloadAsync() + { + lock (_gate) + { + _cache.Clear(); + _skills.Clear(); + } + + return Task.CompletedTask; + } + + public Task> LoadAllSkillsAsync(string? directory = null) + => Task.FromResult(LoadSkillDefinitions(directory)); + + public Task LoadSkillAsync(string name, string? directory = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + lock (_gate) + { + if (_skills.TryGetValue(name, out var cached)) + { + return Task.FromResult(cached); + } + } + + var skill = LoadSkillDefinitions(directory) + .FirstOrDefault(candidate => string.Equals(candidate.Name, name, StringComparison.OrdinalIgnoreCase)); + + return Task.FromResult(skill); + } + + public Task ExecuteSkillAsync( + SkillDefinition skill, + IReadOnlyDictionary? arguments = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(skill); + ct.ThrowIfCancellationRequested(); + + var content = skill.Content; + if (arguments is not null) + { + foreach (var pair in arguments) + { + content = content.Replace($"{{{{{pair.Key}}}}}", pair.Value, StringComparison.OrdinalIgnoreCase); + } + } + + return Task.FromResult(content); + } + + public Task GetSkillAsync(string name, string? directory = null) + => LoadSkillAsync(name, directory); + + public Task> GetAllSkillsAsync(string? directory = null) + => Task.FromResult(LoadSkillDefinitions(directory)); + + public Task MatchSkillAsync(string input, string? directory = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(input); + var skills = LoadSkillDefinitions(directory); + + var match = skills.FirstOrDefault(skill => + skill.Tools.Any(tool => input.Contains(tool, StringComparison.OrdinalIgnoreCase)) || + input.Contains(skill.Name, StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrWhiteSpace(skill.Description) && input.Contains(skill.Description, StringComparison.OrdinalIgnoreCase))); + + return Task.FromResult(match); + } + + private IReadOnlyList LoadSkillDefinitions(string? directory) + { + var cacheKey = NormalizeCacheKey(directory); + + lock (_gate) + { + if (_cache.TryGetValue(cacheKey, out var cached)) + { + return cached; + } + } + + var skills = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var skillDirectory in GetSkillDirectories(directory)) + { + if (!Directory.Exists(skillDirectory)) + { + continue; + } + + foreach (var filePath in Directory.EnumerateFiles(skillDirectory, "*.md", SearchOption.AllDirectories)) + { + var skill = ParseSkill(filePath); + skills[skill.Name] = skill; + } + } + + var loaded = skills.Values.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToArray(); + + lock (_gate) + { + _cache[cacheKey] = loaded; + foreach (var skill in loaded) + { + _skills[skill.Name] = skill; + } + } + + return loaded; + } + + private static string NormalizeCacheKey(string? directory) + => string.IsNullOrWhiteSpace(directory) ? $"current:{Directory.GetCurrentDirectory()}" : Path.GetFullPath(directory); + + private static IEnumerable GetSkillDirectories(string? directory) + { + var startingDirectory = string.IsNullOrWhiteSpace(directory) ? Directory.GetCurrentDirectory() : Path.GetFullPath(directory); + var current = new DirectoryInfo(startingDirectory); + var yielded = new HashSet(StringComparer.OrdinalIgnoreCase); + + while (current is not null) + { + var candidate = Path.Combine(current.FullName, ".free-code", "skills"); + if (yielded.Add(candidate)) + { + yield return candidate; + } + + current = current.Parent; + } + + var userSkills = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".free-code", "skills"); + if (yielded.Add(userSkills)) + { + yield return userSkills; + } + } + + private static SkillDefinition ParseSkill(string filePath) + { + var raw = File.ReadAllText(filePath); + var normalized = raw.Replace("\r\n", "\n", StringComparison.Ordinal); + var lines = normalized.Split('\n'); + + var frontMatter = new Dictionary(StringComparer.OrdinalIgnoreCase); + var contentStartIndex = 0; + + if (lines.Length > 0 && string.Equals(lines[0].Trim(), "---", StringComparison.Ordinal)) + { + var frontMatterEnd = Array.FindIndex(lines, 1, line => string.Equals(line.Trim(), "---", StringComparison.Ordinal)); + if (frontMatterEnd > 0) + { + for (var i = 1; i < frontMatterEnd; i++) + { + var line = lines[i]; + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + var separatorIndex = line.IndexOf(':'); + if (separatorIndex <= 0) + { + continue; + } + + var key = line[..separatorIndex].Trim(); + if (string.IsNullOrWhiteSpace(key)) + { + continue; + } + + var value = line[(separatorIndex + 1)..].Trim(); + if (!string.IsNullOrWhiteSpace(value)) + { + frontMatter[key] = Unquote(value); + continue; + } + + var nestedLines = new List(); + var nestedIndex = i + 1; + while (nestedIndex < frontMatterEnd && (string.IsNullOrWhiteSpace(lines[nestedIndex]) || lines[nestedIndex].StartsWith(" ", StringComparison.Ordinal))) + { + nestedLines.Add(lines[nestedIndex]); + nestedIndex++; + } + + frontMatter[key] = ParseNestedValue(key, nestedLines); + i = nestedIndex - 1; + } + + contentStartIndex = frontMatterEnd + 1; + } + } + + var content = contentStartIndex < lines.Length + ? string.Join('\n', lines[contentStartIndex..]).TrimStart('\n') + : string.Empty; + + var name = frontMatter.TryGetValue("name", out var rawName) && rawName is string nameValue && !string.IsNullOrWhiteSpace(nameValue) + ? nameValue + : Path.GetFileNameWithoutExtension(filePath); + + var description = frontMatter.TryGetValue("description", out var rawDescription) ? rawDescription as string : null; + var model = frontMatter.TryGetValue("model", out var rawModel) ? rawModel as string : null; + + return new SkillDefinition + { + Name = name, + Description = string.IsNullOrWhiteSpace(description) ? null : description, + Content = content, + Tools = ParseTools(frontMatter), + Model = string.IsNullOrWhiteSpace(model) ? null : model, + Arguments = ParseArguments(frontMatter), + Hooks = ParseHooks(frontMatter), + FilePath = filePath + }; + } + + private static object ParseNestedValue(string key, IReadOnlyList lines) + { + if (string.Equals(key, "tools", StringComparison.OrdinalIgnoreCase)) + { + return ParseStringArray(lines); + } + + if (string.Equals(key, "arguments", StringComparison.OrdinalIgnoreCase)) + { + return ParseArgumentArray(lines); + } + + if (string.Equals(key, "hooks", StringComparison.OrdinalIgnoreCase)) + { + return ParseStringMap(lines, 2); + } + + return string.Join('\n', lines.Select(line => line.TrimEnd()).Where(line => !string.IsNullOrWhiteSpace(line))); + } + + private static IReadOnlyList ParseStringArray(IReadOnlyList lines) + { + var values = new List(); + foreach (var line in lines) + { + if (!line.StartsWith(" - ", StringComparison.Ordinal)) + { + continue; + } + + values.Add(Unquote(line[4..].Trim())); + } + + return values; + } + + private static IReadOnlyDictionary ParseArgumentArray(IReadOnlyList lines) + { + var items = new List>(); + Dictionary? current = null; + + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + if (line.StartsWith(" - ", StringComparison.Ordinal)) + { + current = new Dictionary(StringComparer.OrdinalIgnoreCase); + items.Add(current); + AddKeyValue(current, line[4..].Trim()); + continue; + } + + if (current is not null && line.StartsWith(" ", StringComparison.Ordinal)) + { + AddKeyValue(current, line[4..].Trim()); + } + } + + return items + .Where(item => item.TryGetValue("name", out var argumentName) && !string.IsNullOrWhiteSpace(argumentName)) + .ToDictionary( + item => item["name"], + item => item.TryGetValue("description", out var description) ? description : string.Empty, + StringComparer.OrdinalIgnoreCase); + } + + private static IReadOnlyDictionary ParseStringMap(IReadOnlyList lines, int indent) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line) || line.Length < indent || !line.StartsWith(new string(' ', indent), StringComparison.Ordinal)) + { + continue; + } + + AddKeyValue(values, line[indent..].Trim()); + } + + return values; + } + + private static void AddKeyValue(IDictionary values, string line) + { + var separatorIndex = line.IndexOf(':'); + if (separatorIndex <= 0) + { + return; + } + + var key = line[..separatorIndex].Trim(); + var value = Unquote(line[(separatorIndex + 1)..].Trim()); + if (!string.IsNullOrWhiteSpace(key)) + { + values[key] = value; + } + } + + private static IReadOnlyList ParseTools(IReadOnlyDictionary frontMatter) + { + if (!frontMatter.TryGetValue("tools", out var rawTools)) + { + return []; + } + + return rawTools switch + { + string toolText => toolText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), + IReadOnlyList toolList => toolList.Where(tool => !string.IsNullOrWhiteSpace(tool)).ToArray(), + _ => [] + }; + } + + private static IReadOnlyDictionary? ParseArguments(IReadOnlyDictionary frontMatter) + { + if (!frontMatter.TryGetValue("arguments", out var rawArguments)) + { + return null; + } + + return rawArguments switch + { + IReadOnlyDictionary argumentMap when argumentMap.Count > 0 => argumentMap, + string argumentText when !string.IsNullOrWhiteSpace(argumentText) => argumentText + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(entry => entry.Split('=', 2, StringSplitOptions.TrimEntries)) + .Where(parts => parts.Length == 2 && !string.IsNullOrWhiteSpace(parts[0])) + .ToDictionary(parts => parts[0], parts => Unquote(parts[1]), StringComparer.OrdinalIgnoreCase), + _ => null + }; + } + + private static SkillHooks? ParseHooks(IReadOnlyDictionary frontMatter) + { + if (!frontMatter.TryGetValue("hooks", out var rawHooks) || rawHooks is not IReadOnlyDictionary hooks || hooks.Count == 0) + { + return null; + } + + hooks.TryGetValue("preExecute", out var preExecute); + hooks.TryGetValue("postExecute", out var postExecute); + hooks.TryGetValue("onError", out var onError); + + return new SkillHooks + { + PreExecute = string.IsNullOrWhiteSpace(preExecute) ? null : preExecute, + PostExecute = string.IsNullOrWhiteSpace(postExecute) ? null : postExecute, + OnError = string.IsNullOrWhiteSpace(onError) ? null : onError + }; + } + + private static string Unquote(string value) + { + if (value.Length >= 2) + { + var first = value[0]; + var last = value[^1]; + if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) + { + return value[1..^1]; + } + } + + return value; + } +} diff --git a/src/FreeCode.Skills/SkillTypes.cs b/src/FreeCode.Skills/SkillTypes.cs new file mode 100644 index 0000000..7ab1e5f --- /dev/null +++ b/src/FreeCode.Skills/SkillTypes.cs @@ -0,0 +1,29 @@ +namespace FreeCode.Skills; + +public sealed record SkillDefinition +{ + public required string Name { get; init; } + + public string? Description { get; init; } + + public required string Content { get; init; } + + public IReadOnlyList Tools { get; init; } = []; + + public string? Model { get; init; } + + public IReadOnlyDictionary? Arguments { get; init; } + + public SkillHooks? Hooks { get; init; } + + public string? FilePath { get; init; } +} + +public sealed record SkillHooks +{ + public string? PreExecute { get; init; } + + public string? PostExecute { get; init; } + + public string? OnError { get; init; } +} diff --git a/src/FreeCode.State/AppState.cs b/src/FreeCode.State/AppState.cs new file mode 100644 index 0000000..ee42be8 --- /dev/null +++ b/src/FreeCode.State/AppState.cs @@ -0,0 +1,86 @@ +using FreeCode.Core.Models; +using FreeCode.Core.Enums; + +namespace FreeCode.State; + +public sealed record AppState +{ + public SettingsJson Settings { get; init; } = new(); + public bool Verbose { get; init; } + public string? MainLoopModel { get; init; } + public string? MainLoopModelForSession { get; init; } + public string? StatusLineText { get; init; } + public ExpandedView ExpandedView { get; init; } + public bool IsBriefOnly { get; init; } + public PermissionMode PermissionMode { get; init; } = PermissionMode.Default; + public ToolPermissionContext ToolPermissionContext { get; init; } = new(); + public string? Agent { get; init; } + public bool KairosEnabled { get; init; } + + public int SelectedIPAgentIndex { get; init; } + public int CoordinatorTaskIndex { get; init; } + public ViewSelectionMode ViewSelectionMode { get; init; } + public FooterItem? FooterSelection { get; init; } + + public IReadOnlyDictionary Tasks { get; init; } = new Dictionary(); + public string? ForegroundedTaskId { get; init; } + public string? ViewingAgentTaskId { get; init; } + public IReadOnlyDictionary AgentNameRegistry { get; init; } = new Dictionary(); + + public McpState Mcp { get; init; } = McpState.Empty; + public PluginState Plugins { get; init; } = PluginState.Empty; + public string? RemoteSessionUrl { get; init; } + public RemoteConnectionStatus RemoteConnectionStatus { get; init; } = RemoteConnectionStatus.Disconnected; + public object? Bridge { get; init; } + public Companion? Companion { get; init; } + + public NotificationState Notifications { get; init; } = NotificationState.Empty; + public AgentDefinitionsResult AgentDefinitions { get; init; } = new(Array.Empty()); + public IReadOnlyList FileHistory { get; init; } = Array.Empty(); + public FileHistoryState FileHistoryState { get; init; } = new(new Dictionary()); + public AttributionState Attribution { get; init; } = new(null, null); + public IReadOnlyDictionary Todos { get; init; } = new Dictionary(); + + public SpeculationState Speculation { get; init; } = SpeculationState.Idle; + public long SpeculationSessionTimeSavedMs { get; init; } + public bool? ThinkingEnabled { get; init; } + public bool PromptSuggestionEnabled { get; init; } + public bool FastMode { get; init; } +} + +public sealed record McpState +{ + public static McpState Empty { get; } = new(); + + public IReadOnlyList Clients { get; init; } = []; + public IReadOnlyList Tools { get; init; } = []; + public IReadOnlyList Commands { get; init; } = []; + public IReadOnlyDictionary> Resources { get; init; } = new Dictionary>(); + public int PluginReconnectKey { get; init; } +} + +public sealed record PluginState +{ + public static PluginState Empty { get; } = new(); + + public IReadOnlyList Enabled { get; init; } = []; + public IReadOnlyList Disabled { get; init; } = []; + public IReadOnlyList Commands { get; init; } = []; + public IReadOnlyList Errors { get; init; } = []; + public object InstallationStatus { get; init; } = new { }; + public bool NeedsRefresh { get; init; } +} + +public sealed record NotificationState +{ + public static NotificationState Empty { get; } = new(); + + public Notification? Current { get; init; } + public IReadOnlyList Queue { get; init; } = []; +} + +public sealed record SpeculationState +{ + public static SpeculationState Idle { get; } = new() { Status = "idle" }; + public string Status { get; init; } = "idle"; +} diff --git a/src/FreeCode.State/AppStateStore.cs b/src/FreeCode.State/AppStateStore.cs new file mode 100644 index 0000000..fce4c72 --- /dev/null +++ b/src/FreeCode.State/AppStateStore.cs @@ -0,0 +1,96 @@ +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.State; + +public sealed class AppStateStore : IAppStateStore +{ + private readonly object _gate = new(); + private readonly List> _listeners = new(); + private AppState _state = new(); + + public event EventHandler? StateChanged; + + public object GetState() + { + lock (_gate) + { + return _state; + } + } + + public void Update(Func updater) + { + ArgumentNullException.ThrowIfNull(updater); + + AppState oldState; + AppState newState; + + lock (_gate) + { + oldState = _state; + var updated = updater(oldState); + newState = updated as AppState ?? throw new InvalidCastException("AppStateStore requires updater to return AppState."); + _state = newState; + } + + StateChanged?.Invoke(this, new StateChangedEventArgs(oldState, newState)); + + List> listenersSnapshot; + lock (_gate) + { + listenersSnapshot = [.. _listeners]; + } + + foreach (var listener in listenersSnapshot) + { + listener(newState); + } + } + + public IDisposable Subscribe(Action listener) + { + ArgumentNullException.ThrowIfNull(listener); + + lock (_gate) + { + _listeners.Add(listener); + } + + return new Subscription(this, listener); + } + + private void Unsubscribe(Action listener) + { + lock (_gate) + { + _listeners.Remove(listener); + } + } + + private sealed class Subscription(AppStateStore owner, Action listener) : IDisposable + { + private AppStateStore? _owner = owner; + private readonly Action _listener = listener; + + public void Dispose() + { + _owner?.Unsubscribe(_listener); + _owner = null; + } + } + + public AppState GetTypedState() + { + lock (_gate) + { + return _state; + } + } + + public void Update(Func updater) + { + ArgumentNullException.ThrowIfNull(updater); + Update((Func)(state => updater((AppState)state)!)); + } +} diff --git a/src/FreeCode.State/AppStateStoreExtensions.cs b/src/FreeCode.State/AppStateStoreExtensions.cs new file mode 100644 index 0000000..e12d29d --- /dev/null +++ b/src/FreeCode.State/AppStateStoreExtensions.cs @@ -0,0 +1,19 @@ +using FreeCode.Core.Interfaces; + +namespace FreeCode.State; + +public static class AppStateStoreExtensions +{ + public static AppState GetTypedState(this IAppStateStore stateStore) + { + ArgumentNullException.ThrowIfNull(stateStore); + return stateStore.GetState() as AppState ?? throw new InvalidCastException("AppStateStore requires AppState state."); + } + + public static void Update(this IAppStateStore stateStore, Func updater) + { + ArgumentNullException.ThrowIfNull(stateStore); + ArgumentNullException.ThrowIfNull(updater); + stateStore.Update(state => updater((AppState)state)!); + } +} diff --git a/src/FreeCode.State/FreeCode.State.csproj b/src/FreeCode.State/FreeCode.State.csproj new file mode 100644 index 0000000..b61ddd3 --- /dev/null +++ b/src/FreeCode.State/FreeCode.State.csproj @@ -0,0 +1,9 @@ + + + FreeCode.State + + + + + + diff --git a/src/FreeCode.State/ServiceCollectionExtensions.cs b/src/FreeCode.State/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..439606a --- /dev/null +++ b/src/FreeCode.State/ServiceCollectionExtensions.cs @@ -0,0 +1,13 @@ +using FreeCode.Core.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode.State; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddState(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/src/FreeCode.State/StateSelectors.cs b/src/FreeCode.State/StateSelectors.cs new file mode 100644 index 0000000..8099928 --- /dev/null +++ b/src/FreeCode.State/StateSelectors.cs @@ -0,0 +1,30 @@ +using FreeCode.Core.Models; + +namespace FreeCode.State; + +public static class StateSelectors +{ + public static AppState AsAppState(this object state) + => state as AppState ?? throw new InvalidCastException($"Expected {nameof(AppState)} state."); + + public static string? GetCurrentModel(this AppState state) => state.MainLoopModelForSession ?? state.MainLoopModel; + public static string? GetSessionId(this AppState state) => state.ForegroundedTaskId ?? state.ViewingAgentTaskId; + public static TokenUsage GetTokenUsage(this AppState state) => new(0, 0, 0, 0); + public static IReadOnlyDictionary GetFeatureFlags(this AppState state) + => new Dictionary + { + ["verbose"] = state.Verbose, + ["thinking"] = state.ThinkingEnabled ?? false, + ["fastMode"] = state.FastMode, + ["promptSuggestion"] = state.PromptSuggestionEnabled, + }; + public static IReadOnlyList GetWorkingDirectories(this AppState state) + => state.ToolPermissionContext.AllowedTools.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray(); + + public static bool IsVerbose(this AppState state) => state.Verbose; + public static string? GetMainLoopModel(this AppState state) => state.MainLoopModel; + public static string? GetMainLoopModelForSession(this AppState state) => state.MainLoopModelForSession; + public static IReadOnlyDictionary GetTasks(this AppState state) => state.Tasks; + public static IReadOnlyList GetFileHistory(this AppState state) => state.FileHistory; + public static bool IsThinkingEnabled(this AppState state) => state.ThinkingEnabled ?? false; +} diff --git a/src/FreeCode.Tasks/BackgroundTaskManager.cs b/src/FreeCode.Tasks/BackgroundTaskManager.cs new file mode 100644 index 0000000..038cfee --- /dev/null +++ b/src/FreeCode.Tasks/BackgroundTaskManager.cs @@ -0,0 +1,370 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using FreeCode.Core.Enums; +using FreeCode.Services; +using TaskStatus = FreeCode.Core.Enums.TaskStatus; + +namespace FreeCode.Tasks; + +public sealed class BackgroundTaskManager : IBackgroundTaskManager, IAsyncDisposable +{ + private readonly ConcurrentDictionary _tasks = new(StringComparer.OrdinalIgnoreCase); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly IAppStateStore? _appStateStore; + private readonly IQueryEngine? _queryEngine; + private readonly HttpClient _httpClient; + private readonly ISessionMemoryService? _sessionMemoryService; + private readonly object _gate = new(); + private readonly CancellationTokenSource _cts = new(); + + public BackgroundTaskManager(IAppStateStore? appStateStore = null, IQueryEngine? queryEngine = null, HttpClient? httpClient = null, ISessionMemoryService? sessionMemoryService = null) + { + _appStateStore = appStateStore; + _queryEngine = queryEngine; + _httpClient = httpClient ?? new HttpClient(); + _sessionMemoryService = sessionMemoryService; + } + + public event EventHandler? TaskStateChanged; + + public Task StartAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken = default) + { + _cts.Cancel(); + await CleanupCompletedTasksAsync(TimeSpan.FromMinutes(5), cancellationToken).ConfigureAwait(false); + } + + public async Task CreateShellTaskAsync(string command, ProcessStartInfo psi) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command); + ArgumentNullException.ThrowIfNull(psi); + + var task = new LocalShellTask + { + TaskId = Guid.NewGuid().ToString("N"), + Command = command, + ProcessStartInfo = psi + }; + + RegisterTask(task, () => RunShellTaskAsync(task, _cts.Token)); + await Task.CompletedTask.ConfigureAwait(false); + return task; + } + + public async Task CreateAgentTaskAsync(string prompt, string? agentType, string? model) + { + var task = new LocalAgentTask + { + TaskId = Guid.NewGuid().ToString("N"), + Prompt = prompt, + AgentType = agentType, + Model = model + }; + RegisterTask(task, () => ExecuteAgentTaskAsync(task, _cts.Token)); + await Task.CompletedTask.ConfigureAwait(false); + return task; + } + + public async Task CreateRemoteAgentTaskAsync(string sessionUrl) + { + var task = new RemoteAgentTask + { + TaskId = Guid.NewGuid().ToString("N"), + SessionUrl = sessionUrl + }; + RegisterTask(task, () => ExecuteRemoteTaskAsync(task, _cts.Token)); + await Task.CompletedTask.ConfigureAwait(false); + return task; + } + + public async Task CreateDreamTaskAsync(string triggerReason) + { + var task = new DreamTask + { + TaskId = Guid.NewGuid().ToString("N"), + TriggerReason = triggerReason + }; + RegisterTask(task, () => ExecuteDreamTaskAsync(task, _cts.Token)); + await Task.CompletedTask.ConfigureAwait(false); + return task; + } + + public async Task StopTaskAsync(string taskId) + { + if (_tasks.TryGetValue(taskId, out var handle)) + { + handle.Cancel(); + handle.Task.Status = TaskStatus.Stopped; + handle.Task.CompletedAt = DateTime.UtcNow; + PublishStateChanged(handle.Task); + UpdateAppState(handle.Task); + } + + await Task.CompletedTask.ConfigureAwait(false); + } + + public Task GetTaskOutputAsync(string taskId) + { + if (!_tasks.TryGetValue(taskId, out var handle)) + { + return Task.FromResult(null); + } + + var task = handle.Task; + return Task.FromResult(task switch + { + LocalShellTask shell => string.Join('\n', shell.Stdout, shell.Stderr).Trim(), + RemoteAgentTask remote => remote.Plan, + DreamTask dream => dream.TriggerReason, + LocalAgentTask agent => string.Join("\n", agent.Messages.Select(m => m.Content?.ToString() ?? string.Empty)), + _ => null + }); + } + + public IReadOnlyList ListTasks() => _tasks.Values.Select(handle => handle.Task).OrderBy(task => task.StartedAt ?? DateTime.MinValue).ToList(); + + public Task> ListTasksAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult>(_tasks.Values.Select(handle => handle.Task).OrderBy(task => task.StartedAt ?? DateTime.MinValue).ToList()); + } + + public Task CleanupCompletedTasksAsync(TimeSpan olderThan, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var cutoff = DateTime.UtcNow - olderThan; + + foreach (var pair in _tasks.ToArray()) + { + if (pair.Value.Task.CompletedAt is { } completedAt && completedAt < cutoff && pair.Value.Task.Status is TaskStatus.Completed or TaskStatus.Failed or TaskStatus.Stopped) + { + _tasks.TryRemove(pair.Key, out _); + } + } + + return Task.CompletedTask; + } + + public BackgroundTask? GetTask(string taskId) => _tasks.TryGetValue(taskId, out var handle) ? handle.Task : null; + + private void RegisterTask(BackgroundTask task, Func runner) + { + var cts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token); + var handle = new TaskHandle(task, cts); + _tasks[task.TaskId] = handle; + UpdateAppState(task); + _ = Task.Run(async () => await ExecuteTaskAsync(handle, runner, cts.Token).ConfigureAwait(false), CancellationToken.None); + } + + private async Task ExecuteTaskAsync(TaskHandle handle, Func runner, CancellationToken ct) + { + var task = handle.Task; + task.Status = TaskStatus.Running; + task.StartedAt = DateTime.UtcNow; + PublishStateChanged(task); + UpdateAppState(task); + + try + { + await runner().ConfigureAwait(false); + if (task.Status != TaskStatus.Stopped) + { + task.Status = TaskStatus.Completed; + } + } + catch (OperationCanceledException) + { + task.Status = TaskStatus.Stopped; + } + catch (Exception ex) + { + task.Status = TaskStatus.Failed; + task.ErrorMessage = ex.Message; + } + finally + { + task.CompletedAt = DateTime.UtcNow; + PublishStateChanged(task); + UpdateAppState(task); + } + } + + private static async Task RunShellTaskAsync(LocalShellTask task, CancellationToken ct) + { + if (task.ProcessStartInfo is null) + { + throw new InvalidOperationException("ProcessStartInfo is required for shell tasks."); + } + + using var process = new Process { StartInfo = task.ProcessStartInfo, EnableRaisingEvents = true }; + if (!process.Start()) + { + throw new InvalidOperationException("Failed to start process."); + } + + if (task.ProcessStartInfo.RedirectStandardOutput) + { + task.Stdout = await process.StandardOutput.ReadToEndAsync(ct).ConfigureAwait(false); + } + + if (task.ProcessStartInfo.RedirectStandardError) + { + task.Stderr = await process.StandardError.ReadToEndAsync(ct).ConfigureAwait(false); + } + + await process.WaitForExitAsync(ct).ConfigureAwait(false); + task.ExitCode = process.ExitCode; + } + + private async Task ExecuteAgentTaskAsync(LocalAgentTask task, CancellationToken ct) + { + if (_queryEngine is not null) + { + var options = string.IsNullOrWhiteSpace(task.Model) ? null : new SubmitMessageOptions(Model: task.Model); + + await foreach (var msg in _queryEngine.SubmitMessageAsync(task.Prompt, options, ct).ConfigureAwait(false)) + { + if (msg is SDKMessage.AssistantMessage assistant && !string.IsNullOrEmpty(assistant.Text)) + { + task.Messages.Add(new Message + { + MessageId = Guid.NewGuid().ToString("N"), + Role = MessageRole.Assistant, + Content = assistant.Text + }); + } + } + + return; + } + + task.Messages.Add(new Message + { + MessageId = Guid.NewGuid().ToString("N"), + Role = MessageRole.Assistant, + Content = $"Agent task completed: {task.Prompt}" + }); + } + + private async Task ExecuteRemoteTaskAsync(RemoteAgentTask task, CancellationToken ct) + { + task.Status = "connecting"; + + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, task.SessionUrl) + { + Content = JsonContent.Create(new { prompt = task.Plan ?? "Continue the remote session and return the latest response." }, options: JsonOptions) + }; + + using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + task.Status = "streaming"; + await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + using var reader = new StreamReader(stream); + var responseText = (await reader.ReadToEndAsync(ct).ConfigureAwait(false)).Trim(); + + task.Plan = string.IsNullOrWhiteSpace(responseText) + ? $"Remote session responded without a body: {task.SessionUrl}" + : responseText; + task.Status = "completed"; + } + catch (OperationCanceledException) + { + task.Status = "cancelled"; + throw; + } + catch (Exception ex) when (ex is HttpRequestException or IOException or TaskCanceledException) + { + task.Status = "failed"; + task.Plan = $"Remote session request failed: {ex.Message}"; + throw; + } + } + + private async Task ExecuteDreamTaskAsync(DreamTask task, CancellationToken ct) + { + if (_sessionMemoryService is null) + { + task.ErrorMessage = "Dream service unavailable; skipping memory consolidation."; + return; + } + + if (_sessionMemoryService is SessionMemoryService sessionMemoryService) + { + await sessionMemoryService.AddMemoryAsync( + $"dream-{task.TaskId}", + $"Dream cycle completed for trigger '{task.TriggerReason}' at {DateTimeOffset.UtcNow:O}.", + ["dream", "consolidation", task.TriggerReason], + ct).ConfigureAwait(false); + return; + } + + await _sessionMemoryService.TryExtractAsync([]).ConfigureAwait(false); + } + + private static Task RunDelayedAsync(TimeSpan delay, CancellationToken ct) + { + var delayedTask = Task.Run(async () => + { + ct.ThrowIfCancellationRequested(); + await Task.Delay(delay, ct).ConfigureAwait(false); + }, ct); + + return delayedTask; + } + + private void PublishStateChanged(BackgroundTask task) + { + TaskStateChanged?.Invoke(this, new TaskStateChangedEventArgs(task.TaskId, task.Status)); + } + + private void UpdateAppState(BackgroundTask task) + { + if (_appStateStore is null) + { + return; + } + + _appStateStore.Update(state => + { + return ReplaceTasks(state, _tasks.Values.ToDictionary(x => x.Task.TaskId, x => x.Task, StringComparer.OrdinalIgnoreCase)); + }); + } + + private static object ReplaceTasks(object state, IReadOnlyDictionary tasks) + { + var stateType = state.GetType(); + var tasksProperty = stateType.GetProperty("Tasks"); + if (tasksProperty is null || !tasksProperty.CanWrite) + { + return state; + } + + tasksProperty.SetValue(state, tasks); + return state; + } + + private sealed record TaskHandle(BackgroundTask Task, CancellationTokenSource CancellationSource) + { + public void Cancel() => CancellationSource.Cancel(); + } + + public async ValueTask DisposeAsync() + { + await StopAsync().ConfigureAwait(false); + _cts.Dispose(); + } +} diff --git a/src/FreeCode.Tasks/FreeCode.Tasks.csproj b/src/FreeCode.Tasks/FreeCode.Tasks.csproj new file mode 100644 index 0000000..78ed891 --- /dev/null +++ b/src/FreeCode.Tasks/FreeCode.Tasks.csproj @@ -0,0 +1,11 @@ + + + FreeCode.Tasks + + + + + + + + diff --git a/src/FreeCode.Tasks/ServiceCollectionExtensions.cs b/src/FreeCode.Tasks/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..034d937 --- /dev/null +++ b/src/FreeCode.Tasks/ServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode.Tasks; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddFreeCodeTasks(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + return services; + } +} diff --git a/src/FreeCode.TerminalUI/AppRunner.cs b/src/FreeCode.TerminalUI/AppRunner.cs new file mode 100644 index 0000000..9d6ea01 --- /dev/null +++ b/src/FreeCode.TerminalUI/AppRunner.cs @@ -0,0 +1,11 @@ +using FreeCode.Core.Interfaces; + +namespace FreeCode.TerminalUI; + +public sealed class AppRunner(IServiceProvider services) : IAppRunner +{ + public async Task RunAsync(CancellationToken ct = default) + { + await TerminalApp.RunAsync(services).ConfigureAwait(false); + } +} diff --git a/src/FreeCode.TerminalUI/CompanionSpriteView.cs b/src/FreeCode.TerminalUI/CompanionSpriteView.cs new file mode 100644 index 0000000..f443b81 --- /dev/null +++ b/src/FreeCode.TerminalUI/CompanionSpriteView.cs @@ -0,0 +1,35 @@ +using Terminal.Gui; + +namespace FreeCode.TerminalUI; + +public sealed class CompanionSpriteView : View +{ + private readonly string[] _sprite; + + public CompanionSpriteView(string[]? spriteLines = null) + { + _sprite = spriteLines ?? GetDefaultSprite(); + Width = _sprite.Max(line => line.Length); + Height = _sprite.Length; + } + + public override void Redraw(Rect bounds) + { + Driver.SetAttribute(ColorScheme.Normal); + + for (var row = 0; row < _sprite.Length; row++) + { + Move(0, row); + Driver.AddStr(_sprite[row]); + } + } + + private static string[] GetDefaultSprite() => + [ + " /\\_/\\ ", + " ( o.o ) ", + " > ^ < ", + " /| |\\", + "(_| |_)" + ]; +} diff --git a/src/FreeCode.TerminalUI/ConsoleRepl.cs b/src/FreeCode.TerminalUI/ConsoleRepl.cs new file mode 100644 index 0000000..bb1c5b4 --- /dev/null +++ b/src/FreeCode.TerminalUI/ConsoleRepl.cs @@ -0,0 +1,174 @@ +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode.TerminalUI; + +public sealed class ConsoleRepl(IServiceProvider services) +{ + private readonly ICommandRegistry _commandRegistry = services.GetRequiredService(); + private readonly IQueryEngine _queryEngine = services.GetRequiredService(); + private readonly CommandContext _commandContext = new(Environment.CurrentDirectory, services); + private CancellationTokenSource? _activeOperation; + private bool _exitRequested; + + public async Task RunAsync() + { + Console.CancelKeyPress += OnCancelKeyPress; + + try + { + while (!_exitRequested) + { + Console.Write("free-code> "); + var input = Console.ReadLine(); + if (input is null) + { + break; + } + + if (string.IsNullOrWhiteSpace(input)) + { + continue; + } + + if (input.StartsWith('/')) + { + await HandleCommandAsync(input).ConfigureAwait(false); + continue; + } + + await RunQueryAsync(input).ConfigureAwait(false); + } + } + finally + { + Console.CancelKeyPress -= OnCancelKeyPress; + } + } + + private async Task HandleCommandAsync(string input) + { + var commandText = input[1..].Trim(); + if (string.IsNullOrWhiteSpace(commandText)) + { + return; + } + + var spaceIndex = commandText.IndexOf(' '); + var commandName = spaceIndex >= 0 ? commandText[..spaceIndex] : commandText; + var args = spaceIndex >= 0 ? commandText[(spaceIndex + 1)..] : null; + + if (string.Equals(commandName, "exit", StringComparison.OrdinalIgnoreCase)) + { + _exitRequested = true; + return; + } + + var command = await ResolveCommandAsync(commandName).ConfigureAwait(false); + if (command is null) + { + Console.WriteLine($"Unknown command: /{commandName}"); + return; + } + + using var cts = new CancellationTokenSource(); + _activeOperation = cts; + + try + { + var result = await command.ExecuteAsync(_commandContext, args, cts.Token).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(result.Output)) + { + Console.WriteLine(result.Output); + } + + if (!result.Success && string.Equals(command.Name, "exit", StringComparison.OrdinalIgnoreCase)) + { + _exitRequested = true; + } + } + finally + { + _activeOperation = null; + } + } + + private async Task RunQueryAsync(string input) + { + using var cts = new CancellationTokenSource(); + _activeOperation = cts; + + try + { + await foreach (var message in _queryEngine.SubmitMessageAsync(input, null, cts.Token).ConfigureAwait(false)) + { + RenderMessage(message); + } + + Console.WriteLine(); + } + catch (OperationCanceledException) + { + Console.WriteLine(); + } + finally + { + _activeOperation = null; + } + } + + private async Task ResolveCommandAsync(string commandName) + { + var commands = await _commandRegistry.GetEnabledCommandsAsync().ConfigureAwait(false); + return commands.FirstOrDefault(command => + string.Equals(command.Name, commandName, StringComparison.OrdinalIgnoreCase) || + (command.Aliases is not null && command.Aliases.Any(alias => string.Equals(alias, commandName, StringComparison.OrdinalIgnoreCase)))); + } + + private static void RenderMessage(SDKMessage message) + { + switch (message) + { + case SDKMessage.StreamingDelta streamingDelta: + Console.Write(streamingDelta.Text); + break; + case SDKMessage.AssistantMessage assistantMessage: + Console.WriteLine(assistantMessage.Text); + break; + case SDKMessage.ToolUseStart toolUseStart: + Console.WriteLine(); + Console.WriteLine($"[{toolUseStart.ToolName}] starting"); + break; + case SDKMessage.ToolUseResult toolUseResult: + Console.WriteLine(); + Console.WriteLine(toolUseResult.Output); + break; + case SDKMessage.CompactBoundary compactBoundary: + Console.WriteLine(); + Console.WriteLine($"[{compactBoundary.Reason}]"); + break; + case SDKMessage.AssistantError assistantError: + Console.Error.WriteLine(assistantError.Error); + break; + case SDKMessage.PermissionDenial permissionDenial: + Console.Error.WriteLine($"Permission denied for {permissionDenial.ToolName} ({permissionDenial.ToolUseId})"); + break; + } + } + + private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) + { + e.Cancel = true; + + var activeOperation = _activeOperation; + if (activeOperation is not null) + { + activeOperation.Cancel(); + return; + } + + _exitRequested = true; + } +} diff --git a/src/FreeCode.TerminalUI/FreeCode.TerminalUI.csproj b/src/FreeCode.TerminalUI/FreeCode.TerminalUI.csproj new file mode 100644 index 0000000..42445ef --- /dev/null +++ b/src/FreeCode.TerminalUI/FreeCode.TerminalUI.csproj @@ -0,0 +1,13 @@ + + + FreeCode.TerminalUI + + + + + + + + + + diff --git a/src/FreeCode.TerminalUI/PermissionDialog.cs b/src/FreeCode.TerminalUI/PermissionDialog.cs new file mode 100644 index 0000000..818e6ab --- /dev/null +++ b/src/FreeCode.TerminalUI/PermissionDialog.cs @@ -0,0 +1,53 @@ +using Terminal.Gui; + +namespace FreeCode.TerminalUI; + +public enum PermissionResponse +{ + AllowOnce, + Deny, + AllowAlways +} + +public sealed class PermissionDialog : Dialog +{ + public PermissionResponse Response { get; private set; } = PermissionResponse.Deny; + + public PermissionDialog(string toolName, string description) : base($"Permission: {toolName}", 60, 12) + { + var label = new Label(description ?? $"Allow {toolName} to execute?") + { + X = 1, + Y = 1, + Width = Dim.Fill(1), + Height = 3 + }; + + Add(label); + + var allowOnce = new Button("Allow Once", true); + allowOnce.Clicked += () => + { + Response = PermissionResponse.AllowOnce; + Application.RequestStop(); + }; + + var allowAlways = new Button("Allow Always"); + allowAlways.Clicked += () => + { + Response = PermissionResponse.AllowAlways; + Application.RequestStop(); + }; + + var deny = new Button("Deny"); + deny.Clicked += () => + { + Response = PermissionResponse.Deny; + Application.RequestStop(); + }; + + AddButton(allowOnce); + AddButton(allowAlways); + AddButton(deny); + } +} diff --git a/src/FreeCode.TerminalUI/REPLScreen.cs b/src/FreeCode.TerminalUI/REPLScreen.cs new file mode 100644 index 0000000..472c75e --- /dev/null +++ b/src/FreeCode.TerminalUI/REPLScreen.cs @@ -0,0 +1,299 @@ +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using Microsoft.Extensions.DependencyInjection; +using Terminal.Gui; + +namespace FreeCode.TerminalUI; + +public sealed class REPLScreen : Window +{ + private readonly IServiceProvider _services; + private readonly IQueryEngine _queryEngine; + private readonly ICommandRegistry _commandRegistry; + private readonly IAppStateStore? _stateStore; + private readonly ListView _messageList; + private readonly TextView _promptInput; + private readonly List _messages = []; + private CancellationTokenSource? _activeOperation; + private bool _isRenderingStreamingLine; + + public REPLScreen(IServiceProvider services) : base("free-code") + { + _services = services; + _queryEngine = services.GetRequiredService(); + _commandRegistry = services.GetRequiredService(); + _stateStore = services.GetService(); + + var messagesFrame = new FrameView("Messages") + { + X = 0, + Y = 0, + Width = Dim.Fill(), + Height = Dim.Fill(4) + }; + + _messageList = new ListView(_messages) + { + X = 0, + Y = 0, + Width = Dim.Fill(), + Height = Dim.Fill() + }; + messagesFrame.Add(_messageList); + Add(messagesFrame); + + var inputFrame = new FrameView("Input") + { + X = 0, + Y = Pos.Bottom(messagesFrame), + Width = Dim.Fill(), + Height = 3 + }; + + _promptInput = new TextView + { + X = 0, + Y = 0, + Width = Dim.Fill(), + Height = Dim.Fill(), + Multiline = false, + WordWrap = false + }; + + inputFrame.Add(_promptInput); + Add(inputFrame); + + var statusBar = new StatusBar( + [ + new StatusItem(Key.Enter, "~Enter~ Send", null), + new StatusItem(Key.CtrlMask | Key.C, "~Ctrl+C~ Cancel", null), + new StatusItem(Key.Esc, "~Esc~ Quit", null) + ]); + + Add(statusBar); + SetupKeyHandlers(); + AddWelcomeMessage(); + } + + private void AddWelcomeMessage() + { + AddMessage("free-code .NET 10"); + AddMessage("Type /help for commands, /exit to quit"); + + if (_stateStore is not null) + { + AddMessage("State store connected."); + } + } + + private void SetupKeyHandlers() + { + _promptInput.KeyPress += e => + { + if (e.KeyEvent.Key == Key.Enter) + { + var input = _promptInput.Text.ToString()?.Trim(); + if (!string.IsNullOrWhiteSpace(input)) + { + _ = HandleInputAsync(input); + } + + _promptInput.Text = string.Empty; + e.Handled = true; + return; + } + + if (e.KeyEvent.Key == Key.Esc) + { + Application.RequestStop(); + e.Handled = true; + return; + } + + if (e.KeyEvent.Key == (Key.CtrlMask | Key.C)) + { + _activeOperation?.Cancel(); + e.Handled = true; + } + }; + } + + private async Task HandleInputAsync(string input) + { + AddMessage($"> {input}"); + + if (input.StartsWith('/')) + { + await HandleCommandAsync(input[1..]).ConfigureAwait(false); + return; + } + + using var cts = new CancellationTokenSource(); + _activeOperation = cts; + _isRenderingStreamingLine = false; + + try + { + await foreach (var message in _queryEngine.SubmitMessageAsync(input, null, cts.Token).ConfigureAwait(false)) + { + Application.MainLoop.Invoke(() => RenderMessage(message)); + } + + if (_isRenderingStreamingLine) + { + AddMessage(string.Empty); + } + } + catch (OperationCanceledException) + { + AddMessage("[cancelled]"); + } + finally + { + _isRenderingStreamingLine = false; + _activeOperation = null; + } + } + + private async Task HandleCommandAsync(string input) + { + var commandText = input.Trim(); + if (string.IsNullOrWhiteSpace(commandText)) + { + return; + } + + var spaceIndex = commandText.IndexOf(' '); + var commandName = spaceIndex >= 0 ? commandText[..spaceIndex] : commandText; + var args = spaceIndex >= 0 ? commandText[(spaceIndex + 1)..] : null; + + if (string.Equals(commandName, "exit", StringComparison.OrdinalIgnoreCase)) + { + Application.RequestStop(); + return; + } + + var commands = await _commandRegistry.GetEnabledCommandsAsync().ConfigureAwait(false); + var command = commands.FirstOrDefault(cmd => + string.Equals(cmd.Name, commandName, StringComparison.OrdinalIgnoreCase) || + (cmd.Aliases is not null && cmd.Aliases.Any(alias => string.Equals(alias, commandName, StringComparison.OrdinalIgnoreCase)))); + + if (command is null) + { + AddMessage($"Unknown command: /{commandName}"); + return; + } + + using var cts = new CancellationTokenSource(); + _activeOperation = cts; + + try + { + var context = new CommandContext(Environment.CurrentDirectory, _services); + var result = await command.ExecuteAsync(context, args, cts.Token).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(result.Output)) + { + AddMessage(result.Output); + } + } + catch (OperationCanceledException) + { + AddMessage("[cancelled]"); + } + finally + { + _activeOperation = null; + } + } + + private void AddMessage(string message) + { + if (Application.MainLoop is null) + { + _messages.Add(message); + RefreshMessages(); + return; + } + + Application.MainLoop.Invoke(() => + { + _messages.Add(message); + RefreshMessages(); + }); + } + + private void RefreshMessages() + { + _messageList.SetSource(_messages); + + if (_messages.Count > 0) + { + _messageList.SelectedItem = _messages.Count - 1; + } + + _messageList.SetNeedsDisplay(); + } + + private void RenderMessage(SDKMessage message) + { + switch (message) + { + case SDKMessage.UserMessage userMessage: + AddMessage($"> {userMessage.Message.Content}"); + break; + case SDKMessage.StreamingDelta delta: + RenderStreamingDelta(delta.Text); + break; + case SDKMessage.AssistantMessage assistant: + if (!_isRenderingStreamingLine) + { + AddMessage(assistant.Text ?? string.Empty); + } + else + { + _isRenderingStreamingLine = false; + } + + break; + case SDKMessage.ToolUseStart toolStart: + _isRenderingStreamingLine = false; + AddMessage($"[{toolStart.ToolName}] starting"); + break; + case SDKMessage.ToolUseResult toolResult: + _isRenderingStreamingLine = false; + if (!string.IsNullOrWhiteSpace(toolResult.Output)) + { + AddMessage(toolResult.Output); + } + + break; + case SDKMessage.CompactBoundary compactBoundary: + _isRenderingStreamingLine = false; + AddMessage($"[{compactBoundary.Reason}]"); + break; + case SDKMessage.AssistantError error: + _isRenderingStreamingLine = false; + AddMessage($"Error: {error.Error}"); + break; + case SDKMessage.PermissionDenial denial: + _isRenderingStreamingLine = false; + AddMessage($"Permission denied for {denial.ToolName} ({denial.ToolUseId})"); + break; + } + } + + private void RenderStreamingDelta(string text) + { + if (!_isRenderingStreamingLine || _messages.Count == 0) + { + _messages.Add(text); + _isRenderingStreamingLine = true; + } + else + { + _messages[^1] += text; + } + + RefreshMessages(); + } +} diff --git a/src/FreeCode.TerminalUI/ServiceCollectionExtensions.cs b/src/FreeCode.TerminalUI/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..c9e7cef --- /dev/null +++ b/src/FreeCode.TerminalUI/ServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +using FreeCode.Core.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode.TerminalUI; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddTerminalUI(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + return services; + } +} + +public sealed class PermissionDialogFactory +{ + public PermissionDialog Create(string toolName, string description) => new(toolName, description); +} diff --git a/src/FreeCode.TerminalUI/TerminalApp.cs b/src/FreeCode.TerminalUI/TerminalApp.cs new file mode 100644 index 0000000..a975cc1 --- /dev/null +++ b/src/FreeCode.TerminalUI/TerminalApp.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.DependencyInjection; +using Terminal.Gui; + +namespace FreeCode.TerminalUI; + +public static class TerminalApp +{ + public static async Task RunAsync(IServiceProvider services) + { + Application.Init(); + + try + { + Colors.Base.Normal = Application.Driver.MakeAttribute(Color.Green, Color.Black); + + var top = Application.Top; + var screen = services.GetRequiredService(); + screen.X = 0; + screen.Y = 0; + screen.Width = Dim.Fill(); + screen.Height = Dim.Fill(); + + top.Add(screen); + + await Task.Run(() => Application.Run()).ConfigureAwait(false); + } + finally + { + Application.Shutdown(); + } + } +} diff --git a/src/FreeCode.Tools/AgentTool.cs b/src/FreeCode.Tools/AgentTool.cs new file mode 100644 index 0000000..40a3b5c --- /dev/null +++ b/src/FreeCode.Tools/AgentTool.cs @@ -0,0 +1,73 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class AgentTool : ToolBase, ITool +{ + public override string Name => "Agent"; + public override ToolCategory Category => ToolCategory.Agent; + + public async Task> ExecuteAsync(AgentToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var manager = context.TaskManager as IBackgroundTaskManager; + if (manager is null) + { + return new ToolResult(new AgentToolOutput(string.Empty, false, 0), true, "Background task manager is unavailable."); + } + + var task = await manager.CreateAgentTaskAsync(input.Prompt, input.AgentType, null).ConfigureAwait(false); + if (task is LocalAgentTask agentTask && input.WorkDirectory is not null) + { + var resolved = ToolUtilities.ResolvePath(input.WorkDirectory, context.WorkingDirectory); + task = agentTask with { WorkingDirectory = resolved }; + } + + return new ToolResult(new AgentToolOutput($"Agent task queued: {task.TaskId}", true, 0)); + } + catch (Exception ex) + { + return new ToolResult(new AgentToolOutput(string.Empty, false, 0), true, ex.Message); + } + } + + public Task ValidateInputAsync(AgentToolInput input) + => Task.FromResult(string.IsNullOrWhiteSpace(input.Prompt) ? ValidationResult.Failure(new[] { "Prompt is required." }) : ValidationResult.Success()); + + public Task CheckPermissionAsync(AgentToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => false; + public override bool IsReadOnly(object input) => false; + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Prompt\":{\"type\":\"string\"},\"AgentType\":{\"type\":\"string\"},\"WorkDirectory\":{\"type\":\"string\"}},\"required\":[\"Prompt\"]}").RootElement.Clone(); +} + +public sealed class AgentToolInput +{ + public AgentToolInput(string prompt, string? agentType = null, string? workDirectory = null) + { + Prompt = prompt; + AgentType = agentType; + WorkDirectory = workDirectory; + } + + public string Prompt { get; } + public string? AgentType { get; } + public string? WorkDirectory { get; } +} + +public sealed class AgentToolOutput +{ + public AgentToolOutput(string result, bool success, int tokensUsed) + { + Result = result; + Success = success; + TokensUsed = tokensUsed; + } + + public string Result { get; } + public bool Success { get; } + public int TokensUsed { get; } +} diff --git a/src/FreeCode.Tools/AskUserQuestionTool.cs b/src/FreeCode.Tools/AskUserQuestionTool.cs new file mode 100644 index 0000000..ffa6e79 --- /dev/null +++ b/src/FreeCode.Tools/AskUserQuestionTool.cs @@ -0,0 +1,99 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class AskUserQuestionTool : ToolBase, ITool +{ + public override string Name => "AskUserQuestion"; + public override ToolCategory Category => ToolCategory.UserInteraction; + + public async Task> ExecuteAsync(AskUserQuestionToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + await Console.Out.WriteLineAsync().ConfigureAwait(false); + await Console.Out.WriteLineAsync($"[Question] {input.Question}").ConfigureAwait(false); + + if (input.Options is { Count: > 0 }) + { + for (var i = 0; i < input.Options.Count; i++) + { + await Console.Out.WriteLineAsync($" [{i + 1}] {input.Options[i]}").ConfigureAwait(false); + } + + await Console.Out.WriteAsync("> ").ConfigureAwait(false); + } + else + { + await Console.Out.WriteAsync("> ").ConfigureAwait(false); + } + + var answer = await Task.Run(() => + { + try + { + return Console.ReadLine(); + } + catch + { + return null; + } + }, ct).ConfigureAwait(false); + + if (answer is null) + { + return new ToolResult(new AskUserQuestionToolOutput(string.Empty, true)); + } + + if (input.Options is { Count: > 0 } && int.TryParse(answer.Trim(), out var index) && index >= 1 && index <= input.Options.Count) + { + answer = input.Options[index - 1]; + } + + return new ToolResult(new AskUserQuestionToolOutput(answer.Trim(), false)); + } + catch (OperationCanceledException) + { + return new ToolResult(new AskUserQuestionToolOutput(string.Empty, true)); + } + catch (Exception ex) + { + return new ToolResult(new AskUserQuestionToolOutput(string.Empty, true), true, ex.Message); + } + } + + public Task ValidateInputAsync(AskUserQuestionToolInput input) + => Task.FromResult(string.IsNullOrWhiteSpace(input.Question) ? ValidationResult.Failure(new[] { "Question is required." }) : ValidationResult.Success()); + + public Task CheckPermissionAsync(AskUserQuestionToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => false; + public override bool IsReadOnly(object input) => true; + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Question\":{\"type\":\"string\"},\"Options\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"Question\"]}").RootElement.Clone(); +} + +public sealed class AskUserQuestionToolInput +{ + public AskUserQuestionToolInput(string question, IReadOnlyList? options = null) + { + Question = question; + Options = options; + } + + public string Question { get; } + public IReadOnlyList? Options { get; } +} + +public sealed class AskUserQuestionToolOutput +{ + public AskUserQuestionToolOutput(string answer, bool cancelled) + { + Answer = answer; + Cancelled = cancelled; + } + + public string Answer { get; } + public bool Cancelled { get; } +} diff --git a/src/FreeCode.Tools/BashTool.cs b/src/FreeCode.Tools/BashTool.cs new file mode 100644 index 0000000..d7a57a1 --- /dev/null +++ b/src/FreeCode.Tools/BashTool.cs @@ -0,0 +1,145 @@ +using System.Diagnostics; +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class BashTool : ToolBase, ITool +{ + public override string Name => "Bash"; + public override ToolCategory Category => ToolCategory.Shell; + + public Task> ExecuteAsync(BashToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + if (input.RunInBackground) + { + var started = StartProcess(input, context, redirectOutput: false, redirectError: false); + var output = new BashToolOutput(string.Empty, string.Empty, 0, false, started.process?.Id.ToString() ?? string.Empty); + return Task.FromResult(new ToolResult(output)); + } + + return ExecuteForegroundAsync(input, context, ct); + } + + public Task ValidateInputAsync(BashToolInput input) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(input.Command)) + { + errors.Add("Command is required."); + } + + return Task.FromResult(errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors)); + } + + public Task CheckPermissionAsync(BashToolInput input, ToolExecutionContext context) + { + return Task.FromResult(PermissionResult.Allowed()); + } + + public override bool IsConcurrencySafe(object input) => false; + + public override bool IsReadOnly(object input) + { + var command = input is BashToolInput toolInput ? toolInput.Command : input as string; + if (string.IsNullOrWhiteSpace(command)) + { + return false; + } + + var trimmed = command.TrimStart(); + var readOnlyPrefixes = new[] { "ls", "cat", "grep", "find", "pwd", "head", "tail", "stat", "wc", "du", "which", "whoami", "git status", "git diff", "git log" }; + return readOnlyPrefixes.Any(prefix => trimmed.StartsWith(prefix, StringComparison.Ordinal)); + } + + public override JsonElement GetInputSchema() + { + return JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Command\":{\"type\":\"string\"},\"Timeout\":{\"type\":\"integer\"},\"Description\":{\"type\":\"string\"},\"RunInBackground\":{\"type\":\"boolean\"},\"DangerouslyDisableSandbox\":{\"type\":\"boolean\"}},\"required\":[\"Command\"]}").RootElement.Clone(); + } + + public override async Task GetDescriptionAsync(object? input = null) + { + if (input is BashToolInput bashInput && !string.IsNullOrWhiteSpace(bashInput.Description)) + { + return bashInput.Description; + } + + return await base.GetDescriptionAsync(input).ConfigureAwait(false); + } + + private async Task> ExecuteForegroundAsync(BashToolInput input, ToolExecutionContext context, CancellationToken ct) + { + var started = StartProcess(input, context, redirectOutput: true, redirectError: true); + if (started.process is null) + { + return new ToolResult(new BashToolOutput(string.Empty, "Failed to start process.", -1, false, string.Empty), true, "Failed to start process."); + } + + using var process = started.process; + var stdoutTask = process.StandardOutput.ReadToEndAsync(ct); + var stderrTask = process.StandardError.ReadToEndAsync(ct); + + var waitTask = process.WaitForExitAsync(ct); + var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromMilliseconds(Math.Max(1, input.Timeout)), ct)).ConfigureAwait(false); + var interrupted = completed != waitTask; + if (interrupted && !process.HasExited) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + if (!process.HasExited) + { + throw; + } + } + } + + if (process.HasExited) + { + await waitTask.ConfigureAwait(false); + } + + var stdout = started.redirectOutput ? await stdoutTask.ConfigureAwait(false) : string.Empty; + var stderr = started.redirectError ? await stderrTask.ConfigureAwait(false) : string.Empty; + var output = new BashToolOutput(stdout, stderr, process.HasExited ? process.ExitCode : -1, interrupted, process.Id.ToString()); + return new ToolResult(output, process.ExitCode != 0 && !interrupted, process.ExitCode != 0 && !interrupted ? stderr : null); + } + + private static (Process? process, bool redirectOutput, bool redirectError) StartProcess(BashToolInput input, ToolExecutionContext context, bool redirectOutput, bool redirectError) + { + var shell = OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/zsh"; + var arguments = OperatingSystem.IsWindows() + ? $"/c {input.Command}" + : $"-lc {QuoteForShell(input.Command)}"; + + var startInfo = new ProcessStartInfo + { + FileName = shell, + Arguments = arguments, + WorkingDirectory = context.WorkingDirectory, + UseShellExecute = false, + RedirectStandardOutput = redirectOutput, + RedirectStandardError = redirectError, + RedirectStandardInput = false, + CreateNoWindow = true + }; + + var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; + if (!process.Start()) + { + return (null, redirectOutput, redirectError); + } + + return (process, redirectOutput, redirectError); + } + + private static string QuoteForShell(string command) + { + return "'" + command.Replace("'", "'\\''") + "'"; + } +} diff --git a/src/FreeCode.Tools/BashToolInput.cs b/src/FreeCode.Tools/BashToolInput.cs new file mode 100644 index 0000000..deef872 --- /dev/null +++ b/src/FreeCode.Tools/BashToolInput.cs @@ -0,0 +1,19 @@ +namespace FreeCode.Tools; + +public sealed class BashToolInput +{ + public BashToolInput(string command, int timeout = 60000, string? description = null, bool runInBackground = false, bool dangerouslyDisableSandbox = false) + { + Command = command; + Timeout = timeout; + Description = description; + RunInBackground = runInBackground; + DangerouslyDisableSandbox = dangerouslyDisableSandbox; + } + + public string Command { get; } + public int Timeout { get; } + public string? Description { get; } + public bool RunInBackground { get; } + public bool DangerouslyDisableSandbox { get; } +} diff --git a/src/FreeCode.Tools/BashToolOutput.cs b/src/FreeCode.Tools/BashToolOutput.cs new file mode 100644 index 0000000..36afa2e --- /dev/null +++ b/src/FreeCode.Tools/BashToolOutput.cs @@ -0,0 +1,19 @@ +namespace FreeCode.Tools; + +public sealed class BashToolOutput +{ + public BashToolOutput(string stdout, string stderr, int exitCode, bool interrupted, string backgroundTaskId) + { + Stdout = stdout; + Stderr = stderr; + ExitCode = exitCode; + Interrupted = interrupted; + BackgroundTaskId = backgroundTaskId; + } + + public string Stdout { get; } + public string Stderr { get; } + public int ExitCode { get; } + public bool Interrupted { get; } + public string BackgroundTaskId { get; } +} diff --git a/src/FreeCode.Tools/BriefTool.cs b/src/FreeCode.Tools/BriefTool.cs new file mode 100644 index 0000000..08f18de --- /dev/null +++ b/src/FreeCode.Tools/BriefTool.cs @@ -0,0 +1,63 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class BriefTool : ToolBase, ITool +{ + public override string Name => "Brief"; + public override ToolCategory Category => ToolCategory.UserInteraction; + + public async Task> ExecuteAsync(BriefToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + await Console.Out.WriteLineAsync().ConfigureAwait(false); + await Console.Out.WriteLineAsync($"=== {input.Title} ===").ConfigureAwait(false); + await Console.Out.WriteLineAsync(input.Content).ConfigureAwait(false); + await Console.Out.WriteLineAsync().ConfigureAwait(false); + return new ToolResult(new BriefToolOutput(true)); + } + catch (Exception ex) + { + return new ToolResult(new BriefToolOutput(false), true, ex.Message); + } + } + + public Task ValidateInputAsync(BriefToolInput input) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(input.Title)) errors.Add("Title is required."); + if (string.IsNullOrWhiteSpace(input.Content)) errors.Add("Content is required."); + return Task.FromResult(errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors)); + } + + public Task CheckPermissionAsync(BriefToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => true; + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Title\":{\"type\":\"string\"},\"Content\":{\"type\":\"string\"}},\"required\":[\"Title\",\"Content\"]}").RootElement.Clone(); +} + +public sealed class BriefToolInput +{ + public BriefToolInput(string title, string content) + { + Title = title; + Content = content; + } + + public string Title { get; } + public string Content { get; } +} + +public sealed class BriefToolOutput +{ + public BriefToolOutput(bool shown) + { + Shown = shown; + } + + public bool Shown { get; } +} diff --git a/src/FreeCode.Tools/ConfigTool.cs b/src/FreeCode.Tools/ConfigTool.cs new file mode 100644 index 0000000..2fb0a48 --- /dev/null +++ b/src/FreeCode.Tools/ConfigTool.cs @@ -0,0 +1,132 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class ConfigTool : ToolBase, ITool +{ + private static readonly string ConfigDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".free-code"); + private static readonly string ConfigFilePath = Path.Combine(ConfigDirectory, "config.json"); + private static readonly JsonSerializerOptions JsonOptions = new(SourceGenerationContext.Default.Options); + + public override string Name => "Config"; + public override ToolCategory Category => ToolCategory.Config; + + public async Task> ExecuteAsync(ConfigToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var action = input.Action.ToLowerInvariant(); + var config = await LoadConfigAsync(ct).ConfigureAwait(false); + + if (action == "list") + { + return new ToolResult(new ConfigToolOutput(null, config, true)); + } + + if (action == "get") + { + if (string.IsNullOrWhiteSpace(input.Key)) + { + return new ToolResult(new ConfigToolOutput(null, null, false), true, "Key is required for get action."); + } + + config.TryGetValue(input.Key, out var value); + return new ToolResult(new ConfigToolOutput(value, null, true)); + } + + if (action == "set") + { + if (string.IsNullOrWhiteSpace(input.Key)) + { + return new ToolResult(new ConfigToolOutput(null, null, false), true, "Key is required for set action."); + } + + if (input.Value is null) + { + config.Remove(input.Key); + } + else + { + config[input.Key] = input.Value; + } + + await SaveConfigAsync(config, ct).ConfigureAwait(false); + return new ToolResult(new ConfigToolOutput(input.Value, null, true)); + } + + return new ToolResult(new ConfigToolOutput(null, null, false), true, $"Unknown action: {input.Action}. Use 'get', 'set', or 'list'."); + } + catch (Exception ex) + { + return new ToolResult(new ConfigToolOutput(null, null, false), true, ex.Message); + } + } + + public Task ValidateInputAsync(ConfigToolInput input) + => Task.FromResult(string.IsNullOrWhiteSpace(input.Action) ? ValidationResult.Failure(new[] { "Action is required." }) : ValidationResult.Success()); + + public Task CheckPermissionAsync(ConfigToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => false; + + public override bool IsReadOnly(object input) + { + if (input is ConfigToolInput configInput) + { + return string.Equals(configInput.Action, "get", StringComparison.OrdinalIgnoreCase) || string.Equals(configInput.Action, "list", StringComparison.OrdinalIgnoreCase); + } + + return false; + } + + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Action\":{\"type\":\"string\",\"enum\":[\"get\",\"set\",\"list\"]},\"Key\":{\"type\":\"string\"},\"Value\":{\"type\":\"string\"}},\"required\":[\"Action\"]}").RootElement.Clone(); + + private static async Task> LoadConfigAsync(CancellationToken ct) + { + if (!File.Exists(ConfigFilePath)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + var json = await File.ReadAllTextAsync(ConfigFilePath, ct).ConfigureAwait(false); + var parsed = JsonSerializer.Deserialize(json, SourceGenerationContext.Default.DictionaryStringString); + return parsed is not null ? new Dictionary(parsed, StringComparer.OrdinalIgnoreCase) : new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + private static async Task SaveConfigAsync(Dictionary config, CancellationToken ct) + { + Directory.CreateDirectory(ConfigDirectory); + var json = JsonSerializer.Serialize(config, SourceGenerationContext.Default.DictionaryStringString); + await File.WriteAllTextAsync(ConfigFilePath, json, ct).ConfigureAwait(false); + } +} + +public sealed class ConfigToolInput +{ + public ConfigToolInput(string action, string? key = null, string? value = null) + { + Action = action; + Key = key; + Value = value; + } + + public string Action { get; } + public string? Key { get; } + public string? Value { get; } +} + +public sealed class ConfigToolOutput +{ + public ConfigToolOutput(string? configValue, Dictionary? allConfig, bool success) + { + ConfigValue = configValue; + AllConfig = allConfig; + Success = success; + } + + public string? ConfigValue { get; } + public Dictionary? AllConfig { get; } + public bool Success { get; } +} diff --git a/src/FreeCode.Tools/CronCreateTool.cs b/src/FreeCode.Tools/CronCreateTool.cs new file mode 100644 index 0000000..9b42598 --- /dev/null +++ b/src/FreeCode.Tools/CronCreateTool.cs @@ -0,0 +1,70 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class CronCreateTool : ToolBase, ITool +{ + public override string Name => "CronCreate"; + public override ToolCategory Category => ToolCategory.Task; + + public Task> ExecuteAsync(CronCreateToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + if (!CronTaskStore.TryCreate(input.Schedule, input.Command, input.Name, input.Recurring, input.Durable, out var task, out var error) || task is null) + { + return Task.FromResult(new ToolResult(new CronCreateToolOutput(string.Empty, input.Name, input.Schedule, CronTaskStore.ToHumanSchedule(input.Schedule), input.Command, input.Recurring, input.Durable, "failed", null), true, error)); + } + + var output = new CronCreateToolOutput(task.Id, task.Name, task.Schedule, task.HumanSchedule, task.Command, task.Recurring, task.Durable, task.Status, task.NextRunUtc); + return Task.FromResult(new ToolResult(output)); + } + catch (Exception ex) + { + return Task.FromResult(new ToolResult(new CronCreateToolOutput(string.Empty, input.Name, input.Schedule, CronTaskStore.ToHumanSchedule(input.Schedule), input.Command, input.Recurring, input.Durable, "failed", null), true, ex.Message)); + } + } + + public Task ValidateInputAsync(CronCreateToolInput input) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(input.Schedule)) + { + errors.Add("Schedule is required."); + } + + if (string.IsNullOrWhiteSpace(input.Command)) + { + errors.Add("Command is required."); + } + + if (!string.IsNullOrWhiteSpace(input.Schedule) && !CronTaskStore.TryValidateSchedule(input.Schedule, out var scheduleError, out _)) + { + errors.Add(scheduleError ?? "Schedule is invalid."); + } + + if (!string.IsNullOrWhiteSpace(input.Name) && CronTaskStore.ExistsByName(input.Name)) + { + errors.Add($"A scheduled job named '{input.Name}' already exists."); + } + + return Task.FromResult(errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors)); + } + + public Task CheckPermissionAsync(CronCreateToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public override bool IsConcurrencySafe(object input) => false; + public override bool IsReadOnly(object input) => false; + + public override JsonElement GetInputSchema() + { + return JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Schedule\":{\"type\":\"string\"},\"Command\":{\"type\":\"string\"},\"Name\":{\"type\":\"string\"},\"Recurring\":{\"type\":\"boolean\"},\"Durable\":{\"type\":\"boolean\"}},\"required\":[\"Schedule\",\"Command\"]}").RootElement.Clone(); + } + + public override Task GetDescriptionAsync(object? input = null) + => Task.FromResult("Create a scheduled cron task."); +} diff --git a/src/FreeCode.Tools/CronCreateToolInput.cs b/src/FreeCode.Tools/CronCreateToolInput.cs new file mode 100644 index 0000000..3061d79 --- /dev/null +++ b/src/FreeCode.Tools/CronCreateToolInput.cs @@ -0,0 +1,19 @@ +namespace FreeCode.Tools; + +public sealed class CronCreateToolInput +{ + public CronCreateToolInput(string schedule, string command, string? name = null, bool recurring = true, bool durable = false) + { + Schedule = schedule; + Command = command; + Name = name; + Recurring = recurring; + Durable = durable; + } + + public string Schedule { get; } + public string Command { get; } + public string? Name { get; } + public bool Recurring { get; } + public bool Durable { get; } +} diff --git a/src/FreeCode.Tools/CronCreateToolOutput.cs b/src/FreeCode.Tools/CronCreateToolOutput.cs new file mode 100644 index 0000000..8093da8 --- /dev/null +++ b/src/FreeCode.Tools/CronCreateToolOutput.cs @@ -0,0 +1,27 @@ +namespace FreeCode.Tools; + +public sealed class CronCreateToolOutput +{ + public CronCreateToolOutput(string id, string? name, string schedule, string humanSchedule, string command, bool recurring, bool durable, string status, DateTime? nextRunUtc) + { + Id = id; + Name = name; + Schedule = schedule; + HumanSchedule = humanSchedule; + Command = command; + Recurring = recurring; + Durable = durable; + Status = status; + NextRunUtc = nextRunUtc; + } + + public string Id { get; } + public string? Name { get; } + public string Schedule { get; } + public string HumanSchedule { get; } + public string Command { get; } + public bool Recurring { get; } + public bool Durable { get; } + public string Status { get; } + public DateTime? NextRunUtc { get; } +} diff --git a/src/FreeCode.Tools/CronDeleteTool.cs b/src/FreeCode.Tools/CronDeleteTool.cs new file mode 100644 index 0000000..bf1c38b --- /dev/null +++ b/src/FreeCode.Tools/CronDeleteTool.cs @@ -0,0 +1,50 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class CronDeleteTool : ToolBase, ITool +{ + public override string Name => "CronDelete"; + public override ToolCategory Category => ToolCategory.Task; + + public Task> ExecuteAsync(CronDeleteToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + if (!CronTaskStore.TryDelete(input.Identifier, out var removedTask) || removedTask is null) + { + return Task.FromResult(new ToolResult(new CronDeleteToolOutput(string.Empty, null, false), true, $"No scheduled job with identifier '{input.Identifier}'.")); + } + + return Task.FromResult(new ToolResult(new CronDeleteToolOutput(removedTask.Id, removedTask.Name, true))); + } + catch (Exception ex) + { + return Task.FromResult(new ToolResult(new CronDeleteToolOutput(string.Empty, null, false), true, ex.Message)); + } + } + + public Task ValidateInputAsync(CronDeleteToolInput input) + { + return Task.FromResult(string.IsNullOrWhiteSpace(input.Identifier) + ? ValidationResult.Failure(new[] { "Identifier is required." }) + : ValidationResult.Success()); + } + + public Task CheckPermissionAsync(CronDeleteToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public override bool IsConcurrencySafe(object input) => false; + public override bool IsReadOnly(object input) => false; + + public override JsonElement GetInputSchema() + { + return JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Identifier\":{\"type\":\"string\"}},\"required\":[\"Identifier\"]}").RootElement.Clone(); + } + + public override Task GetDescriptionAsync(object? input = null) + => Task.FromResult("Delete a scheduled cron task by id or name."); +} diff --git a/src/FreeCode.Tools/CronDeleteToolInput.cs b/src/FreeCode.Tools/CronDeleteToolInput.cs new file mode 100644 index 0000000..32571fa --- /dev/null +++ b/src/FreeCode.Tools/CronDeleteToolInput.cs @@ -0,0 +1,11 @@ +namespace FreeCode.Tools; + +public sealed class CronDeleteToolInput +{ + public CronDeleteToolInput(string identifier) + { + Identifier = identifier; + } + + public string Identifier { get; } +} diff --git a/src/FreeCode.Tools/CronDeleteToolOutput.cs b/src/FreeCode.Tools/CronDeleteToolOutput.cs new file mode 100644 index 0000000..5061027 --- /dev/null +++ b/src/FreeCode.Tools/CronDeleteToolOutput.cs @@ -0,0 +1,15 @@ +namespace FreeCode.Tools; + +public sealed class CronDeleteToolOutput +{ + public CronDeleteToolOutput(string id, string? name, bool deleted) + { + Id = id; + Name = name; + Deleted = deleted; + } + + public string Id { get; } + public string? Name { get; } + public bool Deleted { get; } +} diff --git a/src/FreeCode.Tools/CronListTool.cs b/src/FreeCode.Tools/CronListTool.cs new file mode 100644 index 0000000..a99cd9f --- /dev/null +++ b/src/FreeCode.Tools/CronListTool.cs @@ -0,0 +1,44 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class CronListTool : ToolBase, ITool +{ + public override string Name => "CronList"; + public override ToolCategory Category => ToolCategory.Task; + + public Task> ExecuteAsync(CronListToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var jobs = CronTaskStore.List() + .Select(task => new CronListToolJob(task.Id, task.Name, task.Schedule, task.HumanSchedule, task.Command, task.Recurring, task.Durable, task.Status, task.CreatedAtUtc, task.NextRunUtc)) + .ToArray(); + return Task.FromResult(new ToolResult(new CronListToolOutput(jobs))); + } + catch (Exception ex) + { + return Task.FromResult(new ToolResult(new CronListToolOutput(Array.Empty()), true, ex.Message)); + } + } + + public Task ValidateInputAsync(CronListToolInput input) + => Task.FromResult(ValidationResult.Success()); + + public Task CheckPermissionAsync(CronListToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => true; + + public override JsonElement GetInputSchema() + { + return JsonDocument.Parse("{\"type\":\"object\",\"properties\":{}}") .RootElement.Clone(); + } + + public override Task GetDescriptionAsync(object? input = null) + => Task.FromResult("List scheduled cron tasks."); +} diff --git a/src/FreeCode.Tools/CronListToolInput.cs b/src/FreeCode.Tools/CronListToolInput.cs new file mode 100644 index 0000000..b9724d7 --- /dev/null +++ b/src/FreeCode.Tools/CronListToolInput.cs @@ -0,0 +1,8 @@ +namespace FreeCode.Tools; + +public sealed class CronListToolInput +{ + public CronListToolInput() + { + } +} diff --git a/src/FreeCode.Tools/CronListToolOutput.cs b/src/FreeCode.Tools/CronListToolOutput.cs new file mode 100644 index 0000000..8a37a9a --- /dev/null +++ b/src/FreeCode.Tools/CronListToolOutput.cs @@ -0,0 +1,39 @@ +namespace FreeCode.Tools; + +public sealed class CronListToolOutput +{ + public CronListToolOutput(IReadOnlyList jobs) + { + Jobs = jobs; + } + + public IReadOnlyList Jobs { get; } +} + +public sealed class CronListToolJob +{ + public CronListToolJob(string id, string? name, string schedule, string humanSchedule, string command, bool recurring, bool durable, string status, DateTime createdAtUtc, DateTime? nextRunUtc) + { + Id = id; + Name = name; + Schedule = schedule; + HumanSchedule = humanSchedule; + Command = command; + Recurring = recurring; + Durable = durable; + Status = status; + CreatedAtUtc = createdAtUtc; + NextRunUtc = nextRunUtc; + } + + public string Id { get; } + public string? Name { get; } + public string Schedule { get; } + public string HumanSchedule { get; } + public string Command { get; } + public bool Recurring { get; } + public bool Durable { get; } + public string Status { get; } + public DateTime CreatedAtUtc { get; } + public DateTime? NextRunUtc { get; } +} diff --git a/src/FreeCode.Tools/CronTaskStore.cs b/src/FreeCode.Tools/CronTaskStore.cs new file mode 100644 index 0000000..8305baa --- /dev/null +++ b/src/FreeCode.Tools/CronTaskStore.cs @@ -0,0 +1,304 @@ +using System.Collections.Concurrent; + +namespace FreeCode.Tools; + +internal static class CronTaskStore +{ + private static readonly ConcurrentDictionary Tasks = new(StringComparer.OrdinalIgnoreCase); + private static readonly ConcurrentDictionary Timers = new(StringComparer.OrdinalIgnoreCase); + private const int MaxJobs = 50; + + public static IReadOnlyList List() + { + return Tasks.Values + .OrderBy(task => task.CreatedAtUtc) + .ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public static bool ExistsByName(string name) + { + return Tasks.Values.Any(task => string.Equals(task.Name, name, StringComparison.OrdinalIgnoreCase)); + } + + public static bool TryGet(string identifier, out CronTaskState? task) + { + if (Tasks.TryGetValue(identifier, out task)) + { + return true; + } + + task = Tasks.Values.FirstOrDefault(candidate => string.Equals(candidate.Name, identifier, StringComparison.OrdinalIgnoreCase)); + return task is not null; + } + + public static bool TryCreate(string schedule, string command, string? name, bool recurring, bool durable, out CronTaskState? task, out string? error) + { + task = null; + error = null; + + if (Tasks.Count >= MaxJobs) + { + error = $"Too many scheduled jobs (max {MaxJobs})."; + return false; + } + + if (!TryValidateSchedule(schedule, out error, out var interval)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(name) && ExistsByName(name)) + { + error = $"A scheduled job named '{name}' already exists."; + return false; + } + + var id = Guid.NewGuid().ToString("N")[..8]; + var createdAtUtc = DateTime.UtcNow; + var nextRunUtc = createdAtUtc.Add(interval); + task = new CronTaskState( + id, + name, + schedule, + ToHumanSchedule(schedule), + command, + recurring, + durable, + "scheduled", + createdAtUtc, + nextRunUtc); + + if (!Tasks.TryAdd(id, task)) + { + task = null; + error = "Failed to create scheduled task."; + return false; + } + + var timer = new Timer(_ => AdvanceTask(id, interval, recurring), null, interval, recurring ? interval : Timeout.InfiniteTimeSpan); + Timers[id] = timer; + return true; + } + + public static bool TryDelete(string identifier, out CronTaskState? removedTask) + { + removedTask = null; + if (!TryGet(identifier, out var existing) || existing is null) + { + return false; + } + + if (!Tasks.TryRemove(existing.Id, out removedTask)) + { + return false; + } + + if (Timers.TryRemove(existing.Id, out var timer)) + { + timer.Dispose(); + } + + return true; + } + + public static bool TryValidateSchedule(string schedule, out string? error, out TimeSpan interval) + { + error = null; + interval = TimeSpan.Zero; + + if (string.IsNullOrWhiteSpace(schedule)) + { + error = "Schedule is required."; + return false; + } + + var trimmed = schedule.Trim(); + if (TryParseShortInterval(trimmed, out interval)) + { + return true; + } + + var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length != 5) + { + error = $"Invalid cron expression '{schedule}'. Expected 5 fields: M H DoM Mon DoW."; + return false; + } + + if (!parts.All(IsSupportedCronField)) + { + error = $"Invalid cron expression '{schedule}'. Supported values are '*', '*/N', or integers."; + return false; + } + + if (TryParsePositiveInt(parts[3], out var month) && TryParsePositiveInt(parts[2], out var dayOfMonth)) + { + if (month is < 1 or > 12) + { + error = $"Invalid month value '{parts[3]}' in cron expression '{schedule}'."; + return false; + } + + var maxDay = DateTime.DaysInMonth(DateTime.UtcNow.Year, month); + if (dayOfMonth < 1 || dayOfMonth > maxDay) + { + error = $"Cron expression '{schedule}' does not match a valid calendar date."; + return false; + } + } + + interval = ParseCronInterval(parts); + return true; + } + + public static string ToHumanSchedule(string schedule) + { + var trimmed = schedule.Trim(); + if (TryParseShortInterval(trimmed, out var shortInterval)) + { + return $"Every {FormatInterval(shortInterval)}"; + } + + var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length != 5) + { + return schedule; + } + + return (parts[0], parts[1], parts[2], parts[3], parts[4]) switch + { + ("*", "*", "*", "*", "*") => "Every minute", + var value when value.Item1.StartsWith("*/", StringComparison.Ordinal) && value.Item2 == "*" && value.Item3 == "*" && value.Item4 == "*" && value.Item5 == "*" + => $"Every {value.Item1[2..]} minutes", + var value when TryParsePositiveInt(value.Item1, out var minute) && value.Item2 == "*" && value.Item3 == "*" && value.Item4 == "*" && value.Item5 == "*" + => $"At minute {minute} of every hour", + var value when TryParsePositiveInt(value.Item1, out var exactMinute) && TryParsePositiveInt(value.Item2, out var hour) && value.Item3 == "*" && value.Item4 == "*" && value.Item5 == "*" + => $"At {hour:D2}:{exactMinute:D2} every day", + _ => $"Cron: {schedule}" + }; + } + + private static void AdvanceTask(string id, TimeSpan interval, bool recurring) + { + if (!Tasks.TryGetValue(id, out var existing)) + { + return; + } + + if (!recurring) + { + TryDelete(id, out _); + return; + } + + Tasks.TryUpdate(id, existing with { NextRunUtc = DateTime.UtcNow.Add(interval), Status = "scheduled" }, existing); + } + + private static bool TryParseShortInterval(string schedule, out TimeSpan interval) + { + interval = TimeSpan.Zero; + if (schedule.Length < 2) + { + return false; + } + + var unit = char.ToLowerInvariant(schedule[^1]); + if (!int.TryParse(schedule[..^1], out var value) || value <= 0) + { + return false; + } + + interval = unit switch + { + 's' => TimeSpan.FromSeconds(value), + 'm' => TimeSpan.FromMinutes(value), + 'h' => TimeSpan.FromHours(value), + 'd' => TimeSpan.FromDays(value), + _ => TimeSpan.Zero + }; + + return interval > TimeSpan.Zero; + } + + private static bool IsSupportedCronField(string value) + { + if (value == "*") + { + return true; + } + + if (value.StartsWith("*/", StringComparison.Ordinal) && int.TryParse(value[2..], out var step)) + { + return step > 0; + } + + return TryParsePositiveInt(value, out _); + } + + private static TimeSpan ParseCronInterval(string[] parts) + { + if (parts[0] == "*" && parts[1] == "*" && parts[2] == "*" && parts[3] == "*" && parts[4] == "*") + { + return TimeSpan.FromMinutes(1); + } + + if (parts[0].StartsWith("*/", StringComparison.Ordinal) && int.TryParse(parts[0][2..], out var steppedMinutes) && steppedMinutes > 0) + { + return TimeSpan.FromMinutes(steppedMinutes); + } + + if (TryParsePositiveInt(parts[0], out _) && parts[1] == "*" && parts[2] == "*" && parts[3] == "*" && parts[4] == "*") + { + return TimeSpan.FromHours(1); + } + + if (TryParsePositiveInt(parts[0], out _) && TryParsePositiveInt(parts[1], out _) && parts[2] == "*" && parts[3] == "*" && parts[4] == "*") + { + return TimeSpan.FromDays(1); + } + + return TimeSpan.FromDays(1); + } + + private static bool TryParsePositiveInt(string value, out int number) + { + return int.TryParse(value, out number) && number >= 0; + } + + private static string FormatInterval(TimeSpan interval) + { + if (interval.TotalDays >= 1 && interval.TotalDays == Math.Truncate(interval.TotalDays)) + { + var days = (int)interval.TotalDays; + return days == 1 ? "day" : $"{days} days"; + } + + if (interval.TotalHours >= 1 && interval.TotalHours == Math.Truncate(interval.TotalHours)) + { + var hours = (int)interval.TotalHours; + return hours == 1 ? "hour" : $"{hours} hours"; + } + + if (interval.TotalMinutes >= 1 && interval.TotalMinutes == Math.Truncate(interval.TotalMinutes)) + { + var minutes = (int)interval.TotalMinutes; + return minutes == 1 ? "minute" : $"{minutes} minutes"; + } + + var seconds = Math.Max(1, (int)interval.TotalSeconds); + return seconds == 1 ? "second" : $"{seconds} seconds"; + } +} + +internal sealed record CronTaskState( + string Id, + string? Name, + string Schedule, + string HumanSchedule, + string Command, + bool Recurring, + bool Durable, + string Status, + DateTime CreatedAtUtc, + DateTime? NextRunUtc); diff --git a/src/FreeCode.Tools/CronTool.cs b/src/FreeCode.Tools/CronTool.cs new file mode 100644 index 0000000..786ea6c --- /dev/null +++ b/src/FreeCode.Tools/CronTool.cs @@ -0,0 +1,162 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class CronTool : ToolBase, ITool +{ + private static readonly ConcurrentDictionary ScheduledTasks = new(StringComparer.OrdinalIgnoreCase); + private static readonly ConcurrentDictionary Timers = new(StringComparer.OrdinalIgnoreCase); + + public override string Name => "Cron"; + public override ToolCategory Category => ToolCategory.Task; + + public Task> ExecuteAsync(CronToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var action = input.Action.ToLowerInvariant(); + + if (action == "list") + { + var tasks = ScheduledTasks.Values.ToList(); + return Task.FromResult(new ToolResult(new CronToolOutput(null, tasks, true))); + } + + if (action == "create") + { + if (string.IsNullOrWhiteSpace(input.Schedule) || string.IsNullOrWhiteSpace(input.Command)) + { + return Task.FromResult(new ToolResult(new CronToolOutput(null, null, false), true, "Schedule and Command are required for create action.")); + } + + var taskId = Guid.NewGuid().ToString("N")[..8]; + var interval = ParseScheduleToInterval(input.Schedule); + var nextRun = DateTime.UtcNow.Add(interval); + var task = new ScheduledTask(taskId, input.Schedule, input.Command, nextRun); + + if (!ScheduledTasks.TryAdd(taskId, task)) + { + return Task.FromResult(new ToolResult(new CronToolOutput(null, null, false), true, "Failed to create scheduled task.")); + } + + var timer = new Timer(_ => + { + if (ScheduledTasks.TryGetValue(taskId, out var existing)) + { + ScheduledTasks.TryUpdate(taskId, existing with { NextRun = DateTime.UtcNow.Add(interval) }, existing); + } + }, null, interval, interval); + Timers.TryAdd(taskId, timer); + + return Task.FromResult(new ToolResult(new CronToolOutput(taskId, null, true))); + } + + if (action == "delete") + { + if (string.IsNullOrWhiteSpace(input.TaskId)) + { + return Task.FromResult(new ToolResult(new CronToolOutput(null, null, false), true, "TaskId is required for delete action.")); + } + + ScheduledTasks.TryRemove(input.TaskId, out _); + if (Timers.TryRemove(input.TaskId, out var timer)) + { + timer.Dispose(); + } + + return Task.FromResult(new ToolResult(new CronToolOutput(input.TaskId, null, true))); + } + + return Task.FromResult(new ToolResult(new CronToolOutput(null, null, false), true, $"Unknown action: {input.Action}. Use 'create', 'delete', or 'list'.")); + } + catch (Exception ex) + { + return Task.FromResult(new ToolResult(new CronToolOutput(null, null, false), true, ex.Message)); + } + } + + public Task ValidateInputAsync(CronToolInput input) + => Task.FromResult(string.IsNullOrWhiteSpace(input.Action) ? ValidationResult.Failure(new[] { "Action is required." }) : ValidationResult.Success()); + + public Task CheckPermissionAsync(CronToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => false; + public override bool IsReadOnly(object input) => input is CronToolInput ci && string.Equals(ci.Action, "list", StringComparison.OrdinalIgnoreCase); + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Schedule\":{\"type\":\"string\"},\"Command\":{\"type\":\"string\"},\"Action\":{\"type\":\"string\",\"enum\":[\"create\",\"delete\",\"list\"]},\"TaskId\":{\"type\":\"string\"}},\"required\":[\"Action\"]}").RootElement.Clone(); + + private static TimeSpan ParseScheduleToInterval(string schedule) + { + var trimmed = schedule.Trim().ToLowerInvariant(); + + if (trimmed.EndsWith('s') && int.TryParse(trimmed[..^1], out var seconds)) + { + return TimeSpan.FromSeconds(seconds); + } + + if (trimmed.EndsWith('m') && int.TryParse(trimmed[..^1], out var minutes)) + { + return TimeSpan.FromMinutes(minutes); + } + + if (trimmed.EndsWith('h') && int.TryParse(trimmed[..^1], out var hours)) + { + return TimeSpan.FromHours(hours); + } + + if (trimmed.EndsWith('d') && int.TryParse(trimmed[..^1], out var days)) + { + return TimeSpan.FromDays(days); + } + + var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 5) + { + if (parts[0] == "*" && parts[1] == "*") + { + return TimeSpan.FromMinutes(1); + } + + if (int.TryParse(parts[0], out var cronMinutes)) + { + return TimeSpan.FromMinutes(cronMinutes == 0 ? 60 : cronMinutes); + } + } + + return TimeSpan.FromMinutes(5); + } +} + +public sealed record ScheduledTask(string Id, string Schedule, string Command, DateTime? NextRun); + +public sealed class CronToolInput +{ + public CronToolInput(string action, string? schedule = null, string? command = null, string? taskId = null) + { + Action = action; + Schedule = schedule; + Command = command; + TaskId = taskId; + } + + public string Action { get; } + public string? Schedule { get; } + public string? Command { get; } + public string? TaskId { get; } +} + +public sealed class CronToolOutput +{ + public CronToolOutput(string? taskId, IReadOnlyList? tasks, bool success) + { + TaskId = taskId; + Tasks = tasks; + Success = success; + } + + public string? TaskId { get; } + public IReadOnlyList? Tasks { get; } + public bool Success { get; } +} diff --git a/src/FreeCode.Tools/DiscoverSkillsTool.cs b/src/FreeCode.Tools/DiscoverSkillsTool.cs new file mode 100644 index 0000000..9b49df3 --- /dev/null +++ b/src/FreeCode.Tools/DiscoverSkillsTool.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using FreeCode.Skills; + +namespace FreeCode.Tools; + +public sealed class DiscoverSkillsTool : ToolBase, ITool +{ + public override string Name => "DiscoverSkills"; + + public override string[]? Aliases => ["SkillsDiscovery"]; + + public override ToolCategory Category => ToolCategory.Agent; + + public override bool IsConcurrencySafe(object input) => true; + + public override bool IsReadOnly(object input) => true; + + public override bool IsEnabled() => true; + + public override JsonElement GetInputSchema() + { + return JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Directory\":{\"type\":\"string\"},\"Query\":{\"type\":\"string\"},\"IncludeContent\":{\"type\":\"boolean\"}}}").RootElement.Clone(); + } + + public override Task GetDescriptionAsync(object? input = null) + { + if (input is DiscoverSkillsToolInput skillsInput && !string.IsNullOrWhiteSpace(skillsInput.Query)) + { + return Task.FromResult($"Discover skills matching {skillsInput.Query}"); + } + + return Task.FromResult("Discover available skills and their metadata."); + } + + public Task ValidateInputAsync(DiscoverSkillsToolInput input) + => Task.FromResult(ValidationResult.Success()); + + public Task CheckPermissionAsync(DiscoverSkillsToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public async Task> ExecuteAsync(DiscoverSkillsToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var loader = context.Services.GetService(typeof(ISkillLoader)) as ISkillLoader; + if (loader is null) + { + return new ToolResult(new DiscoverSkillsToolOutput(), true, "Skill loader is unavailable."); + } + + var resolvedDirectory = string.IsNullOrWhiteSpace(input.Directory) + ? context.WorkingDirectory + : ToolUtilities.ResolvePath(input.Directory, context.WorkingDirectory); + + var loaded = await loader.LoadSkillsAsync(input.Directory).ConfigureAwait(false); + var skills = loaded.OfType(); + if (!string.IsNullOrWhiteSpace(input.Query)) + { + skills = skills.Where(skill => MatchesQuery(skill, input.Query)); + } + + var items = skills + .OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase) + .Select(skill => new DiscoverSkillsToolSkillInfo + { + Name = skill.Name, + Description = skill.Description, + Tools = skill.Tools, + Model = skill.Model, + FilePath = skill.FilePath, + Content = input.IncludeContent ? skill.Content : null + }) + .ToArray(); + + return new ToolResult(new DiscoverSkillsToolOutput + { + Skills = items, + Count = items.Length, + Directory = resolvedDirectory + }); + } + catch (Exception ex) + { + return new ToolResult(new DiscoverSkillsToolOutput(), true, ex.Message); + } + } + + private static bool MatchesQuery(SkillDefinition skill, string query) + { + return skill.Name.Contains(query, StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrWhiteSpace(skill.Description) && skill.Description.Contains(query, StringComparison.OrdinalIgnoreCase)) + || skill.Tools.Any(tool => tool.Contains(query, StringComparison.OrdinalIgnoreCase)) + || skill.Content.Contains(query, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/FreeCode.Tools/DiscoverSkillsToolInput.cs b/src/FreeCode.Tools/DiscoverSkillsToolInput.cs new file mode 100644 index 0000000..7cb00c4 --- /dev/null +++ b/src/FreeCode.Tools/DiscoverSkillsToolInput.cs @@ -0,0 +1,14 @@ +namespace FreeCode.Tools; + +public sealed class DiscoverSkillsToolInput +{ + public DiscoverSkillsToolInput() + { + } + + public string? Directory { get; set; } + + public string? Query { get; set; } + + public bool IncludeContent { get; set; } +} diff --git a/src/FreeCode.Tools/DiscoverSkillsToolOutput.cs b/src/FreeCode.Tools/DiscoverSkillsToolOutput.cs new file mode 100644 index 0000000..5ec1772 --- /dev/null +++ b/src/FreeCode.Tools/DiscoverSkillsToolOutput.cs @@ -0,0 +1,29 @@ +namespace FreeCode.Tools; + +public sealed class DiscoverSkillsToolOutput +{ + public DiscoverSkillsToolOutput() + { + } + + public IReadOnlyList Skills { get; set; } = []; + + public int Count { get; set; } + + public string Directory { get; set; } = string.Empty; +} + +public sealed class DiscoverSkillsToolSkillInfo +{ + public string Name { get; set; } = string.Empty; + + public string? Description { get; set; } + + public IReadOnlyList Tools { get; set; } = []; + + public string? Model { get; set; } + + public string? FilePath { get; set; } + + public string? Content { get; set; } +} diff --git a/src/FreeCode.Tools/EnterPlanModeTool.cs b/src/FreeCode.Tools/EnterPlanModeTool.cs new file mode 100644 index 0000000..288073e --- /dev/null +++ b/src/FreeCode.Tools/EnterPlanModeTool.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class EnterPlanModeTool : ToolBase, ITool +{ + private static bool _isPlanMode; + private readonly IAppStateStore _stateStore; + + public EnterPlanModeTool(IAppStateStore stateStore) + { + _stateStore = stateStore; + } + + public override string Name => "EnterPlanMode"; + public override ToolCategory Category => ToolCategory.PlanMode; + + public Task> ExecuteAsync(EnterPlanModeToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + if (_isPlanMode) + { + global::FreeCode.State.AppStateStoreExtensions.Update(_stateStore, state => state with { PermissionMode = PermissionMode.Plan }); + return Task.FromResult(new ToolResult(new EnterPlanModeToolOutput(true, "Already in plan mode."))); + } + + _isPlanMode = true; + global::FreeCode.State.AppStateStoreExtensions.Update(_stateStore, state => state with { PermissionMode = PermissionMode.Plan }); + var message = input.Reason is not null ? $"Entered plan mode: {input.Reason}" : "Entered plan mode."; + return Task.FromResult(new ToolResult(new EnterPlanModeToolOutput(true, message))); + } + catch (Exception ex) + { + return Task.FromResult(new ToolResult(new EnterPlanModeToolOutput(false, ex.Message), true, ex.Message)); + } + } + + public Task ValidateInputAsync(EnterPlanModeToolInput input) => Task.FromResult(ValidationResult.Success()); + public Task CheckPermissionAsync(EnterPlanModeToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => false; + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Reason\":{\"type\":\"string\"}}}").RootElement.Clone(); + + internal static bool IsPlanMode => _isPlanMode; + internal static void Reset() => _isPlanMode = false; +} + +public sealed class EnterPlanModeToolInput +{ + public EnterPlanModeToolInput(string? reason = null) + { + Reason = reason; + } + + public string? Reason { get; } +} + +public sealed class EnterPlanModeToolOutput +{ + public EnterPlanModeToolOutput(bool entered, string message) + { + Entered = entered; + Message = message; + } + + public bool Entered { get; } + public string Message { get; } +} diff --git a/src/FreeCode.Tools/EnterWorktreeTool.cs b/src/FreeCode.Tools/EnterWorktreeTool.cs new file mode 100644 index 0000000..3252729 --- /dev/null +++ b/src/FreeCode.Tools/EnterWorktreeTool.cs @@ -0,0 +1,88 @@ +using System.Diagnostics; +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class EnterWorktreeTool : ToolBase, ITool +{ + public override string Name => "EnterWorktree"; + public override ToolCategory Category => ToolCategory.Worktree; + + public async Task> ExecuteAsync(EnterWorktreeToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var worktreePath = input.Path ?? Path.Combine(context.WorkingDirectory, "..", $"worktree-{input.Branch.Replace('/', '-')}"); + worktreePath = Path.GetFullPath(worktreePath); + + var psi = new ProcessStartInfo + { + FileName = "git", + Arguments = $"worktree add {QuoteArg(worktreePath)} {QuoteArg(input.Branch)}", + WorkingDirectory = context.WorkingDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var process = new Process { StartInfo = psi, EnableRaisingEvents = true }; + if (!process.Start()) + { + return new ToolResult(new EnterWorktreeToolOutput(string.Empty, input.Branch), true, "Failed to start git process."); + } + + var stdout = await process.StandardOutput.ReadToEndAsync(ct).ConfigureAwait(false); + var stderr = await process.StandardError.ReadToEndAsync(ct).ConfigureAwait(false); + await process.WaitForExitAsync(ct).ConfigureAwait(false); + + if (process.ExitCode != 0) + { + return new ToolResult(new EnterWorktreeToolOutput(string.Empty, input.Branch), true, $"git worktree add failed: {stderr}"); + } + + return new ToolResult(new EnterWorktreeToolOutput(worktreePath, input.Branch)); + } + catch (Exception ex) + { + return new ToolResult(new EnterWorktreeToolOutput(string.Empty, input.Branch), true, ex.Message); + } + } + + public Task ValidateInputAsync(EnterWorktreeToolInput input) + => Task.FromResult(string.IsNullOrWhiteSpace(input.Branch) ? ValidationResult.Failure(new[] { "Branch is required." }) : ValidationResult.Success()); + + public Task CheckPermissionAsync(EnterWorktreeToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => false; + public override bool IsReadOnly(object input) => false; + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Branch\":{\"type\":\"string\"},\"Path\":{\"type\":\"string\"}},\"required\":[\"Branch\"]}").RootElement.Clone(); + + private static string QuoteArg(string arg) => $"\"{arg.Replace("\"", "\\\"")}\""; +} + +public sealed class EnterWorktreeToolInput +{ + public EnterWorktreeToolInput(string branch, string? path = null) + { + Branch = branch; + Path = path; + } + + public string Branch { get; } + public string? Path { get; } +} + +public sealed class EnterWorktreeToolOutput +{ + public EnterWorktreeToolOutput(string worktreePath, string branch) + { + WorktreePath = worktreePath; + Branch = branch; + } + + public string WorktreePath { get; } + public string Branch { get; } +} diff --git a/src/FreeCode.Tools/ExitPlanModeTool.cs b/src/FreeCode.Tools/ExitPlanModeTool.cs new file mode 100644 index 0000000..dd4bb4c --- /dev/null +++ b/src/FreeCode.Tools/ExitPlanModeTool.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class ExitPlanModeTool : ToolBase, ITool +{ + private readonly IAppStateStore _stateStore; + + public ExitPlanModeTool(IAppStateStore stateStore) + { + _stateStore = stateStore; + } + + public override string Name => "ExitPlanMode"; + public override ToolCategory Category => ToolCategory.PlanMode; + + public Task> ExecuteAsync(ExitPlanModeToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + if (!EnterPlanModeTool.IsPlanMode) + { + global::FreeCode.State.AppStateStoreExtensions.Update(_stateStore, state => state with { PermissionMode = PermissionMode.Default }); + return Task.FromResult(new ToolResult(new ExitPlanModeToolOutput(true, input.Plan))); + } + + EnterPlanModeTool.Reset(); + global::FreeCode.State.AppStateStoreExtensions.Update(_stateStore, state => state with { PermissionMode = PermissionMode.Default }); + return Task.FromResult(new ToolResult(new ExitPlanModeToolOutput(true, input.Plan))); + } + catch (Exception ex) + { + return Task.FromResult(new ToolResult(new ExitPlanModeToolOutput(false, null), true, ex.Message)); + } + } + + public Task ValidateInputAsync(ExitPlanModeToolInput input) => Task.FromResult(ValidationResult.Success()); + public Task CheckPermissionAsync(ExitPlanModeToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => false; + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Plan\":{\"type\":\"string\"}}}").RootElement.Clone(); +} + +public sealed class ExitPlanModeToolInput +{ + public ExitPlanModeToolInput(string? plan = null) + { + Plan = plan; + } + + public string? Plan { get; } +} + +public sealed class ExitPlanModeToolOutput +{ + public ExitPlanModeToolOutput(bool exited, string? plan) + { + Exited = exited; + Plan = plan; + } + + public bool Exited { get; } + public string? Plan { get; } +} diff --git a/src/FreeCode.Tools/ExitWorktreeTool.cs b/src/FreeCode.Tools/ExitWorktreeTool.cs new file mode 100644 index 0000000..f4a51fe --- /dev/null +++ b/src/FreeCode.Tools/ExitWorktreeTool.cs @@ -0,0 +1,83 @@ +using System.Diagnostics; +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class ExitWorktreeTool : ToolBase, ITool +{ + public override string Name => "ExitWorktree"; + public override ToolCategory Category => ToolCategory.Worktree; + + public async Task> ExecuteAsync(ExitWorktreeToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var worktreePath = input.Path ?? context.WorkingDirectory; + worktreePath = System.IO.Path.GetFullPath(worktreePath); + + var psi = new ProcessStartInfo + { + FileName = "git", + Arguments = $"worktree remove {QuoteArg(worktreePath)}", + WorkingDirectory = context.WorkingDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var process = new Process { StartInfo = psi, EnableRaisingEvents = true }; + if (!process.Start()) + { + return new ToolResult(new ExitWorktreeToolOutput(false, context.WorkingDirectory), true, "Failed to start git process."); + } + + var stderr = await process.StandardError.ReadToEndAsync(ct).ConfigureAwait(false); + await process.WaitForExitAsync(ct).ConfigureAwait(false); + + if (process.ExitCode != 0) + { + return new ToolResult(new ExitWorktreeToolOutput(false, context.WorkingDirectory), true, $"git worktree remove failed: {stderr}"); + } + + return new ToolResult(new ExitWorktreeToolOutput(true, context.WorkingDirectory)); + } + catch (Exception ex) + { + return new ToolResult(new ExitWorktreeToolOutput(false, context.WorkingDirectory), true, ex.Message); + } + } + + public Task ValidateInputAsync(ExitWorktreeToolInput input) => Task.FromResult(ValidationResult.Success()); + public Task CheckPermissionAsync(ExitWorktreeToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => false; + public override bool IsReadOnly(object input) => false; + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Path\":{\"type\":\"string\"}}}").RootElement.Clone(); + + private static string QuoteArg(string arg) => $"\"{arg.Replace("\"", "\\\"")}\""; +} + +public sealed class ExitWorktreeToolInput +{ + public ExitWorktreeToolInput(string? path = null) + { + Path = path; + } + + public string? Path { get; } +} + +public sealed class ExitWorktreeToolOutput +{ + public ExitWorktreeToolOutput(bool exited, string originalPath) + { + Exited = exited; + OriginalPath = originalPath; + } + + public bool Exited { get; } + public string OriginalPath { get; } +} diff --git a/src/FreeCode.Tools/FileEditTool.cs b/src/FreeCode.Tools/FileEditTool.cs new file mode 100644 index 0000000..80c1004 --- /dev/null +++ b/src/FreeCode.Tools/FileEditTool.cs @@ -0,0 +1,78 @@ +using System.Text; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class FileEditTool : ToolBase, ITool +{ + public override string Name => "Edit"; + public override ToolCategory Category => ToolCategory.FileSystem; + + public Task> ExecuteAsync(FileEditToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var path = ResolvePath(input.FilePath, context.WorkingDirectory); + var content = File.ReadAllText(path, Encoding.UTF8); + var occurrences = CountOccurrences(content, input.OldString); + if (occurrences == 0) + { + return Task.FromResult(new ToolResult(string.Empty, true, "OldString not found.")); + } + + var updated = input.ReplaceAll + ? content.Replace(input.OldString, input.NewString, StringComparison.Ordinal) + : ReplaceFirst(content, input.OldString, input.NewString); + + File.WriteAllText(path, updated, Encoding.UTF8); + return Task.FromResult(new ToolResult($"Updated {path}; replacements: {(input.ReplaceAll ? occurrences : 1)}.")); + } + catch (Exception ex) + { + return Task.FromResult(new ToolResult(string.Empty, true, ex.Message)); + } + } + + public Task ValidateInputAsync(FileEditToolInput input) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(input.FilePath)) errors.Add("FilePath is required."); + if (string.IsNullOrEmpty(input.OldString)) errors.Add("OldString is required."); + return Task.FromResult(errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors)); + } + + public Task CheckPermissionAsync(FileEditToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public override bool IsConcurrencySafe(object input) => false; + public override bool IsReadOnly(object input) => false; + + private static string ResolvePath(string path, string workingDirectory) + => Path.IsPathRooted(path) ? path : Path.GetFullPath(Path.Combine(workingDirectory, path)); + + private static int CountOccurrences(string content, string search) + { + var count = 0; + var index = 0; + while ((index = content.IndexOf(search, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += search.Length == 0 ? 1 : search.Length; + } + + return count; + } + + private static string ReplaceFirst(string content, string oldValue, string newValue) + { + var index = content.IndexOf(oldValue, StringComparison.Ordinal); + if (index < 0) + { + return content; + } + + return content.Remove(index, oldValue.Length).Insert(index, newValue); + } +} diff --git a/src/FreeCode.Tools/FileEditToolInput.cs b/src/FreeCode.Tools/FileEditToolInput.cs new file mode 100644 index 0000000..d6dede1 --- /dev/null +++ b/src/FreeCode.Tools/FileEditToolInput.cs @@ -0,0 +1,17 @@ +namespace FreeCode.Tools; + +public sealed class FileEditToolInput +{ + public FileEditToolInput(string filePath, string oldString, string newString, bool replaceAll = false) + { + FilePath = filePath; + OldString = oldString; + NewString = newString; + ReplaceAll = replaceAll; + } + + public string FilePath { get; } + public string OldString { get; } + public string NewString { get; } + public bool ReplaceAll { get; } +} diff --git a/src/FreeCode.Tools/FileReadTool.cs b/src/FreeCode.Tools/FileReadTool.cs new file mode 100644 index 0000000..49f2d50 --- /dev/null +++ b/src/FreeCode.Tools/FileReadTool.cs @@ -0,0 +1,45 @@ +using System.Text; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class FileReadTool : ToolBase, ITool +{ + public override string Name => "Read"; + public override ToolCategory Category => ToolCategory.FileSystem; + + public Task> ExecuteAsync(FileReadToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var path = ResolvePath(input.FilePath, context.WorkingDirectory); + var lines = File.ReadAllLines(path, Encoding.UTF8); + var start = Math.Clamp(input.Offset, 0, lines.Length); + var take = input.Limit > 0 ? Math.Min(input.Limit, lines.Length - start) : lines.Length - start; + var content = string.Join(Environment.NewLine, lines.Skip(start).Take(take)); + return Task.FromResult(new ToolResult(content)); + } + catch (Exception ex) + { + return Task.FromResult(new ToolResult(string.Empty, true, ex.Message)); + } + } + + public Task ValidateInputAsync(FileReadToolInput input) + { + return Task.FromResult(string.IsNullOrWhiteSpace(input.FilePath) + ? ValidationResult.Failure(new[] { "FilePath is required." }) + : ValidationResult.Success()); + } + + public Task CheckPermissionAsync(FileReadToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => true; + + private static string ResolvePath(string path, string workingDirectory) + => Path.IsPathRooted(path) ? path : Path.GetFullPath(Path.Combine(workingDirectory, path)); +} diff --git a/src/FreeCode.Tools/FileReadToolInput.cs b/src/FreeCode.Tools/FileReadToolInput.cs new file mode 100644 index 0000000..2050171 --- /dev/null +++ b/src/FreeCode.Tools/FileReadToolInput.cs @@ -0,0 +1,15 @@ +namespace FreeCode.Tools; + +public sealed class FileReadToolInput +{ + public FileReadToolInput(string filePath, int offset = 0, int limit = 0) + { + FilePath = filePath; + Offset = offset; + Limit = limit; + } + + public string FilePath { get; } + public int Offset { get; } + public int Limit { get; } +} diff --git a/src/FreeCode.Tools/FileWriteTool.cs b/src/FreeCode.Tools/FileWriteTool.cs new file mode 100644 index 0000000..926c770 --- /dev/null +++ b/src/FreeCode.Tools/FileWriteTool.cs @@ -0,0 +1,44 @@ +using System.Text; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class FileWriteTool : ToolBase, ITool +{ + public override string Name => "Write"; + public override ToolCategory Category => ToolCategory.FileSystem; + + public Task> ExecuteAsync(FileWriteToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var path = ResolvePath(input.FilePath, context.WorkingDirectory); + Directory.CreateDirectory(Path.GetDirectoryName(path) ?? context.WorkingDirectory); + File.WriteAllText(path, input.Content, Encoding.UTF8); + return Task.FromResult(new ToolResult($"Wrote {input.Content.Length} characters to {path}.")); + } + catch (Exception ex) + { + return Task.FromResult(new ToolResult(string.Empty, true, ex.Message)); + } + } + + public Task ValidateInputAsync(FileWriteToolInput input) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(input.FilePath)) errors.Add("FilePath is required."); + if (input.Content is null) errors.Add("Content is required."); + return Task.FromResult(errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors)); + } + + public Task CheckPermissionAsync(FileWriteToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public override bool IsConcurrencySafe(object input) => false; + public override bool IsReadOnly(object input) => false; + + private static string ResolvePath(string path, string workingDirectory) + => Path.IsPathRooted(path) ? path : Path.GetFullPath(Path.Combine(workingDirectory, path)); +} diff --git a/src/FreeCode.Tools/FileWriteToolInput.cs b/src/FreeCode.Tools/FileWriteToolInput.cs new file mode 100644 index 0000000..01e9f00 --- /dev/null +++ b/src/FreeCode.Tools/FileWriteToolInput.cs @@ -0,0 +1,13 @@ +namespace FreeCode.Tools; + +public sealed class FileWriteToolInput +{ + public FileWriteToolInput(string filePath, string content) + { + FilePath = filePath; + Content = content; + } + + public string FilePath { get; } + public string Content { get; } +} diff --git a/src/FreeCode.Tools/FreeCode.Tools.csproj b/src/FreeCode.Tools/FreeCode.Tools.csproj new file mode 100644 index 0000000..8b77b22 --- /dev/null +++ b/src/FreeCode.Tools/FreeCode.Tools.csproj @@ -0,0 +1,13 @@ + + + FreeCode.Tools + + + + + + + + + + diff --git a/src/FreeCode.Tools/GlobTool.cs b/src/FreeCode.Tools/GlobTool.cs new file mode 100644 index 0000000..9254e55 --- /dev/null +++ b/src/FreeCode.Tools/GlobTool.cs @@ -0,0 +1,85 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class GlobTool : ToolBase, ITool +{ + public override string Name => "Glob"; + public override ToolCategory Category => ToolCategory.FileSystem; + + public Task> ExecuteAsync(GlobToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var basePath = ToolUtilities.ResolvePath(input.Path ?? ".", context.WorkingDirectory); + if (!Directory.Exists(basePath) && !File.Exists(basePath)) + { + return Task.FromResult(new ToolResult(new GlobToolOutput([], false), true, $"Path not found: {basePath}")); + } + + var regex = ToolUtilities.GlobToRegex(input.Pattern); + var root = Directory.Exists(basePath) ? basePath : Path.GetDirectoryName(basePath) ?? context.WorkingDirectory; + var matches = new List(); + var start = Directory.Exists(basePath) ? basePath : Path.GetDirectoryName(basePath)!; + + foreach (var entry in Directory.EnumerateFileSystemEntries(root, "*", SearchOption.AllDirectories)) + { + ct.ThrowIfCancellationRequested(); + var relative = Path.GetRelativePath(basePath, entry).Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/'); + if (!regex.IsMatch(relative)) + { + continue; + } + + matches.Add(Path.GetFullPath(entry)); + if (input.MaxResults > 0 && matches.Count >= input.MaxResults) + { + break; + } + } + + return Task.FromResult(new ToolResult(new GlobToolOutput(matches, input.MaxResults > 0 && matches.Count >= input.MaxResults))); + } + catch (Exception ex) + { + return Task.FromResult(new ToolResult(new GlobToolOutput([], false), true, ex.Message)); + } + } + + public Task ValidateInputAsync(GlobToolInput input) + => Task.FromResult(string.IsNullOrWhiteSpace(input.Pattern) ? ValidationResult.Failure(new[] { "Pattern is required." }) : ValidationResult.Success()); + + public Task CheckPermissionAsync(GlobToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => true; + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Pattern\":{\"type\":\"string\"},\"Path\":{\"type\":\"string\"},\"MaxResults\":{\"type\":\"integer\"}},\"required\":[\"Pattern\"]}").RootElement.Clone(); +} + +public sealed class GlobToolInput +{ + public GlobToolInput(string pattern, string? path = null, int maxResults = 1000) + { + Pattern = pattern; + Path = path; + MaxResults = maxResults; + } + + public string Pattern { get; } + public string? Path { get; } + public int MaxResults { get; } +} + +public sealed class GlobToolOutput +{ + public GlobToolOutput(IReadOnlyList matches, bool truncated) + { + Matches = matches; + Truncated = truncated; + } + + public IReadOnlyList Matches { get; } + public bool Truncated { get; } +} diff --git a/src/FreeCode.Tools/GrepTool.cs b/src/FreeCode.Tools/GrepTool.cs new file mode 100644 index 0000000..a8d9259 --- /dev/null +++ b/src/FreeCode.Tools/GrepTool.cs @@ -0,0 +1,100 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class GrepTool : ToolBase, ITool +{ + public override string Name => "Grep"; + public override ToolCategory Category => ToolCategory.FileSystem; + + public Task> ExecuteAsync(GrepToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var basePath = ToolUtilities.ResolvePath(input.Path ?? ".", context.WorkingDirectory); + if (!Directory.Exists(basePath)) + { + return Task.FromResult(new ToolResult(new GrepToolOutput([], 0), true, $"Path not found: {basePath}")); + } + + var regex = new Regex(input.Pattern, RegexOptions.Compiled | RegexOptions.CultureInvariant); + var include = input.Include is { Count: > 0 } ? input.Include.ToArray() : null; + var matches = new List(); + var total = 0; + + foreach (var file in Directory.EnumerateFiles(basePath, "*", SearchOption.AllDirectories)) + { + ct.ThrowIfCancellationRequested(); + if (include is not null && !include.Any(pattern => ToolUtilities.MatchesGlob(Path.GetRelativePath(basePath, file), pattern))) + { + continue; + } + + var lineNumber = 0; + foreach (var line in File.ReadLines(file)) + { + lineNumber++; + var match = regex.Match(line); + if (!match.Success) + { + continue; + } + + total++; + if (input.MaxResults <= 0 || matches.Count < input.MaxResults) + { + matches.Add(new GrepMatch(Path.GetFullPath(file), lineNumber, line, match.Value)); + } + } + } + + return Task.FromResult(new ToolResult(new GrepToolOutput(matches, total))); + } + catch (Exception ex) + { + return Task.FromResult(new ToolResult(new GrepToolOutput([], 0), true, ex.Message)); + } + } + + public Task ValidateInputAsync(GrepToolInput input) + => Task.FromResult(string.IsNullOrWhiteSpace(input.Pattern) ? ValidationResult.Failure(new[] { "Pattern is required." }) : ValidationResult.Success()); + + public Task CheckPermissionAsync(GrepToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => true; + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Pattern\":{\"type\":\"string\"},\"Path\":{\"type\":\"string\"},\"Include\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"MaxResults\":{\"type\":\"integer\"}},\"required\":[\"Pattern\"]}").RootElement.Clone(); +} + +public sealed class GrepToolInput +{ + public GrepToolInput(string pattern, string? path = null, IReadOnlyList? include = null, int maxResults = 1000) + { + Pattern = pattern; + Path = path; + Include = include; + MaxResults = maxResults; + } + + public string Pattern { get; } + public string? Path { get; } + public IReadOnlyList? Include { get; } + public int MaxResults { get; } +} + +public sealed class GrepToolOutput +{ + public GrepToolOutput(IReadOnlyList matches, int totalMatches) + { + Matches = matches; + TotalMatches = totalMatches; + } + + public IReadOnlyList Matches { get; } + public int TotalMatches { get; } +} + +public sealed record GrepMatch(string FilePath, int LineNumber, string Line, string Highlight); diff --git a/src/FreeCode.Tools/ListMcpResourcesTool.cs b/src/FreeCode.Tools/ListMcpResourcesTool.cs new file mode 100644 index 0000000..86b6ecb --- /dev/null +++ b/src/FreeCode.Tools/ListMcpResourcesTool.cs @@ -0,0 +1,63 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class ListMcpResourcesTool : ToolBase, ITool +{ + public override string Name => "ListMcpResources"; + public override ToolCategory Category => ToolCategory.Mcp; + + public async Task> ExecuteAsync(ListMcpResourcesToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + if (context.Services.GetService(typeof(IMcpClientManager)) is not IMcpClientManager manager) + { + return new ToolResult(new ListMcpResourcesToolOutput([], 0), true, "MCP client manager is unavailable."); + } + + var resources = await manager.ListResourcesAsync(input.ServerName, ct).ConfigureAwait(false); + var mapped = resources.Select(r => new McpResourceInfo(r.Uri, r.Name, r.Description, r.MimeType)).ToList(); + var connections = manager.GetConnections(); + var serverCount = input.ServerName is not null ? (connections.Any(c => string.Equals(c.Name, input.ServerName, StringComparison.OrdinalIgnoreCase)) ? 1 : 0) : connections.Count; + return new ToolResult(new ListMcpResourcesToolOutput(mapped, serverCount)); + } + catch (Exception ex) + { + return new ToolResult(new ListMcpResourcesToolOutput([], 0), true, ex.Message); + } + } + + public Task ValidateInputAsync(ListMcpResourcesToolInput input) => Task.FromResult(ValidationResult.Success()); + public Task CheckPermissionAsync(ListMcpResourcesToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => true; + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"ServerName\":{\"type\":\"string\"}}}").RootElement.Clone(); +} + +public sealed class ListMcpResourcesToolInput +{ + public ListMcpResourcesToolInput(string? serverName = null) + { + ServerName = serverName; + } + + public string? ServerName { get; } +} + +public sealed class ListMcpResourcesToolOutput +{ + public ListMcpResourcesToolOutput(IReadOnlyList resources, int serverCount) + { + Resources = resources; + ServerCount = serverCount; + } + + public IReadOnlyList Resources { get; } + public int ServerCount { get; } +} + +public sealed record McpResourceInfo(string Uri, string Name, string? Description, string? MimeType); diff --git a/src/FreeCode.Tools/LspTool.cs b/src/FreeCode.Tools/LspTool.cs new file mode 100644 index 0000000..cef5325 --- /dev/null +++ b/src/FreeCode.Tools/LspTool.cs @@ -0,0 +1,149 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class LspTool : ToolBase, ITool +{ + public override string Name => "Lsp"; + public override ToolCategory Category => ToolCategory.Lsp; + + public async Task> ExecuteAsync(LspToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + if (context.LspManager is not ILspClientManager manager) + { + return new ToolResult(new LspToolOutput([], input.Operation), true, "LSP manager is unavailable."); + } + + var filePath = ToolUtilities.ResolvePath(input.FilePath, context.WorkingDirectory); + var results = new List(); + var operation = input.Operation.ToLowerInvariant(); + + if (operation is "definitions" or "references") + { + var response = await manager.SendRequestAsync(filePath, operation == "definitions" ? "textDocument/definition" : "textDocument/references", new + { + textDocument = new { uri = new Uri(filePath).AbsoluteUri }, + position = new { line = input.Line, character = input.Character } + }).ConfigureAwait(false); + if (response.HasValue) + { + ExtractLocations(response.Value, results, filePath); + } + } + else if (operation == "hover") + { + var response = await manager.SendRequestAsync(filePath, "textDocument/hover", new + { + textDocument = new { uri = new Uri(filePath).AbsoluteUri }, + position = new { line = input.Line, character = input.Character } + }).ConfigureAwait(false); + if (response.HasValue) + { + results.Add(new LspResult(filePath, input.Line, input.Character, response.Value.ToString(), "hover")); + } + } + else if (operation == "symbols") + { + var response = await manager.SendRequestAsync(filePath, "textDocument/documentSymbol", new + { + textDocument = new { uri = new Uri(filePath).AbsoluteUri } + }).ConfigureAwait(false); + if (response.HasValue) + { + ExtractSymbols(response.Value, results, filePath); + } + } + else + { + return new ToolResult(new LspToolOutput([], input.Operation), true, $"Unsupported LSP operation: {input.Operation}"); + } + + return new ToolResult(new LspToolOutput(results, input.Operation)); + } + catch (Exception ex) + { + return new ToolResult(new LspToolOutput([], input.Operation), true, ex.Message); + } + } + + public Task ValidateInputAsync(LspToolInput input) + => Task.FromResult(string.IsNullOrWhiteSpace(input.Operation) ? ValidationResult.Failure(new[] { "Operation is required." }) : ValidationResult.Success()); + + public Task CheckPermissionAsync(LspToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => true; + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Operation\":{\"type\":\"string\"},\"FilePath\":{\"type\":\"string\"},\"Line\":{\"type\":\"integer\"},\"Character\":{\"type\":\"integer\"},\"Query\":{\"type\":\"string\"}},\"required\":[\"Operation\",\"FilePath\"]}").RootElement.Clone(); + + private static void ExtractLocations(JsonElement element, List results, string filePath) + { + if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + AddLocation(item, results, filePath); + } + } + else + { + AddLocation(element, results, filePath); + } + } + + private static void AddLocation(JsonElement element, List results, string fallback) + { + var uri = element.TryGetProperty("uri", out var uriProp) ? uriProp.GetString() : null; + var range = element.TryGetProperty("range", out var rangeProp) ? rangeProp : default; + var start = range.ValueKind == JsonValueKind.Object && range.TryGetProperty("start", out var startProp) ? startProp : default; + var line = start.ValueKind == JsonValueKind.Object && start.TryGetProperty("line", out var lineProp) ? lineProp.GetInt32() : 0; + var character = start.ValueKind == JsonValueKind.Object && start.TryGetProperty("character", out var charProp) ? charProp.GetInt32() : 0; + results.Add(new LspResult(uri is null ? fallback : new Uri(uri).LocalPath, line, character, element.ToString(), element.TryGetProperty("kind", out var kind) ? kind.ToString() : null)); + } + + private static void ExtractSymbols(JsonElement element, List results, string filePath) + { + if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + results.Add(new LspResult(filePath, item.TryGetProperty("range", out var range) && range.TryGetProperty("start", out var start) && start.TryGetProperty("line", out var line) ? line.GetInt32() : 0, 0, item.ToString(), item.TryGetProperty("kind", out var kind) ? kind.ToString() : null)); + } + } + } +} + +public sealed class LspToolInput +{ + public LspToolInput(string operation, string filePath, int line = 0, int character = 0, string? query = null) + { + Operation = operation; + FilePath = filePath; + Line = line; + Character = character; + Query = query; + } + + public string Operation { get; } + public string FilePath { get; } + public int Line { get; } + public int Character { get; } + public string? Query { get; } +} + +public sealed class LspToolOutput +{ + public LspToolOutput(IReadOnlyList results, string operation) + { + Results = results; + Operation = operation; + } + + public IReadOnlyList Results { get; } + public string Operation { get; } +} + +public sealed record LspResult(string FilePath, int Line, int Character, string Text, string? Kind); diff --git a/src/FreeCode.Tools/MCPTool.cs b/src/FreeCode.Tools/MCPTool.cs new file mode 100644 index 0000000..b395612 --- /dev/null +++ b/src/FreeCode.Tools/MCPTool.cs @@ -0,0 +1,163 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using FreeCode.Mcp; + +namespace FreeCode.Tools; + +public sealed class MCPTool : ToolBase, ITool +{ + public override string Name => "MCP"; + + public override string[]? Aliases => ["mcp"]; + + public override ToolCategory Category => ToolCategory.Mcp; + + public override bool IsConcurrencySafe(object input) => false; + + public override bool IsReadOnly(object input) => false; + + public override bool IsEnabled() => true; + + public override JsonElement GetInputSchema() + { + return JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Name\":{\"type\":\"string\"},\"ServerName\":{\"type\":\"string\"},\"ToolName\":{\"type\":\"string\"},\"Arguments\":{\"type\":\"object\"}}}").RootElement.Clone(); + } + + public override Task GetDescriptionAsync(object? input = null) + { + if (input is MCPToolInput mcpInput) + { + return Task.FromResult($"Call MCP tool {ResolveDisplayName(mcpInput)}"); + } + + return Task.FromResult("Call an MCP server tool by name."); + } + + public Task ValidateInputAsync(MCPToolInput input) + { + var hasFullName = !string.IsNullOrWhiteSpace(input.Name); + var hasParts = !string.IsNullOrWhiteSpace(input.ServerName) && !string.IsNullOrWhiteSpace(input.ToolName); + if (!hasFullName && !hasParts) + { + return Task.FromResult(ValidationResult.Failure(new[] { "Provide Name or both ServerName and ToolName." })); + } + + return Task.FromResult(ValidationResult.Success()); + } + + public Task CheckPermissionAsync(MCPToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public async Task> ExecuteAsync(MCPToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var manager = context.Services.GetService(typeof(IMcpClientManager)) as IMcpClientManager; + if (manager is null) + { + return new ToolResult(new MCPToolOutput { Success = false }, true, "MCP client manager is unavailable."); + } + + var resolved = ResolveTool(input, manager.GetConnections()); + if (!resolved.Success) + { + return new ToolResult(new MCPToolOutput + { + Success = false, + ServerName = resolved.ServerName ?? string.Empty, + ToolName = resolved.ToolName ?? string.Empty, + ResolvedName = resolved.ResolvedName ?? string.Empty + }, true, resolved.ErrorMessage); + } + + object? parameters = input.Arguments.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined + ? null + : input.Arguments.Clone(); + + var result = await resolved.Client!.CallToolAsync(resolved.ToolName!, parameters, ct).ConfigureAwait(false); + return new ToolResult(new MCPToolOutput + { + Success = true, + ServerName = resolved.ServerName!, + ToolName = resolved.ToolName!, + ResolvedName = resolved.ResolvedName!, + Content = FormatContent(result.Content), + HasBinaryContent = result.HasBinaryContent + }); + } + catch (Exception ex) + { + return new ToolResult(new MCPToolOutput + { + Success = false, + ResolvedName = ResolveDisplayName(input) + }, true, ex.Message); + } + } + + private static string ResolveDisplayName(MCPToolInput input) + => !string.IsNullOrWhiteSpace(input.Name) ? input.Name : $"{input.ServerName}::{input.ToolName}"; + + private static string FormatContent(JsonElement content) + { + return content.ValueKind == JsonValueKind.String + ? content.GetString() ?? string.Empty + : content.GetRawText(); + } + + private static ResolvedMcpTool ResolveTool(MCPToolInput input, IReadOnlyList connections) + { + if (!string.IsNullOrWhiteSpace(input.Name)) + { + var parsed = ParseQualifiedName(input.Name); + if (!parsed.Success) + { + return parsed; + } + + var connection = connections.OfType() + .FirstOrDefault(candidate => string.Equals(Sanitize(candidate.Name), parsed.ServerName, StringComparison.OrdinalIgnoreCase)); + if (connection?.Client is not McpClient client) + { + return new ResolvedMcpTool(false, parsed.ServerName, parsed.ToolName, input.Name, null, $"Connected MCP server not found for {input.Name}."); + } + + return new ResolvedMcpTool(true, connection.Name, parsed.ToolName, input.Name, client, null); + } + + var namedConnection = connections.OfType() + .FirstOrDefault(candidate => string.Equals(candidate.Name, input.ServerName, StringComparison.OrdinalIgnoreCase)); + if (namedConnection?.Client is not McpClient namedClient) + { + return new ResolvedMcpTool(false, input.ServerName, input.ToolName, ResolveDisplayName(input), null, $"Connected MCP server not found: {input.ServerName}."); + } + + return new ResolvedMcpTool(true, namedConnection.Name, input.ToolName, $"mcp__{Sanitize(namedConnection.Name)}__{input.ToolName}", namedClient, null); + } + + private static ResolvedMcpTool ParseQualifiedName(string name) + { + if (!name.StartsWith("mcp__", StringComparison.OrdinalIgnoreCase)) + { + return new ResolvedMcpTool(false, null, null, name, null, $"Invalid MCP tool name: {name}"); + } + + var remainder = name["mcp__".Length..]; + var separatorIndex = remainder.IndexOf("__", StringComparison.Ordinal); + if (separatorIndex <= 0 || separatorIndex >= remainder.Length - 2) + { + return new ResolvedMcpTool(false, null, null, name, null, $"Invalid MCP tool name: {name}"); + } + + var serverToken = remainder[..separatorIndex]; + var toolName = remainder[(separatorIndex + 2)..]; + return new ResolvedMcpTool(true, serverToken, toolName, name, null, null); + } + + private static string Sanitize(string value) + => new(value.ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : '_').ToArray()); + + private sealed record ResolvedMcpTool(bool Success, string? ServerName, string? ToolName, string? ResolvedName, McpClient? Client, string? ErrorMessage); +} diff --git a/src/FreeCode.Tools/MCPToolInput.cs b/src/FreeCode.Tools/MCPToolInput.cs new file mode 100644 index 0000000..844e4db --- /dev/null +++ b/src/FreeCode.Tools/MCPToolInput.cs @@ -0,0 +1,18 @@ +using System.Text.Json; + +namespace FreeCode.Tools; + +public sealed class MCPToolInput +{ + public MCPToolInput() + { + } + + public string Name { get; set; } = string.Empty; + + public string? ServerName { get; set; } + + public string? ToolName { get; set; } + + public JsonElement Arguments { get; set; } +} diff --git a/src/FreeCode.Tools/MCPToolOutput.cs b/src/FreeCode.Tools/MCPToolOutput.cs new file mode 100644 index 0000000..6a52a49 --- /dev/null +++ b/src/FreeCode.Tools/MCPToolOutput.cs @@ -0,0 +1,20 @@ +namespace FreeCode.Tools; + +public sealed class MCPToolOutput +{ + public MCPToolOutput() + { + } + + public bool Success { get; set; } + + public string ServerName { get; set; } = string.Empty; + + public string ToolName { get; set; } = string.Empty; + + public string ResolvedName { get; set; } = string.Empty; + + public string Content { get; set; } = string.Empty; + + public bool HasBinaryContent { get; set; } +} diff --git a/src/FreeCode.Tools/McpAuthTool.cs b/src/FreeCode.Tools/McpAuthTool.cs new file mode 100644 index 0000000..47ee42d --- /dev/null +++ b/src/FreeCode.Tools/McpAuthTool.cs @@ -0,0 +1,154 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class McpAuthTool : ToolBase, ITool +{ + public override string Name => "McpAuth"; + + public override string[]? Aliases => ["MCPAuth"]; + + public override ToolCategory Category => ToolCategory.Mcp; + + public override bool IsConcurrencySafe(object input) => false; + + public override bool IsReadOnly(object input) => false; + + public override bool IsEnabled() => true; + + public override JsonElement GetInputSchema() + { + return JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"ServerName\":{\"type\":\"string\"},\"Provider\":{\"type\":\"string\"}}}").RootElement.Clone(); + } + + public override Task GetDescriptionAsync(object? input = null) + { + if (input is McpAuthToolInput authInput && !string.IsNullOrWhiteSpace(authInput.ServerName)) + { + return Task.FromResult($"Authenticate MCP server {authInput.ServerName}"); + } + + return Task.FromResult("Authenticate an MCP server or start a login flow."); + } + + public Task ValidateInputAsync(McpAuthToolInput input) + { + if (string.IsNullOrWhiteSpace(input.ServerName) && string.IsNullOrWhiteSpace(input.Provider)) + { + return Task.FromResult(ValidationResult.Failure(new[] { "ServerName or Provider is required." })); + } + + return Task.FromResult(ValidationResult.Success()); + } + + public Task CheckPermissionAsync(McpAuthToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public async Task> ExecuteAsync(McpAuthToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + if (!string.IsNullOrWhiteSpace(input.ServerName)) + { + return await AuthenticateMcpServerAsync(input.ServerName, context).ConfigureAwait(false); + } + + var authService = context.Services.GetService(typeof(IAuthService)) as IAuthService; + if (authService is null) + { + return new ToolResult(new McpAuthToolOutput + { + Status = "error", + Message = "Auth service is unavailable.", + Authenticated = false + }, true, "Auth service is unavailable."); + } + + var provider = string.IsNullOrWhiteSpace(input.Provider) ? "anthropic" : input.Provider.Trim(); + await authService.LoginAsync(provider).ConfigureAwait(false); + return new ToolResult(new McpAuthToolOutput + { + Status = authService.IsAuthenticated ? "authenticated" : "started", + Message = authService.IsAuthenticated ? $"Authentication completed for provider '{provider}'." : $"Authentication started for provider '{provider}'.", + Authenticated = authService.IsAuthenticated + }); + } + catch (Exception ex) + { + return new ToolResult(new McpAuthToolOutput + { + Status = "error", + Message = ex.Message, + ServerName = input.ServerName, + Authenticated = false + }, true, ex.Message); + } + } + + private static async Task> AuthenticateMcpServerAsync(string serverName, ToolExecutionContext context) + { + var manager = context.Services.GetService(typeof(IMcpClientManager)) as IMcpClientManager; + if (manager is null) + { + return new ToolResult(new McpAuthToolOutput + { + Status = "error", + ServerName = serverName, + Message = "MCP client manager is unavailable.", + Authenticated = false + }, true, "MCP client manager is unavailable."); + } + + var connection = manager.GetConnections().FirstOrDefault(item => string.Equals(item.Name, serverName, StringComparison.OrdinalIgnoreCase)); + if (connection is null) + { + return new ToolResult(new McpAuthToolOutput + { + Status = "error", + ServerName = serverName, + Message = $"MCP server not found: {serverName}", + Authenticated = false + }, true, $"MCP server not found: {serverName}"); + } + + if (connection.Config is ClaudeAiProxyServerConfig) + { + return new ToolResult(new McpAuthToolOutput + { + Status = "unsupported", + ServerName = serverName, + Message = $"Server '{serverName}' uses a claude.ai proxy connector and must be authenticated from the MCP command flow.", + Authenticated = false + }); + } + + if (connection.Config is not SseServerConfig and not HttpServerConfig) + { + return new ToolResult(new McpAuthToolOutput + { + Status = "unsupported", + ServerName = serverName, + Message = $"Server '{serverName}' does not expose an MCP OAuth flow through this tool.", + Authenticated = connection.IsConnected + }); + } + + await manager.AuthenticateServerAsync(serverName).ConfigureAwait(false); + await manager.ReconnectServerAsync(serverName).ConfigureAwait(false); + var refreshed = manager.GetConnections().FirstOrDefault(item => string.Equals(item.Name, serverName, StringComparison.OrdinalIgnoreCase)); + var authenticated = refreshed?.IsConnected == true; + + return new ToolResult(new McpAuthToolOutput + { + Status = authenticated ? "authenticated" : "started", + ServerName = serverName, + Message = authenticated + ? $"Authentication completed for MCP server '{serverName}'." + : $"Authentication flow started for MCP server '{serverName}'.", + Authenticated = authenticated + }); + } +} diff --git a/src/FreeCode.Tools/McpAuthToolInput.cs b/src/FreeCode.Tools/McpAuthToolInput.cs new file mode 100644 index 0000000..0f1ae72 --- /dev/null +++ b/src/FreeCode.Tools/McpAuthToolInput.cs @@ -0,0 +1,12 @@ +namespace FreeCode.Tools; + +public sealed class McpAuthToolInput +{ + public McpAuthToolInput() + { + } + + public string? ServerName { get; set; } + + public string? Provider { get; set; } +} diff --git a/src/FreeCode.Tools/McpAuthToolOutput.cs b/src/FreeCode.Tools/McpAuthToolOutput.cs new file mode 100644 index 0000000..225ebfd --- /dev/null +++ b/src/FreeCode.Tools/McpAuthToolOutput.cs @@ -0,0 +1,16 @@ +namespace FreeCode.Tools; + +public sealed class McpAuthToolOutput +{ + public McpAuthToolOutput() + { + } + + public string Status { get; set; } = string.Empty; + + public string Message { get; set; } = string.Empty; + + public string? ServerName { get; set; } + + public bool Authenticated { get; set; } +} diff --git a/src/FreeCode.Tools/MonitorTool.cs b/src/FreeCode.Tools/MonitorTool.cs new file mode 100644 index 0000000..853a8c8 --- /dev/null +++ b/src/FreeCode.Tools/MonitorTool.cs @@ -0,0 +1,145 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class MonitorTool : ToolBase, ITool +{ + private static readonly ConcurrentDictionary ActiveMonitors = new(StringComparer.OrdinalIgnoreCase); + + public override string Name => "Monitor"; + public override ToolCategory Category => ToolCategory.Task; + + public Task> ExecuteAsync(MonitorToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var action = input.Action.ToLowerInvariant(); + + if (action == "list") + { + var monitors = ActiveMonitors.Values.Select(m => new ActiveMonitor(m.Id, m.Path, m.Pattern, m.Watcher is not null ? "active" : "stopped")).ToList(); + return Task.FromResult(new ToolResult(new MonitorToolOutput(null, ActiveMonitors.Count > 0, monitors))); + } + + if (action == "start") + { + if (string.IsNullOrWhiteSpace(input.Path)) + { + return Task.FromResult(new ToolResult(new MonitorToolOutput(null, false, null), true, "Path is required for start action.")); + } + + var monitorPath = ToolUtilities.ResolvePath(input.Path, context.WorkingDirectory); + if (!Directory.Exists(monitorPath) && !File.Exists(monitorPath)) + { + return Task.FromResult(new ToolResult(new MonitorToolOutput(null, false, null), true, $"Path not found: {monitorPath}")); + } + + var monitorId = Guid.NewGuid().ToString("N")[..8]; + var directory = Directory.Exists(monitorPath) ? monitorPath : System.IO.Path.GetDirectoryName(monitorPath) ?? monitorPath; + var filter = input.Pattern ?? "*"; + + var watcher = new FileSystemWatcher(directory, filter) + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.Size, + EnableRaisingEvents = true, + IncludeSubdirectories = true + }; + + var state = new ActiveMonitorState(monitorId, monitorPath, input.Pattern, watcher); + if (!ActiveMonitors.TryAdd(monitorId, state)) + { + watcher.Dispose(); + return Task.FromResult(new ToolResult(new MonitorToolOutput(null, false, null), true, "Failed to create monitor.")); + } + + return Task.FromResult(new ToolResult(new MonitorToolOutput(monitorId, true, null))); + } + + if (action == "stop") + { + if (string.IsNullOrWhiteSpace(input.MonitorId)) + { + foreach (var kvp in ActiveMonitors) + { + if (ActiveMonitors.TryRemove(kvp.Key, out var removed)) + { + removed.Watcher?.Dispose(); + } + } + + return Task.FromResult(new ToolResult(new MonitorToolOutput(null, false, null))); + } + + if (ActiveMonitors.TryRemove(input.MonitorId, out var monitor)) + { + monitor.Watcher?.Dispose(); + return Task.FromResult(new ToolResult(new MonitorToolOutput(input.MonitorId, false, null))); + } + + return Task.FromResult(new ToolResult(new MonitorToolOutput(input.MonitorId, false, null), true, $"Monitor not found: {input.MonitorId}")); + } + + return Task.FromResult(new ToolResult(new MonitorToolOutput(null, false, null), true, $"Unknown action: {input.Action}. Use 'start', 'stop', or 'list'.")); + } + catch (Exception ex) + { + return Task.FromResult(new ToolResult(new MonitorToolOutput(null, false, null), true, ex.Message)); + } + } + + public Task ValidateInputAsync(MonitorToolInput input) + => Task.FromResult(string.IsNullOrWhiteSpace(input.Action) ? ValidationResult.Failure(new[] { "Action is required." }) : ValidationResult.Success()); + + public Task CheckPermissionAsync(MonitorToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => true; + + public override bool IsReadOnly(object input) + { + if (input is MonitorToolInput mi) + { + return string.Equals(mi.Action, "list", StringComparison.OrdinalIgnoreCase); + } + + return false; + } + + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Path\":{\"type\":\"string\"},\"Pattern\":{\"type\":\"string\"},\"Action\":{\"type\":\"string\",\"enum\":[\"start\",\"stop\",\"list\"]},\"MonitorId\":{\"type\":\"string\"}},\"required\":[\"Action\"]}").RootElement.Clone(); +} + +internal sealed record ActiveMonitorState(string Id, string Path, string? Pattern, FileSystemWatcher? Watcher); + +public sealed record ActiveMonitor(string Id, string Path, string? Pattern, string Status); + +public sealed class MonitorToolInput +{ + public MonitorToolInput(string action, string? path = null, string? pattern = null, string? monitorId = null) + { + Action = action; + Path = path; + Pattern = pattern; + MonitorId = monitorId; + } + + public string Action { get; } + public string? Path { get; } + public string? Pattern { get; } + public string? MonitorId { get; } +} + +public sealed class MonitorToolOutput +{ + public MonitorToolOutput(string? monitorId, bool active, IReadOnlyList? monitors) + { + MonitorId = monitorId; + Active = active; + Monitors = monitors; + } + + public string? MonitorId { get; } + public bool Active { get; } + public IReadOnlyList? Monitors { get; } +} diff --git a/src/FreeCode.Tools/NotebookEditTool.cs b/src/FreeCode.Tools/NotebookEditTool.cs new file mode 100644 index 0000000..03cb275 --- /dev/null +++ b/src/FreeCode.Tools/NotebookEditTool.cs @@ -0,0 +1,110 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class NotebookEditTool : ToolBase, ITool +{ + public override string Name => "NotebookEdit"; + public override ToolCategory Category => ToolCategory.FileSystem; + + public async Task> ExecuteAsync(NotebookEditToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var path = ToolUtilities.ResolvePath(input.NotebookPath, context.WorkingDirectory); + var json = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false); + var notebook = JsonNode.Parse(json) as JsonObject ?? throw new InvalidOperationException("Invalid notebook JSON."); + var cells = notebook["cells"] as JsonArray ?? throw new InvalidOperationException("Notebook has no cells."); + + var indices = input.CellIndex is int index ? new[] { index } : Enumerable.Range(0, cells.Count).ToArray(); + var changed = false; + + foreach (var cellIndex in indices) + { + if (cellIndex < 0 || cellIndex >= cells.Count || cells[cellIndex] is not JsonObject cell) + { + continue; + } + + var source = cell["source"]; + var content = source switch + { + JsonArray arr => string.Concat(arr.Select(node => node?.ToString() ?? string.Empty)), + JsonValue value => value.ToString(), + _ => string.Empty + }; + + if (!content.Contains(input.OldContent, StringComparison.Ordinal)) + { + continue; + } + + content = content.Replace(input.OldContent, input.NewContent, StringComparison.Ordinal); + var sourceArray = new JsonArray(); + foreach (var line in content.Split('\n')) + { + sourceArray.Add(JsonValue.Create(line + "\n")); + } + + cell["source"] = sourceArray; + changed = true; + if (input.CellIndex is not null) + { + break; + } + } + + if (!changed) + { + return new ToolResult(new NotebookEditToolOutput(false, "No matching cell content found."), true, "No matching cell content found."); + } + + await File.WriteAllTextAsync(path, notebook.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), ct).ConfigureAwait(false); + return new ToolResult(new NotebookEditToolOutput(true, $"Updated notebook {path}.")); + } + catch (Exception ex) + { + return new ToolResult(new NotebookEditToolOutput(false, ex.Message), true, ex.Message); + } + } + + public Task ValidateInputAsync(NotebookEditToolInput input) + => Task.FromResult(string.IsNullOrWhiteSpace(input.NotebookPath) ? ValidationResult.Failure(new[] { "NotebookPath is required." }) : ValidationResult.Success()); + + public Task CheckPermissionAsync(NotebookEditToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => false; + public override bool IsReadOnly(object input) => false; + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"NotebookPath\":{\"type\":\"string\"},\"OldContent\":{\"type\":\"string\"},\"NewContent\":{\"type\":\"string\"},\"CellIndex\":{\"type\":\"integer\"}},\"required\":[\"NotebookPath\",\"OldContent\",\"NewContent\"]}").RootElement.Clone(); +} + +public sealed class NotebookEditToolInput +{ + public NotebookEditToolInput(string notebookPath, string oldContent, string newContent, int? cellIndex = null) + { + NotebookPath = notebookPath; + OldContent = oldContent; + NewContent = newContent; + CellIndex = cellIndex; + } + + public string NotebookPath { get; } + public string OldContent { get; } + public string NewContent { get; } + public int? CellIndex { get; } +} + +public sealed class NotebookEditToolOutput +{ + public NotebookEditToolOutput(bool success, string message) + { + Success = success; + Message = message; + } + + public bool Success { get; } + public string Message { get; } +} diff --git a/src/FreeCode.Tools/PowerShellTool.cs b/src/FreeCode.Tools/PowerShellTool.cs new file mode 100644 index 0000000..309fcda --- /dev/null +++ b/src/FreeCode.Tools/PowerShellTool.cs @@ -0,0 +1,209 @@ +using System.Diagnostics; +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class PowerShellTool : ToolBase, ITool +{ + private static readonly string[] ReadOnlyPrefixes = + [ + "Get-ChildItem", + "Get-Content", + "Get-Item", + "Get-Location", + "Get-Process", + "Get-Service", + "Get-FileHash", + "Test-Path", + "Resolve-Path", + "Select-String", + "Format-Hex", + "ls", + "dir", + "cat", + "pwd", + "git status", + "git diff", + "git log" + ]; + + public override string Name => "PowerShell"; + public override string[]? Aliases => ["pwsh", "powershell"]; + public override ToolCategory Category => ToolCategory.Shell; + public override bool IsEnabled() => true; + + public Task> ExecuteAsync(PowerShellToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + if (input.RunInBackground) + { + var started = StartProcess(input, context, redirectOutput: false, redirectError: false); + if (started.process is null) + { + return Task.FromResult(new ToolResult(new PowerShellToolOutput + { + ExitCode = -1, + Stderr = "Failed to start PowerShell process." + }, true, "Failed to start PowerShell process.")); + } + + var output = new PowerShellToolOutput + { + BackgroundTaskId = started.process.Id.ToString(), + ExitCode = 0, + Interrupted = false + }; + return Task.FromResult(new ToolResult(output)); + } + + return ExecuteForegroundAsync(input, context, ct); + } + + public Task ValidateInputAsync(PowerShellToolInput input) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(input.Command)) + { + errors.Add("Command is required."); + } + + if (input.Timeout <= 0) + { + errors.Add("Timeout must be greater than 0."); + } + + return Task.FromResult(errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors)); + } + + public Task CheckPermissionAsync(PowerShellToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public override bool IsConcurrencySafe(object input) => false; + + public override bool IsReadOnly(object input) + { + var command = input is PowerShellToolInput toolInput ? toolInput.Command : input as string; + if (string.IsNullOrWhiteSpace(command)) + { + return false; + } + + var trimmed = command.TrimStart(); + return ReadOnlyPrefixes.Any(prefix => trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + } + + public override JsonElement GetInputSchema() + { + return JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Command\":{\"type\":\"string\"},\"Timeout\":{\"type\":\"integer\"},\"Description\":{\"type\":\"string\"},\"RunInBackground\":{\"type\":\"boolean\"},\"DangerouslyDisableSandbox\":{\"type\":\"boolean\"}},\"required\":[\"Command\"]}").RootElement.Clone(); + } + + public override async Task GetDescriptionAsync(object? input = null) + { + if (input is PowerShellToolInput powershellInput && !string.IsNullOrWhiteSpace(powershellInput.Description)) + { + return powershellInput.Description; + } + + return await base.GetDescriptionAsync(input).ConfigureAwait(false); + } + + private async Task> ExecuteForegroundAsync(PowerShellToolInput input, ToolExecutionContext context, CancellationToken ct) + { + var started = StartProcess(input, context, redirectOutput: true, redirectError: true); + if (started.process is null) + { + return new ToolResult(new PowerShellToolOutput + { + ExitCode = -1, + Stderr = "Failed to start PowerShell process." + }, true, "Failed to start PowerShell process."); + } + + using var process = started.process; + var stdoutTask = process.StandardOutput.ReadToEndAsync(ct); + var stderrTask = process.StandardError.ReadToEndAsync(ct); + var waitTask = process.WaitForExitAsync(ct); + var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromMilliseconds(Math.Max(1, input.Timeout)), ct)).ConfigureAwait(false); + var interrupted = completed != waitTask; + if (interrupted && !process.HasExited) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + if (!process.HasExited) + { + throw; + } + } + } + + if (process.HasExited) + { + await waitTask.ConfigureAwait(false); + } + + var output = new PowerShellToolOutput + { + Stdout = started.redirectOutput ? await stdoutTask.ConfigureAwait(false) : string.Empty, + Stderr = started.redirectError ? await stderrTask.ConfigureAwait(false) : string.Empty, + ExitCode = process.HasExited ? process.ExitCode : -1, + Interrupted = interrupted, + BackgroundTaskId = process.Id.ToString() + }; + + return new ToolResult(output, output.ExitCode != 0 && !interrupted, output.ExitCode != 0 && !interrupted ? output.Stderr : null); + } + + private static (Process? process, bool redirectOutput, bool redirectError) StartProcess(PowerShellToolInput input, ToolExecutionContext context, bool redirectOutput, bool redirectError) + { + foreach (var shell in GetCandidateShells()) + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = shell, + WorkingDirectory = context.WorkingDirectory, + UseShellExecute = false, + RedirectStandardOutput = redirectOutput, + RedirectStandardError = redirectError, + RedirectStandardInput = false, + CreateNoWindow = true + }; + startInfo.ArgumentList.Add("-NoLogo"); + startInfo.ArgumentList.Add("-NoProfile"); + startInfo.ArgumentList.Add("-NonInteractive"); + startInfo.ArgumentList.Add("-Command"); + startInfo.ArgumentList.Add(input.Command); + + var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; + if (!process.Start()) + { + continue; + } + + return (process, redirectOutput, redirectError); + } + catch (Exception) + { + continue; + } + } + + return (null, redirectOutput, redirectError); + } + + private static IEnumerable GetCandidateShells() + { + yield return "pwsh"; + if (OperatingSystem.IsWindows()) + { + yield return "powershell.exe"; + } + } +} diff --git a/src/FreeCode.Tools/PowerShellToolInput.cs b/src/FreeCode.Tools/PowerShellToolInput.cs new file mode 100644 index 0000000..5194356 --- /dev/null +++ b/src/FreeCode.Tools/PowerShellToolInput.cs @@ -0,0 +1,10 @@ +namespace FreeCode.Tools; + +public class PowerShellToolInput +{ + public string Command { get; set; } = string.Empty; + public int Timeout { get; set; } = 60000; + public string? Description { get; set; } + public bool RunInBackground { get; set; } + public bool DangerouslyDisableSandbox { get; set; } +} diff --git a/src/FreeCode.Tools/PowerShellToolOutput.cs b/src/FreeCode.Tools/PowerShellToolOutput.cs new file mode 100644 index 0000000..a7363f0 --- /dev/null +++ b/src/FreeCode.Tools/PowerShellToolOutput.cs @@ -0,0 +1,10 @@ +namespace FreeCode.Tools; + +public class PowerShellToolOutput +{ + public string Stdout { get; set; } = string.Empty; + public string Stderr { get; set; } = string.Empty; + public int ExitCode { get; set; } + public bool Interrupted { get; set; } + public string BackgroundTaskId { get; set; } = string.Empty; +} diff --git a/src/FreeCode.Tools/ReadMcpResourceTool.cs b/src/FreeCode.Tools/ReadMcpResourceTool.cs new file mode 100644 index 0000000..f566abb --- /dev/null +++ b/src/FreeCode.Tools/ReadMcpResourceTool.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class ReadMcpResourceTool : ToolBase, ITool +{ + public override string Name => "ReadMcpResource"; + public override ToolCategory Category => ToolCategory.Mcp; + + public async Task> ExecuteAsync(ReadMcpResourceToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + if (context.Services.GetService(typeof(IMcpClientManager)) is not IMcpClientManager manager) + { + return new ToolResult(new ReadMcpResourceToolOutput(string.Empty, null), true, "MCP client manager is unavailable."); + } + + var serverName = input.ServerName; + if (string.IsNullOrWhiteSpace(serverName)) + { + var connections = manager.GetConnections(); + var connected = connections.Where(c => c.IsConnected).ToList(); + if (connected.Count == 0) + { + return new ToolResult(new ReadMcpResourceToolOutput(string.Empty, null), true, "No connected MCP servers found."); + } + + serverName = connected[0].Name; + } + + var content = await manager.ReadResourceAsync(serverName, input.Uri, ct).ConfigureAwait(false); + return new ToolResult(new ReadMcpResourceToolOutput(content.Text, content.MimeType)); + } + catch (Exception ex) + { + return new ToolResult(new ReadMcpResourceToolOutput(string.Empty, null), true, ex.Message); + } + } + + public Task ValidateInputAsync(ReadMcpResourceToolInput input) + => Task.FromResult(string.IsNullOrWhiteSpace(input.Uri) ? ValidationResult.Failure(new[] { "Uri is required." }) : ValidationResult.Success()); + + public Task CheckPermissionAsync(ReadMcpResourceToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => true; + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Uri\":{\"type\":\"string\"},\"ServerName\":{\"type\":\"string\"}},\"required\":[\"Uri\"]}").RootElement.Clone(); +} + +public sealed class ReadMcpResourceToolInput +{ + public ReadMcpResourceToolInput(string uri, string? serverName = null) + { + Uri = uri; + ServerName = serverName; + } + + public string Uri { get; } + public string? ServerName { get; } +} + +public sealed class ReadMcpResourceToolOutput +{ + public ReadMcpResourceToolOutput(string content, string? mimeType) + { + Content = content; + MimeType = mimeType; + } + + public string Content { get; } + public string? MimeType { get; } +} diff --git a/src/FreeCode.Tools/RemoteTriggerTool.cs b/src/FreeCode.Tools/RemoteTriggerTool.cs new file mode 100644 index 0000000..a4fde35 --- /dev/null +++ b/src/FreeCode.Tools/RemoteTriggerTool.cs @@ -0,0 +1,356 @@ +using System.Text.Json; +using System.Buffers; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class RemoteTriggerTool : ToolBase, ITool +{ + private static readonly HashSet SupportedActions = new(StringComparer.OrdinalIgnoreCase) { "list", "get", "create", "update", "run" }; + + public override string Name => "RemoteTrigger"; + + public override string[]? Aliases => ["Triggers"]; + + public override ToolCategory Category => ToolCategory.Agent; + + public override bool IsConcurrencySafe(object input) => true; + + public override bool IsReadOnly(object input) + { + if (input is not RemoteTriggerToolInput triggerInput) + { + return false; + } + + return string.Equals(triggerInput.Action, "list", StringComparison.OrdinalIgnoreCase) + || string.Equals(triggerInput.Action, "get", StringComparison.OrdinalIgnoreCase); + } + + public override bool IsEnabled() => true; + + public override JsonElement GetInputSchema() + { + return JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Action\":{\"type\":\"string\",\"enum\":[\"list\",\"get\",\"create\",\"update\",\"run\"]},\"TriggerId\":{\"type\":\"string\"},\"Body\":{\"type\":\"object\"}},\"required\":[\"Action\"]}").RootElement.Clone(); + } + + public override Task GetDescriptionAsync(object? input = null) + { + if (input is RemoteTriggerToolInput triggerInput) + { + return Task.FromResult($"Remote trigger action {triggerInput.Action}"); + } + + return Task.FromResult("Manage remote agent triggers."); + } + + public Task ValidateInputAsync(RemoteTriggerToolInput input) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(input.Action) || !SupportedActions.Contains(input.Action)) + { + errors.Add("Action must be one of: list, get, create, update, run."); + } + + if ((string.Equals(input.Action, "get", StringComparison.OrdinalIgnoreCase) + || string.Equals(input.Action, "update", StringComparison.OrdinalIgnoreCase) + || string.Equals(input.Action, "run", StringComparison.OrdinalIgnoreCase)) + && string.IsNullOrWhiteSpace(input.TriggerId)) + { + errors.Add("TriggerId is required for get, update, and run."); + } + + if ((string.Equals(input.Action, "create", StringComparison.OrdinalIgnoreCase) + || string.Equals(input.Action, "update", StringComparison.OrdinalIgnoreCase)) + && input.Body.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + { + errors.Add("Body is required for create and update."); + } + + return Task.FromResult(errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors)); + } + + public Task CheckPermissionAsync(RemoteTriggerToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public async Task> ExecuteAsync(RemoteTriggerToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var path = GetStorePath(); + var store = await LoadStoreAsync(path, ct).ConfigureAwait(false); + + return input.Action.ToLowerInvariant() switch + { + "list" => CreateListResult(200, store.Triggers), + "get" => ExecuteGet(input, store), + "create" => await ExecuteCreateAsync(input, store, path, ct).ConfigureAwait(false), + "update" => await ExecuteUpdateAsync(input, store, path, ct).ConfigureAwait(false), + "run" => await ExecuteRunAsync(input, store, path, context, ct).ConfigureAwait(false), + _ => new ToolResult(new RemoteTriggerToolOutput { Status = 400, Json = "{}" }, true, "Unsupported trigger action.") + }; + } + catch (Exception ex) + { + var payload = CreateErrorJson(ex.Message); + return new ToolResult(new RemoteTriggerToolOutput { Status = 500, Json = payload }, true, ex.Message); + } + } + + private static ToolResult ExecuteGet(RemoteTriggerToolInput input, RemoteTriggerStore store) + { + var trigger = store.Triggers.FirstOrDefault(item => string.Equals(item.Id, input.TriggerId, StringComparison.OrdinalIgnoreCase)); + if (trigger is null) + { + return CreateErrorResult(404, $"Trigger not found: {input.TriggerId}"); + } + + return CreateRecordResult(200, trigger); + } + + private static async Task> ExecuteCreateAsync(RemoteTriggerToolInput input, RemoteTriggerStore store, string path, CancellationToken ct) + { + var record = new RemoteTriggerRecord + { + Id = Guid.NewGuid().ToString("N"), + BodyJson = input.Body.GetRawText(), + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + store.Triggers.Add(record); + await SaveStoreAsync(path, store, ct).ConfigureAwait(false); + return CreateRecordResult(201, record); + } + + private static async Task> ExecuteUpdateAsync(RemoteTriggerToolInput input, RemoteTriggerStore store, string path, CancellationToken ct) + { + var trigger = store.Triggers.FirstOrDefault(item => string.Equals(item.Id, input.TriggerId, StringComparison.OrdinalIgnoreCase)); + if (trigger is null) + { + return CreateErrorResult(404, $"Trigger not found: {input.TriggerId}"); + } + + trigger.BodyJson = input.Body.GetRawText(); + trigger.UpdatedAt = DateTimeOffset.UtcNow; + await SaveStoreAsync(path, store, ct).ConfigureAwait(false); + return CreateRecordResult(200, trigger); + } + + private static async Task> ExecuteRunAsync(RemoteTriggerToolInput input, RemoteTriggerStore store, string path, ToolExecutionContext context, CancellationToken ct) + { + var trigger = store.Triggers.FirstOrDefault(item => string.Equals(item.Id, input.TriggerId, StringComparison.OrdinalIgnoreCase)); + if (trigger is null) + { + return CreateErrorResult(404, $"Trigger not found: {input.TriggerId}"); + } + + var manager = context.TaskManager as IBackgroundTaskManager ?? context.Services.GetService(typeof(IBackgroundTaskManager)) as IBackgroundTaskManager; + if (manager is null) + { + return CreateErrorResult(503, "Background task manager is unavailable."); + } + + using var document = JsonDocument.Parse(trigger.BodyJson); + var root = document.RootElement; + + string? taskId; + if (TryGetString(root, "sessionUrl", out var sessionUrl) && !string.IsNullOrWhiteSpace(sessionUrl)) + { + var task = await manager.CreateRemoteAgentTaskAsync(sessionUrl).ConfigureAwait(false); + taskId = task.TaskId; + } + else if (TryGetString(root, "prompt", out var prompt) && !string.IsNullOrWhiteSpace(prompt)) + { + TryGetString(root, "agentType", out var agentType); + TryGetString(root, "model", out var model); + var task = await manager.CreateAgentTaskAsync(prompt, agentType, model).ConfigureAwait(false); + taskId = task.TaskId; + } + else + { + return CreateErrorResult(400, "Trigger body must include either 'sessionUrl' or 'prompt'."); + } + + trigger.LastRunTaskId = taskId; + trigger.LastRunAt = DateTimeOffset.UtcNow; + trigger.UpdatedAt = DateTimeOffset.UtcNow; + await SaveStoreAsync(path, store, ct).ConfigureAwait(false); + return CreateRunResult(200, trigger.Id, trigger.LastRunTaskId, trigger.LastRunAt); + } + + private static async Task LoadStoreAsync(string path, CancellationToken ct) + { + var store = await ToolUtilities.ReadJsonAsync(path, ct).ConfigureAwait(false); + return store ?? new RemoteTriggerStore(); + } + + private static Task SaveStoreAsync(string path, RemoteTriggerStore store, CancellationToken ct) + => ToolUtilities.WriteJsonAsync(path, store, ct); + + private static string GetStorePath() + => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".free-code", "remote-triggers.json"); + + private static ToolResult CreateListResult(int status, IReadOnlyList triggers) + { + return new ToolResult(new RemoteTriggerToolOutput + { + Status = status, + Json = CreateTriggerListJson(triggers) + }); + } + + private static ToolResult CreateRecordResult(int status, RemoteTriggerRecord trigger) + { + return new ToolResult(new RemoteTriggerToolOutput + { + Status = status, + Json = CreateTriggerJson(trigger) + }); + } + + private static ToolResult CreateRunResult(int status, string id, string? lastRunTaskId, DateTimeOffset? lastRunAt) + { + return new ToolResult(new RemoteTriggerToolOutput + { + Status = status, + Json = CreateRunJson(id, lastRunTaskId, lastRunAt) + }); + } + + private static ToolResult CreateErrorResult(int status, string message) + { + var payload = CreateErrorJson(message); + return new ToolResult(new RemoteTriggerToolOutput { Status = status, Json = payload }, true, message); + } + + private static string CreateTriggerListJson(IReadOnlyList triggers) + { + var buffer = new ArrayBufferWriter(); + using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = true }); + writer.WriteStartArray(); + foreach (var trigger in triggers) + { + WriteTrigger(writer, trigger); + } + + writer.WriteEndArray(); + writer.Flush(); + return System.Text.Encoding.UTF8.GetString(buffer.WrittenSpan); + } + + private static string CreateTriggerJson(RemoteTriggerRecord trigger) + { + var buffer = new ArrayBufferWriter(); + using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = true }); + WriteTrigger(writer, trigger); + writer.Flush(); + return System.Text.Encoding.UTF8.GetString(buffer.WrittenSpan); + } + + private static string CreateRunJson(string id, string? lastRunTaskId, DateTimeOffset? lastRunAt) + { + var buffer = new ArrayBufferWriter(); + using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = true }); + writer.WriteStartObject(); + writer.WriteString("id", id); + if (lastRunTaskId is null) + { + writer.WriteNull("lastRunTaskId"); + } + else + { + writer.WriteString("lastRunTaskId", lastRunTaskId); + } + + if (lastRunAt is null) + { + writer.WriteNull("lastRunAt"); + } + else + { + writer.WriteString("lastRunAt", lastRunAt.Value); + } + + writer.WriteEndObject(); + writer.Flush(); + return System.Text.Encoding.UTF8.GetString(buffer.WrittenSpan); + } + + private static string CreateErrorJson(string message) + { + var buffer = new ArrayBufferWriter(); + using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = true }); + writer.WriteStartObject(); + writer.WriteString("error", message); + writer.WriteEndObject(); + writer.Flush(); + return System.Text.Encoding.UTF8.GetString(buffer.WrittenSpan); + } + + private static void WriteTrigger(Utf8JsonWriter writer, RemoteTriggerRecord trigger) + { + writer.WriteStartObject(); + writer.WriteString("id", trigger.Id); + writer.WritePropertyName("body"); + using (var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(trigger.BodyJson) ? "{}" : trigger.BodyJson)) + { + document.RootElement.WriteTo(writer); + } + + writer.WriteString("createdAt", trigger.CreatedAt); + writer.WriteString("updatedAt", trigger.UpdatedAt); + if (trigger.LastRunAt is null) + { + writer.WriteNull("lastRunAt"); + } + else + { + writer.WriteString("lastRunAt", trigger.LastRunAt.Value); + } + + if (trigger.LastRunTaskId is null) + { + writer.WriteNull("lastRunTaskId"); + } + else + { + writer.WriteString("lastRunTaskId", trigger.LastRunTaskId); + } + + writer.WriteEndObject(); + } + + private static bool TryGetString(JsonElement element, string propertyName, out string? value) + { + value = null; + if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.String) + { + return false; + } + + value = property.GetString(); + return true; + } + + internal sealed class RemoteTriggerStore + { + public List Triggers { get; set; } = []; + } + + internal sealed class RemoteTriggerRecord + { + public string Id { get; set; } = string.Empty; + + public string BodyJson { get; set; } = "{}"; + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset UpdatedAt { get; set; } + + public DateTimeOffset? LastRunAt { get; set; } + + public string? LastRunTaskId { get; set; } + } +} diff --git a/src/FreeCode.Tools/RemoteTriggerToolInput.cs b/src/FreeCode.Tools/RemoteTriggerToolInput.cs new file mode 100644 index 0000000..1fa7c6e --- /dev/null +++ b/src/FreeCode.Tools/RemoteTriggerToolInput.cs @@ -0,0 +1,16 @@ +using System.Text.Json; + +namespace FreeCode.Tools; + +public sealed class RemoteTriggerToolInput +{ + public RemoteTriggerToolInput() + { + } + + public string Action { get; set; } = string.Empty; + + public string? TriggerId { get; set; } + + public JsonElement Body { get; set; } +} diff --git a/src/FreeCode.Tools/RemoteTriggerToolOutput.cs b/src/FreeCode.Tools/RemoteTriggerToolOutput.cs new file mode 100644 index 0000000..6a4f7a6 --- /dev/null +++ b/src/FreeCode.Tools/RemoteTriggerToolOutput.cs @@ -0,0 +1,12 @@ +namespace FreeCode.Tools; + +public sealed class RemoteTriggerToolOutput +{ + public RemoteTriggerToolOutput() + { + } + + public int Status { get; set; } + + public string Json { get; set; } = string.Empty; +} diff --git a/src/FreeCode.Tools/SendMessageTool.cs b/src/FreeCode.Tools/SendMessageTool.cs new file mode 100644 index 0000000..7c4334e --- /dev/null +++ b/src/FreeCode.Tools/SendMessageTool.cs @@ -0,0 +1,82 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class SendMessageTool : ToolBase, ITool +{ + private static readonly ConcurrentDictionary> MessageQueues = new(StringComparer.OrdinalIgnoreCase); + + public override string Name => "SendMessage"; + public override ToolCategory Category => ToolCategory.AgentSwarm; + + public Task> ExecuteAsync(SendMessageToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var queue = MessageQueues.GetOrAdd(input.Recipient, _ => new ConcurrentQueue()); + queue.Enqueue(input.Message); + return Task.FromResult(new ToolResult(new SendMessageToolOutput(true, input.Recipient))); + } + catch (Exception ex) + { + return Task.FromResult(new ToolResult(new SendMessageToolOutput(false, input.Recipient), true, ex.Message)); + } + } + + public Task ValidateInputAsync(SendMessageToolInput input) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(input.Recipient)) errors.Add("Recipient is required."); + if (string.IsNullOrWhiteSpace(input.Message)) errors.Add("Message is required."); + return Task.FromResult(errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors)); + } + + public Task CheckPermissionAsync(SendMessageToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => false; + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Recipient\":{\"type\":\"string\"},\"Message\":{\"type\":\"string\"}},\"required\":[\"Recipient\",\"Message\"]}").RootElement.Clone(); + + internal static IReadOnlyList DrainMessages(string recipient) + { + if (!MessageQueues.TryGetValue(recipient, out var queue)) + { + return []; + } + + var messages = new List(); + while (queue.TryDequeue(out var msg)) + { + messages.Add(msg); + } + + return messages; + } +} + +public sealed class SendMessageToolInput +{ + public SendMessageToolInput(string recipient, string message) + { + Recipient = recipient; + Message = message; + } + + public string Recipient { get; } + public string Message { get; } +} + +public sealed class SendMessageToolOutput +{ + public SendMessageToolOutput(bool sent, string recipient) + { + Sent = sent; + Recipient = recipient; + } + + public bool Sent { get; } + public string Recipient { get; } +} diff --git a/src/FreeCode.Tools/SendUserFileTool.cs b/src/FreeCode.Tools/SendUserFileTool.cs new file mode 100644 index 0000000..49273f2 --- /dev/null +++ b/src/FreeCode.Tools/SendUserFileTool.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class SendUserFileTool : ToolBase, ITool +{ + public override string Name => "SendUserFile"; + public override ToolCategory Category => ToolCategory.UserInteraction; + public override string[]? Aliases => ["AttachFile"]; + public override bool IsEnabled() => true; + + public async Task> ExecuteAsync(SendUserFileToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var sourcePath = ToolUtilities.ResolvePath(input.FilePath, context.WorkingDirectory); + var sourceInfo = new FileInfo(sourcePath); + if (!sourceInfo.Exists) + { + return new ToolResult(new SendUserFileToolOutput(), true, $"File not found: {sourcePath}"); + } + + var shareDirectory = ToolUtilities.EnsureDirectory(Path.Combine(context.WorkingDirectory, ".freecode", "user-files")); + var fileName = SanitizeFileName(string.IsNullOrWhiteSpace(input.DisplayName) ? sourceInfo.Name : input.DisplayName!); + var targetPath = Path.Combine(shareDirectory, fileName); + if (!input.Overwrite && File.Exists(targetPath)) + { + targetPath = Path.Combine(shareDirectory, $"{Path.GetFileNameWithoutExtension(fileName)}-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Path.GetExtension(fileName)}"); + } + + await using (var source = File.OpenRead(sourcePath)) + await using (var destination = File.Create(targetPath)) + { + await source.CopyToAsync(destination, ct).ConfigureAwait(false); + } + + if (context.Services.GetService(typeof(INotificationService)) is INotificationService notificationService && notificationService.IsSupportedTerminal()) + { + await notificationService.NotifyAsync("File ready", $"{Path.GetFileName(targetPath)} is ready for download.", ct).ConfigureAwait(false); + } + + return new ToolResult(new SendUserFileToolOutput + { + OriginalPath = sourcePath, + SharedPath = targetPath, + FileName = Path.GetFileName(targetPath), + SizeBytes = new FileInfo(targetPath).Length + }); + } + catch (Exception ex) + { + return new ToolResult(new SendUserFileToolOutput(), true, ex.Message); + } + } + + public Task ValidateInputAsync(SendUserFileToolInput input) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(input.FilePath)) + { + errors.Add("FilePath is required."); + } + + return Task.FromResult(errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors)); + } + + public Task CheckPermissionAsync(SendUserFileToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public override bool IsConcurrencySafe(object input) => false; + public override bool IsReadOnly(object input) => false; + + public override JsonElement GetInputSchema() + { + return JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"FilePath\":{\"type\":\"string\"},\"DisplayName\":{\"type\":\"string\"},\"Overwrite\":{\"type\":\"boolean\"}},\"required\":[\"FilePath\"]}").RootElement.Clone(); + } + + public override Task GetDescriptionAsync(object? input = null) + { + return Task.FromResult("Send a file to the user"); + } + + private static string SanitizeFileName(string fileName) + { + var invalidChars = Path.GetInvalidFileNameChars(); + var sanitized = new string(fileName.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray()); + return string.IsNullOrWhiteSpace(sanitized) ? "download.bin" : sanitized; + } +} diff --git a/src/FreeCode.Tools/SendUserFileToolInput.cs b/src/FreeCode.Tools/SendUserFileToolInput.cs new file mode 100644 index 0000000..7c637a1 --- /dev/null +++ b/src/FreeCode.Tools/SendUserFileToolInput.cs @@ -0,0 +1,8 @@ +namespace FreeCode.Tools; + +public class SendUserFileToolInput +{ + public string FilePath { get; set; } = string.Empty; + public string? DisplayName { get; set; } + public bool Overwrite { get; set; } +} diff --git a/src/FreeCode.Tools/SendUserFileToolOutput.cs b/src/FreeCode.Tools/SendUserFileToolOutput.cs new file mode 100644 index 0000000..ae6bb21 --- /dev/null +++ b/src/FreeCode.Tools/SendUserFileToolOutput.cs @@ -0,0 +1,9 @@ +namespace FreeCode.Tools; + +public class SendUserFileToolOutput +{ + public string OriginalPath { get; set; } = string.Empty; + public string SharedPath { get; set; } = string.Empty; + public string FileName { get; set; } = string.Empty; + public long SizeBytes { get; set; } +} diff --git a/src/FreeCode.Tools/ServiceCollectionExtensions.cs b/src/FreeCode.Tools/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..0fe8e78 --- /dev/null +++ b/src/FreeCode.Tools/ServiceCollectionExtensions.cs @@ -0,0 +1,66 @@ +using System.Text.Json; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode.Tools; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddFreeCodeTools(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(sp => (ToolRegistry)sp.GetRequiredService()); + services.AddSingleton>>( + sp => sp.GetRequiredService().ExecuteToolAsync); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/FreeCode.Tools/SkillTool.cs b/src/FreeCode.Tools/SkillTool.cs new file mode 100644 index 0000000..3863894 --- /dev/null +++ b/src/FreeCode.Tools/SkillTool.cs @@ -0,0 +1,79 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using FreeCode.Skills; + +namespace FreeCode.Tools; + +public sealed class SkillTool : ToolBase, ITool +{ + public override string Name => "Skill"; + public override ToolCategory Category => ToolCategory.Agent; + + public async Task> ExecuteAsync(SkillToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var loader = context.Services.GetService(typeof(ISkillLoader)) as ISkillLoader; + if (loader is null) + { + return new ToolResult(new SkillToolOutput(string.Empty, false), true, "Skill loader is unavailable."); + } + + var skills = await loader.LoadSkillsAsync().ConfigureAwait(false); + var skill = skills.OfType().FirstOrDefault(s => string.Equals(s.Name, input.SkillName, StringComparison.OrdinalIgnoreCase)); + if (skill is null) + { + return new ToolResult(new SkillToolOutput(string.Empty, false), true, $"Skill not found: {input.SkillName}"); + } + + var content = skill.Content; + if (input.Args is not null) + { + foreach (var pair in input.Args) + { + content = content.Replace($"{{{{{pair.Key}}}}}", pair.Value, StringComparison.OrdinalIgnoreCase); + } + } + + return new ToolResult(new SkillToolOutput(content, true)); + } + catch (Exception ex) + { + return new ToolResult(new SkillToolOutput(string.Empty, false), true, ex.Message); + } + } + + public Task ValidateInputAsync(SkillToolInput input) + => Task.FromResult(string.IsNullOrWhiteSpace(input.SkillName) ? ValidationResult.Failure(new[] { "SkillName is required." }) : ValidationResult.Success()); + + public Task CheckPermissionAsync(SkillToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => true; + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"SkillName\":{\"type\":\"string\"},\"Args\":{\"type\":\"object\",\"additionalProperties\":{\"type\":\"string\"}}},\"required\":[\"SkillName\"]}").RootElement.Clone(); +} + +public sealed class SkillToolInput +{ + public SkillToolInput(string skillName, IReadOnlyDictionary? args = null) + { + SkillName = skillName; + Args = args; + } + + public string SkillName { get; } + public IReadOnlyDictionary? Args { get; } +} + +public sealed class SkillToolOutput +{ + public SkillToolOutput(string result, bool success) + { + Result = result; + Success = success; + } + + public string Result { get; } + public bool Success { get; } +} diff --git a/src/FreeCode.Tools/SleepTool.cs b/src/FreeCode.Tools/SleepTool.cs new file mode 100644 index 0000000..e06103e --- /dev/null +++ b/src/FreeCode.Tools/SleepTool.cs @@ -0,0 +1,71 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class SleepTool : ToolBase, ITool +{ + public override string Name => "Sleep"; + public override ToolCategory Category => ToolCategory.Task; + public override string[]? Aliases => ["Wait", "Delay"]; + public override bool IsEnabled() => true; + + public async Task> ExecuteAsync(SleepToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + var startedAt = DateTimeOffset.UtcNow; + try + { + await Task.Delay(input.DurationMs, ct).ConfigureAwait(false); + return new ToolResult(new SleepToolOutput + { + SleptMs = input.DurationMs, + Interrupted = false, + CompletedAtUtc = DateTimeOffset.UtcNow.ToString("O") + }); + } + catch (OperationCanceledException) + { + var elapsed = (int)Math.Max(0, (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds); + return new ToolResult(new SleepToolOutput + { + SleptMs = elapsed, + Interrupted = true, + CompletedAtUtc = DateTimeOffset.UtcNow.ToString("O") + }); + } + } + + public Task ValidateInputAsync(SleepToolInput input) + { + var errors = new List(); + if (input.DurationMs <= 0) + { + errors.Add("DurationMs must be greater than 0."); + } + + return Task.FromResult(errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors)); + } + + public Task CheckPermissionAsync(SleepToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => true; + + public override JsonElement GetInputSchema() + { + return JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"DurationMs\":{\"type\":\"integer\"},\"Description\":{\"type\":\"string\"}},\"required\":[\"DurationMs\"]}").RootElement.Clone(); + } + + public override Task GetDescriptionAsync(object? input = null) + { + if (input is SleepToolInput sleepInput && !string.IsNullOrWhiteSpace(sleepInput.Description)) + { + return Task.FromResult(sleepInput.Description); + } + + return Task.FromResult("Wait for a specified duration"); + } +} diff --git a/src/FreeCode.Tools/SleepToolInput.cs b/src/FreeCode.Tools/SleepToolInput.cs new file mode 100644 index 0000000..2cf3869 --- /dev/null +++ b/src/FreeCode.Tools/SleepToolInput.cs @@ -0,0 +1,7 @@ +namespace FreeCode.Tools; + +public class SleepToolInput +{ + public int DurationMs { get; set; } + public string? Description { get; set; } +} diff --git a/src/FreeCode.Tools/SleepToolOutput.cs b/src/FreeCode.Tools/SleepToolOutput.cs new file mode 100644 index 0000000..2498685 --- /dev/null +++ b/src/FreeCode.Tools/SleepToolOutput.cs @@ -0,0 +1,8 @@ +namespace FreeCode.Tools; + +public class SleepToolOutput +{ + public int SleptMs { get; set; } + public bool Interrupted { get; set; } + public string CompletedAtUtc { get; set; } = string.Empty; +} diff --git a/src/FreeCode.Tools/SnipTool.cs b/src/FreeCode.Tools/SnipTool.cs new file mode 100644 index 0000000..0299d7a --- /dev/null +++ b/src/FreeCode.Tools/SnipTool.cs @@ -0,0 +1,247 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class SnipTool : ToolBase, ITool +{ + public override string Name => "Snip"; + public override ToolCategory Category => ToolCategory.FileSystem; + public override string[]? Aliases => ["Snippet"]; + public override bool IsEnabled() => true; + + public async Task> ExecuteAsync(SnipToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var action = NormalizeAction(input.Action); + var snippets = await LoadSnippetsAsync(context, ct).ConfigureAwait(false); + + return action switch + { + "save" => await SaveAsync(input, snippets, context, ct).ConfigureAwait(false), + "get" => Get(input, snippets), + "delete" => await DeleteAsync(input, snippets, context, ct).ConfigureAwait(false), + _ => List(input, snippets) + }; + } + catch (Exception ex) + { + return new ToolResult(new SnipToolOutput + { + Action = NormalizeAction(input.Action), + Success = false, + Message = ex.Message + }, true, ex.Message); + } + } + + public Task ValidateInputAsync(SnipToolInput input) + { + var errors = new List(); + var action = NormalizeAction(input.Action); + if (action is not ("list" or "save" or "get" or "delete")) + { + errors.Add("Action must be one of: list, save, get, delete."); + } + + if (action == "save") + { + if (string.IsNullOrWhiteSpace(input.Name)) + { + errors.Add("Name is required for save action."); + } + + if (input.Content is null) + { + errors.Add("Content is required for save action."); + } + } + + if (action is "get" or "delete") + { + if (string.IsNullOrWhiteSpace(input.Name) && string.IsNullOrWhiteSpace(input.Query)) + { + errors.Add("Name or Query is required for get and delete actions."); + } + } + + if (input.Limit <= 0) + { + errors.Add("Limit must be greater than 0."); + } + + return Task.FromResult(errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors)); + } + + public Task CheckPermissionAsync(SnipToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public override bool IsConcurrencySafe(object input) => false; + + public override bool IsReadOnly(object input) + { + return input is SnipToolInput snipInput && NormalizeAction(snipInput.Action) is "list" or "get"; + } + + public override JsonElement GetInputSchema() + { + return JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Action\":{\"type\":\"string\",\"enum\":[\"list\",\"save\",\"get\",\"delete\"]},\"Name\":{\"type\":\"string\"},\"Content\":{\"type\":\"string\"},\"Language\":{\"type\":\"string\"},\"Description\":{\"type\":\"string\"},\"Query\":{\"type\":\"string\"},\"Limit\":{\"type\":\"integer\"}},\"required\":[\"Action\"]}").RootElement.Clone(); + } + + public override Task GetDescriptionAsync(object? input = null) + { + return Task.FromResult("Capture, store, and retrieve code snippets"); + } + + private static ToolResult List(SnipToolInput input, List snippets) + { + var query = input.Query?.Trim(); + var matches = snippets + .Where(snippet => string.IsNullOrWhiteSpace(query) + || snippet.Name.Contains(query, StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrWhiteSpace(snippet.Description) && snippet.Description.Contains(query, StringComparison.OrdinalIgnoreCase)) + || (!string.IsNullOrWhiteSpace(snippet.Language) && snippet.Language.Contains(query, StringComparison.OrdinalIgnoreCase))) + .OrderByDescending(snippet => snippet.SavedAtUtc) + .Take(input.Limit) + .Select(ToOutput) + .ToList(); + + return new ToolResult(new SnipToolOutput + { + Action = "list", + Success = true, + Message = $"Found {matches.Count} snippet(s).", + Snippets = matches + }); + } + + private static ToolResult Get(SnipToolInput input, List snippets) + { + var match = FindSnippet(input, snippets); + if (match is null) + { + return new ToolResult(new SnipToolOutput + { + Action = "get", + Success = false, + Message = "Snippet not found." + }, true, "Snippet not found."); + } + + return new ToolResult(new SnipToolOutput + { + Action = "get", + Success = true, + Message = $"Loaded snippet '{match.Name}'.", + Snippets = [ToOutput(match)] + }); + } + + private static async Task> SaveAsync(SnipToolInput input, List snippets, ToolExecutionContext context, CancellationToken ct) + { + var existing = snippets.FirstOrDefault(snippet => string.Equals(snippet.Name, input.Name, StringComparison.OrdinalIgnoreCase)); + var savedAt = DateTimeOffset.UtcNow.ToString("O"); + if (existing is null) + { + existing = new StoredSnippet(); + snippets.Add(existing); + } + + existing.Name = input.Name!.Trim(); + existing.Content = input.Content ?? string.Empty; + existing.Language = input.Language; + existing.Description = input.Description; + existing.SavedAtUtc = savedAt; + + await SaveSnippetsAsync(context, snippets, ct).ConfigureAwait(false); + return new ToolResult(new SnipToolOutput + { + Action = "save", + Success = true, + Message = $"Saved snippet '{existing.Name}'.", + Snippets = [ToOutput(existing)] + }); + } + + private static async Task> DeleteAsync(SnipToolInput input, List snippets, ToolExecutionContext context, CancellationToken ct) + { + var match = FindSnippet(input, snippets); + if (match is null) + { + return new ToolResult(new SnipToolOutput + { + Action = "delete", + Success = false, + Message = "Snippet not found." + }, true, "Snippet not found."); + } + + snippets.Remove(match); + await SaveSnippetsAsync(context, snippets, ct).ConfigureAwait(false); + return new ToolResult(new SnipToolOutput + { + Action = "delete", + Success = true, + Message = $"Deleted snippet '{match.Name}'." + }); + } + + private static StoredSnippet? FindSnippet(SnipToolInput input, IEnumerable snippets) + { + if (!string.IsNullOrWhiteSpace(input.Name)) + { + return snippets.FirstOrDefault(snippet => string.Equals(snippet.Name, input.Name, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(input.Query)) + { + return snippets.FirstOrDefault(snippet => snippet.Name.Contains(input.Query, StringComparison.OrdinalIgnoreCase)); + } + + return null; + } + + private static string NormalizeAction(string? action) + { + return string.IsNullOrWhiteSpace(action) ? "list" : action.Trim().ToLowerInvariant() switch + { + "capture" => "save", + "store" => "save", + "retrieve" => "get", + _ => action.Trim().ToLowerInvariant() + }; + } + + private static string GetStorePath(ToolExecutionContext context) + => Path.Combine(context.WorkingDirectory, ".freecode", "snippets.json"); + + private static async Task> LoadSnippetsAsync(ToolExecutionContext context, CancellationToken ct) + => await ToolUtilities.ReadJsonAsync>(GetStorePath(context), ct).ConfigureAwait(false) ?? []; + + private static Task SaveSnippetsAsync(ToolExecutionContext context, List snippets, CancellationToken ct) + => ToolUtilities.WriteJsonAsync(GetStorePath(context), snippets.OrderByDescending(snippet => snippet.SavedAtUtc).ToList(), ct); + + private static SnipToolSnippet ToOutput(StoredSnippet snippet) + { + return new SnipToolSnippet + { + Name = snippet.Name, + Content = snippet.Content, + Language = snippet.Language, + Description = snippet.Description, + SavedAtUtc = snippet.SavedAtUtc + }; + } + + internal sealed class StoredSnippet + { + public string Name { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public string? Language { get; set; } + public string? Description { get; set; } + public string SavedAtUtc { get; set; } = string.Empty; + } +} diff --git a/src/FreeCode.Tools/SnipToolInput.cs b/src/FreeCode.Tools/SnipToolInput.cs new file mode 100644 index 0000000..7493485 --- /dev/null +++ b/src/FreeCode.Tools/SnipToolInput.cs @@ -0,0 +1,12 @@ +namespace FreeCode.Tools; + +public class SnipToolInput +{ + public string Action { get; set; } = "list"; + public string? Name { get; set; } + public string? Content { get; set; } + public string? Language { get; set; } + public string? Description { get; set; } + public string? Query { get; set; } + public int Limit { get; set; } = 20; +} diff --git a/src/FreeCode.Tools/SnipToolOutput.cs b/src/FreeCode.Tools/SnipToolOutput.cs new file mode 100644 index 0000000..551741a --- /dev/null +++ b/src/FreeCode.Tools/SnipToolOutput.cs @@ -0,0 +1,18 @@ +namespace FreeCode.Tools; + +public class SnipToolOutput +{ + public string Action { get; set; } = string.Empty; + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + public List Snippets { get; set; } = []; +} + +public class SnipToolSnippet +{ + public string Name { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public string? Language { get; set; } + public string? Description { get; set; } + public string SavedAtUtc { get; set; } = string.Empty; +} diff --git a/src/FreeCode.Tools/SourceGenerationContext.cs b/src/FreeCode.Tools/SourceGenerationContext.cs new file mode 100644 index 0000000..6b1b9b8 --- /dev/null +++ b/src/FreeCode.Tools/SourceGenerationContext.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(RemoteTriggerTool.RemoteTriggerStore))] +[JsonSerializable(typeof(TodoWriteToolInput))] +[JsonSerializable(typeof(TodoWriteToolOutput))] +[JsonSerializable(typeof(ConfigToolInput))] +[JsonSerializable(typeof(ConfigToolOutput))] +internal sealed partial class SourceGenerationContext : JsonSerializerContext +{ +} diff --git a/src/FreeCode.Tools/TaskCreateTool.cs b/src/FreeCode.Tools/TaskCreateTool.cs new file mode 100644 index 0000000..6981c0d --- /dev/null +++ b/src/FreeCode.Tools/TaskCreateTool.cs @@ -0,0 +1,93 @@ +using System.Diagnostics; +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode.Tools; + +public sealed class TaskCreateTool : ToolBase, ITool +{ + public override string Name => "TaskCreate"; + public override ToolCategory Category => ToolCategory.Task; + + public async Task> ExecuteAsync(TaskCreateToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var manager = ResolveTaskManager(context); + if (manager is null) + { + return new ToolResult(new TaskCreateToolOutput(string.Empty), true, "Background task manager is unavailable."); + } + + BackgroundTask task; + if (string.IsNullOrWhiteSpace(input.AgentType)) + { + var shell = OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/zsh"; + var arguments = OperatingSystem.IsWindows() + ? $"/c {input.Command}" + : $"-lc {QuoteForShell(input.Command)}"; + + var psi = new ProcessStartInfo + { + FileName = shell, + Arguments = arguments, + WorkingDirectory = context.WorkingDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = false, + CreateNoWindow = true + }; + + task = await manager.CreateShellTaskAsync(input.Command, psi).ConfigureAwait(false); + } + else + { + task = await manager.CreateAgentTaskAsync(input.Command, input.AgentType, model: null).ConfigureAwait(false); + } + + return new ToolResult(new TaskCreateToolOutput(task.TaskId)); + } + catch (Exception ex) + { + return new ToolResult(new TaskCreateToolOutput(string.Empty), true, ex.Message); + } + } + + public Task ValidateInputAsync(TaskCreateToolInput input) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(input.Description)) + { + errors.Add("Description is required."); + } + + if (string.IsNullOrWhiteSpace(input.Command)) + { + errors.Add("Command is required."); + } + + return Task.FromResult(errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors)); + } + + public Task CheckPermissionAsync(TaskCreateToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => false; + + public override JsonElement GetInputSchema() + => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Description\":{\"type\":\"string\"},\"Command\":{\"type\":\"string\"},\"AgentType\":{\"type\":\"string\"}},\"required\":[\"Description\",\"Command\"]}").RootElement.Clone(); + + public override Task GetDescriptionAsync(object? input = null) + => Task.FromResult("Create a background task."); + + private static IBackgroundTaskManager? ResolveTaskManager(ToolExecutionContext context) + => context.TaskManager as IBackgroundTaskManager ?? context.Services.GetService(); + + private static string QuoteForShell(string command) + => "'" + command.Replace("'", "'\\''") + "'"; +} diff --git a/src/FreeCode.Tools/TaskCreateToolInput.cs b/src/FreeCode.Tools/TaskCreateToolInput.cs new file mode 100644 index 0000000..ca448fe --- /dev/null +++ b/src/FreeCode.Tools/TaskCreateToolInput.cs @@ -0,0 +1,15 @@ +namespace FreeCode.Tools; + +public sealed class TaskCreateToolInput +{ + public TaskCreateToolInput(string description, string command, string? agentType = null) + { + Description = description; + Command = command; + AgentType = agentType; + } + + public string Description { get; } + public string Command { get; } + public string? AgentType { get; } +} diff --git a/src/FreeCode.Tools/TaskCreateToolOutput.cs b/src/FreeCode.Tools/TaskCreateToolOutput.cs new file mode 100644 index 0000000..11cecaf --- /dev/null +++ b/src/FreeCode.Tools/TaskCreateToolOutput.cs @@ -0,0 +1,11 @@ +namespace FreeCode.Tools; + +public sealed class TaskCreateToolOutput +{ + public TaskCreateToolOutput(string taskId) + { + TaskId = taskId; + } + + public string TaskId { get; } +} diff --git a/src/FreeCode.Tools/TaskGetTool.cs b/src/FreeCode.Tools/TaskGetTool.cs new file mode 100644 index 0000000..a5339b0 --- /dev/null +++ b/src/FreeCode.Tools/TaskGetTool.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode.Tools; + +public sealed class TaskGetTool : ToolBase, ITool +{ + public override string Name => "TaskGet"; + public override ToolCategory Category => ToolCategory.Task; + + public async Task> ExecuteAsync(TaskGetToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var manager = ResolveTaskManager(context); + if (manager is null) + { + return new ToolResult(new TaskGetToolOutput(null), true, "Background task manager is unavailable."); + } + + var task = manager.GetTask(input.TaskId); + if (task is null) + { + return new ToolResult(new TaskGetToolOutput(null)); + } + + var output = await manager.GetTaskOutputAsync(task.TaskId).ConfigureAwait(false); + return new ToolResult(new TaskGetToolOutput(TaskToolModelFactory.Create(task, output))); + } + catch (Exception ex) + { + return new ToolResult(new TaskGetToolOutput(null), true, ex.Message); + } + } + + public Task ValidateInputAsync(TaskGetToolInput input) + => Task.FromResult(string.IsNullOrWhiteSpace(input.TaskId) ? ValidationResult.Failure(new[] { "TaskId is required." }) : ValidationResult.Success()); + + public Task CheckPermissionAsync(TaskGetToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => true; + + public override JsonElement GetInputSchema() + => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"TaskId\":{\"type\":\"string\"}},\"required\":[\"TaskId\"]}").RootElement.Clone(); + + public override Task GetDescriptionAsync(object? input = null) + => Task.FromResult("Get a background task."); + + private static IBackgroundTaskManager? ResolveTaskManager(ToolExecutionContext context) + => context.TaskManager as IBackgroundTaskManager ?? context.Services.GetService(); +} diff --git a/src/FreeCode.Tools/TaskGetToolInput.cs b/src/FreeCode.Tools/TaskGetToolInput.cs new file mode 100644 index 0000000..68fc1cf --- /dev/null +++ b/src/FreeCode.Tools/TaskGetToolInput.cs @@ -0,0 +1,11 @@ +namespace FreeCode.Tools; + +public sealed class TaskGetToolInput +{ + public TaskGetToolInput(string taskId) + { + TaskId = taskId; + } + + public string TaskId { get; } +} diff --git a/src/FreeCode.Tools/TaskGetToolOutput.cs b/src/FreeCode.Tools/TaskGetToolOutput.cs new file mode 100644 index 0000000..a550755 --- /dev/null +++ b/src/FreeCode.Tools/TaskGetToolOutput.cs @@ -0,0 +1,11 @@ +namespace FreeCode.Tools; + +public sealed class TaskGetToolOutput +{ + public TaskGetToolOutput(TaskToolModel? task) + { + Task = task; + } + + public TaskToolModel? Task { get; } +} diff --git a/src/FreeCode.Tools/TaskListTool.cs b/src/FreeCode.Tools/TaskListTool.cs new file mode 100644 index 0000000..62f839d --- /dev/null +++ b/src/FreeCode.Tools/TaskListTool.cs @@ -0,0 +1,61 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode.Tools; + +public sealed class TaskListTool : ToolBase, ITool +{ + public override string Name => "TaskList"; + public override ToolCategory Category => ToolCategory.Task; + + public async Task> ExecuteAsync(TaskListToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var manager = ResolveTaskManager(context); + if (manager is null) + { + return new ToolResult(new TaskListToolOutput([]), true, "Background task manager is unavailable."); + } + + var tasks = manager.ListTasks(); + var filtered = input.Status.HasValue + ? tasks.Where(task => task.Status == input.Status.Value).ToList() + : tasks; + + var models = new List(filtered.Count); + foreach (var task in filtered) + { + var output = await manager.GetTaskOutputAsync(task.TaskId).ConfigureAwait(false); + models.Add(TaskToolModelFactory.Create(task, output)); + } + + return new ToolResult(new TaskListToolOutput(models)); + } + catch (Exception ex) + { + return new ToolResult(new TaskListToolOutput([]), true, ex.Message); + } + } + + public Task ValidateInputAsync(TaskListToolInput input) + => Task.FromResult(ValidationResult.Success()); + + public Task CheckPermissionAsync(TaskListToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => true; + + public override JsonElement GetInputSchema() + => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Status\":{\"type\":\"string\",\"enum\":[\"Pending\",\"Running\",\"Completed\",\"Failed\",\"Stopped\"]}}}").RootElement.Clone(); + + public override Task GetDescriptionAsync(object? input = null) + => Task.FromResult("List background tasks."); + + private static IBackgroundTaskManager? ResolveTaskManager(ToolExecutionContext context) + => context.TaskManager as IBackgroundTaskManager ?? context.Services.GetService(); +} diff --git a/src/FreeCode.Tools/TaskListToolInput.cs b/src/FreeCode.Tools/TaskListToolInput.cs new file mode 100644 index 0000000..341bba0 --- /dev/null +++ b/src/FreeCode.Tools/TaskListToolInput.cs @@ -0,0 +1,14 @@ +using FreeCode.Core.Enums; +using TaskStatus = FreeCode.Core.Enums.TaskStatus; + +namespace FreeCode.Tools; + +public sealed class TaskListToolInput +{ + public TaskListToolInput(TaskStatus? status = null) + { + Status = status; + } + + public TaskStatus? Status { get; } +} diff --git a/src/FreeCode.Tools/TaskListToolOutput.cs b/src/FreeCode.Tools/TaskListToolOutput.cs new file mode 100644 index 0000000..64adec0 --- /dev/null +++ b/src/FreeCode.Tools/TaskListToolOutput.cs @@ -0,0 +1,11 @@ +namespace FreeCode.Tools; + +public sealed class TaskListToolOutput +{ + public TaskListToolOutput(IReadOnlyList tasks) + { + Tasks = tasks; + } + + public IReadOnlyList Tasks { get; } +} diff --git a/src/FreeCode.Tools/TaskOutputTool.cs b/src/FreeCode.Tools/TaskOutputTool.cs new file mode 100644 index 0000000..8364e90 --- /dev/null +++ b/src/FreeCode.Tools/TaskOutputTool.cs @@ -0,0 +1,63 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class TaskOutputTool : ToolBase, ITool +{ + public override string Name => "TaskOutput"; + public override ToolCategory Category => ToolCategory.Task; + + public async Task> ExecuteAsync(TaskOutputToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + if (context.TaskManager is not IBackgroundTaskManager manager) + { + return new ToolResult(new TaskOutputToolOutput(string.Empty, false, null), true, "Background task manager is unavailable."); + } + + var output = await manager.GetTaskOutputAsync(input.TaskId).ConfigureAwait(false) ?? string.Empty; + var task = manager.GetTask(input.TaskId); + return new ToolResult(new TaskOutputToolOutput(output, task is not null && task.Status is FreeCode.Core.Enums.TaskStatus.Completed or FreeCode.Core.Enums.TaskStatus.Failed or FreeCode.Core.Enums.TaskStatus.Stopped, task is FreeCode.Core.Models.LocalShellTask shell ? shell.ExitCode : null)); + } + catch (Exception ex) + { + return new ToolResult(new TaskOutputToolOutput(string.Empty, false, null), true, ex.Message); + } + } + + public Task ValidateInputAsync(TaskOutputToolInput input) + => Task.FromResult(string.IsNullOrWhiteSpace(input.TaskId) ? ValidationResult.Failure(new[] { "TaskId is required." }) : ValidationResult.Success()); + + public Task CheckPermissionAsync(TaskOutputToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => true; + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"TaskId\":{\"type\":\"string\"}},\"required\":[\"TaskId\"]}").RootElement.Clone(); +} + +public sealed class TaskOutputToolInput +{ + public TaskOutputToolInput(string taskId) + { + TaskId = taskId; + } + + public string TaskId { get; } +} + +public sealed class TaskOutputToolOutput +{ + public TaskOutputToolOutput(string output, bool isComplete, int? exitCode) + { + Output = output; + IsComplete = isComplete; + ExitCode = exitCode; + } + + public string Output { get; } + public bool IsComplete { get; } + public int? ExitCode { get; } +} diff --git a/src/FreeCode.Tools/TaskStopTool.cs b/src/FreeCode.Tools/TaskStopTool.cs new file mode 100644 index 0000000..3e64ef3 --- /dev/null +++ b/src/FreeCode.Tools/TaskStopTool.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode.Tools; + +public sealed class TaskStopTool : ToolBase, ITool +{ + public override string Name => "TaskStop"; + public override ToolCategory Category => ToolCategory.Task; + + public async Task> ExecuteAsync(TaskStopToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var manager = ResolveTaskManager(context); + if (manager is null) + { + return new ToolResult(new TaskStopToolOutput(false, input.TaskId), true, "Background task manager is unavailable."); + } + + var task = manager.GetTask(input.TaskId); + if (task is null) + { + return new ToolResult(new TaskStopToolOutput(false, input.TaskId), true, "Task not found."); + } + + await manager.StopTaskAsync(input.TaskId).ConfigureAwait(false); + return new ToolResult(new TaskStopToolOutput(true, input.TaskId)); + } + catch (Exception ex) + { + return new ToolResult(new TaskStopToolOutput(false, input.TaskId), true, ex.Message); + } + } + + public Task ValidateInputAsync(TaskStopToolInput input) + => Task.FromResult(string.IsNullOrWhiteSpace(input.TaskId) ? ValidationResult.Failure(new[] { "TaskId is required." }) : ValidationResult.Success()); + + public Task CheckPermissionAsync(TaskStopToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => false; + + public override JsonElement GetInputSchema() + => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"TaskId\":{\"type\":\"string\"}},\"required\":[\"TaskId\"]}").RootElement.Clone(); + + public override Task GetDescriptionAsync(object? input = null) + => Task.FromResult("Stop a background task."); + + private static IBackgroundTaskManager? ResolveTaskManager(ToolExecutionContext context) + => context.TaskManager as IBackgroundTaskManager ?? context.Services.GetService(); +} diff --git a/src/FreeCode.Tools/TaskStopToolInput.cs b/src/FreeCode.Tools/TaskStopToolInput.cs new file mode 100644 index 0000000..8f45c8b --- /dev/null +++ b/src/FreeCode.Tools/TaskStopToolInput.cs @@ -0,0 +1,11 @@ +namespace FreeCode.Tools; + +public sealed class TaskStopToolInput +{ + public TaskStopToolInput(string taskId) + { + TaskId = taskId; + } + + public string TaskId { get; } +} diff --git a/src/FreeCode.Tools/TaskStopToolOutput.cs b/src/FreeCode.Tools/TaskStopToolOutput.cs new file mode 100644 index 0000000..0b3e0b1 --- /dev/null +++ b/src/FreeCode.Tools/TaskStopToolOutput.cs @@ -0,0 +1,13 @@ +namespace FreeCode.Tools; + +public sealed class TaskStopToolOutput +{ + public TaskStopToolOutput(bool success, string taskId) + { + Success = success; + TaskId = taskId; + } + + public bool Success { get; } + public string TaskId { get; } +} diff --git a/src/FreeCode.Tools/TaskToolModels.cs b/src/FreeCode.Tools/TaskToolModels.cs new file mode 100644 index 0000000..a8fad7f --- /dev/null +++ b/src/FreeCode.Tools/TaskToolModels.cs @@ -0,0 +1,151 @@ +using FreeCode.Core.Enums; +using FreeCode.Core.Models; +using TaskStatus = FreeCode.Core.Enums.TaskStatus; + +namespace FreeCode.Tools; + +public sealed class TaskToolModel +{ + public TaskToolModel(string taskId, string taskType, TaskStatus status, DateTime? startedAt, DateTime? completedAt, string? errorMessage, bool isBackgrounded, string? command, string? prompt, string? agentType, string? model, string? sessionUrl, string? triggerReason, string? output, int? exitCode) + { + TaskId = taskId; + TaskType = taskType; + Status = status; + StartedAt = startedAt; + CompletedAt = completedAt; + ErrorMessage = errorMessage; + IsBackgrounded = isBackgrounded; + Command = command; + Prompt = prompt; + AgentType = agentType; + Model = model; + SessionUrl = sessionUrl; + TriggerReason = triggerReason; + Output = output; + ExitCode = exitCode; + } + + public string TaskId { get; } + public string TaskType { get; } + public TaskStatus Status { get; } + public DateTime? StartedAt { get; } + public DateTime? CompletedAt { get; } + public string? ErrorMessage { get; } + public bool IsBackgrounded { get; } + public string? Command { get; } + public string? Prompt { get; } + public string? AgentType { get; } + public string? Model { get; } + public string? SessionUrl { get; } + public string? TriggerReason { get; } + public string? Output { get; } + public int? ExitCode { get; } +} + +internal static class TaskToolModelFactory +{ + public static TaskToolModel Create(BackgroundTask task, string? output = null) + { + return task switch + { + LocalShellTask shell => new TaskToolModel( + shell.TaskId, + shell.TaskType.ToString(), + shell.Status, + shell.StartedAt, + shell.CompletedAt, + shell.ErrorMessage, + shell.IsBackgrounded, + shell.Command, + null, + null, + null, + null, + null, + output ?? JoinOutput(shell.Stdout, shell.Stderr), + shell.ExitCode), + LocalAgentTask agent => new TaskToolModel( + agent.TaskId, + agent.TaskType.ToString(), + agent.Status, + agent.StartedAt, + agent.CompletedAt, + agent.ErrorMessage, + agent.IsBackgrounded, + null, + agent.Prompt, + agent.AgentType, + agent.Model, + null, + null, + output ?? JoinMessages(agent), + null), + RemoteAgentTask remote => new TaskToolModel( + remote.TaskId, + remote.TaskType.ToString(), + ((BackgroundTask)remote).Status, + remote.StartedAt, + remote.CompletedAt, + remote.ErrorMessage, + remote.IsBackgrounded, + null, + null, + null, + null, + remote.SessionUrl, + null, + output ?? remote.Plan, + null), + DreamTask dream => new TaskToolModel( + dream.TaskId, + dream.TaskType.ToString(), + dream.Status, + dream.StartedAt, + dream.CompletedAt, + dream.ErrorMessage, + dream.IsBackgrounded, + null, + null, + null, + null, + null, + dream.TriggerReason, + output ?? dream.TriggerReason, + null), + _ => new TaskToolModel( + task.TaskId, + task.TaskType.ToString(), + task.Status, + task.StartedAt, + task.CompletedAt, + task.ErrorMessage, + task.IsBackgrounded, + null, + null, + null, + null, + null, + null, + output, + null) + }; + } + + private static string? JoinOutput(string? stdout, string? stderr) + { + return string.Join('\n', new[] { stdout, stderr }.Where(value => !string.IsNullOrWhiteSpace(value))).Trim() switch + { + "" => null, + var combined => combined + }; + } + + private static string? JoinMessages(LocalAgentTask agent) + { + return string.Join("\n", agent.Messages.Select(message => message.Content?.ToString() ?? string.Empty)).Trim() switch + { + "" => null, + var combined => combined + }; + } +} diff --git a/src/FreeCode.Tools/TaskUpdateTool.cs b/src/FreeCode.Tools/TaskUpdateTool.cs new file mode 100644 index 0000000..b473964 --- /dev/null +++ b/src/FreeCode.Tools/TaskUpdateTool.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using Microsoft.Extensions.DependencyInjection; +using TaskStatus = FreeCode.Core.Enums.TaskStatus; + +namespace FreeCode.Tools; + +public sealed class TaskUpdateTool : ToolBase, ITool +{ + public override string Name => "TaskUpdate"; + public override ToolCategory Category => ToolCategory.Task; + + public async Task> ExecuteAsync(TaskUpdateToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var manager = ResolveTaskManager(context); + if (manager is null) + { + return new ToolResult(new TaskUpdateToolOutput(null), true, "Background task manager is unavailable."); + } + + var task = manager.GetTask(input.TaskId); + if (task is null) + { + return new ToolResult(new TaskUpdateToolOutput(null), true, "Task not found."); + } + + if (input.Status.HasValue) + { + task.Status = input.Status.Value; + if (input.Status is TaskStatus.Completed or TaskStatus.Failed or TaskStatus.Stopped) + { + task.CompletedAt ??= DateTime.UtcNow; + } + else if (input.Status == TaskStatus.Running) + { + task.StartedAt ??= DateTime.UtcNow; + task.CompletedAt = null; + } + } + + var output = await manager.GetTaskOutputAsync(task.TaskId).ConfigureAwait(false); + return new ToolResult(new TaskUpdateToolOutput(TaskToolModelFactory.Create(task, output))); + } + catch (Exception ex) + { + return new ToolResult(new TaskUpdateToolOutput(null), true, ex.Message); + } + } + + public Task ValidateInputAsync(TaskUpdateToolInput input) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(input.TaskId)) + { + errors.Add("TaskId is required."); + } + + if (input.Status is null && string.IsNullOrWhiteSpace(input.Priority)) + { + errors.Add("At least one update field is required."); + } + + return Task.FromResult(errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors)); + } + + public Task CheckPermissionAsync(TaskUpdateToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => false; + + public override JsonElement GetInputSchema() + => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"TaskId\":{\"type\":\"string\"},\"Status\":{\"type\":\"string\",\"enum\":[\"Pending\",\"Running\",\"Completed\",\"Failed\",\"Stopped\"]},\"Priority\":{\"type\":\"string\"}},\"required\":[\"TaskId\"]}").RootElement.Clone(); + + public override Task GetDescriptionAsync(object? input = null) + => Task.FromResult("Update a background task."); + + private static IBackgroundTaskManager? ResolveTaskManager(ToolExecutionContext context) + => context.TaskManager as IBackgroundTaskManager ?? context.Services.GetService(); +} diff --git a/src/FreeCode.Tools/TaskUpdateToolInput.cs b/src/FreeCode.Tools/TaskUpdateToolInput.cs new file mode 100644 index 0000000..bb70162 --- /dev/null +++ b/src/FreeCode.Tools/TaskUpdateToolInput.cs @@ -0,0 +1,18 @@ +using FreeCode.Core.Enums; +using TaskStatus = FreeCode.Core.Enums.TaskStatus; + +namespace FreeCode.Tools; + +public sealed class TaskUpdateToolInput +{ + public TaskUpdateToolInput(string taskId, TaskStatus? status = null, string? priority = null) + { + TaskId = taskId; + Status = status; + Priority = priority; + } + + public string TaskId { get; } + public TaskStatus? Status { get; } + public string? Priority { get; } +} diff --git a/src/FreeCode.Tools/TaskUpdateToolOutput.cs b/src/FreeCode.Tools/TaskUpdateToolOutput.cs new file mode 100644 index 0000000..1bfa259 --- /dev/null +++ b/src/FreeCode.Tools/TaskUpdateToolOutput.cs @@ -0,0 +1,11 @@ +namespace FreeCode.Tools; + +public sealed class TaskUpdateToolOutput +{ + public TaskUpdateToolOutput(TaskToolModel? task) + { + Task = task; + } + + public TaskToolModel? Task { get; } +} diff --git a/src/FreeCode.Tools/TeamCreateTool.cs b/src/FreeCode.Tools/TeamCreateTool.cs new file mode 100644 index 0000000..b7324a8 --- /dev/null +++ b/src/FreeCode.Tools/TeamCreateTool.cs @@ -0,0 +1,78 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class TeamCreateTool : ToolBase, ITool +{ + internal static readonly ConcurrentDictionary Teams = new(StringComparer.OrdinalIgnoreCase); + + public override string Name => "TeamCreate"; + public override ToolCategory Category => ToolCategory.AgentSwarm; + + public Task> ExecuteAsync(TeamCreateToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var teamId = Guid.NewGuid().ToString("N")[..8]; + var record = new TeamRecord(teamId, input.Name, input.Members.ToList(), input.Description); + + if (!Teams.TryAdd(teamId, record)) + { + return Task.FromResult(new ToolResult(new TeamCreateToolOutput(string.Empty, input.Name, 0), true, "Failed to create team.")); + } + + return Task.FromResult(new ToolResult(new TeamCreateToolOutput(teamId, input.Name, input.Members.Count))); + } + catch (Exception ex) + { + return Task.FromResult(new ToolResult(new TeamCreateToolOutput(string.Empty, input.Name, 0), true, ex.Message)); + } + } + + public Task ValidateInputAsync(TeamCreateToolInput input) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(input.Name)) errors.Add("Name is required."); + if (input.Members is not { Count: > 0 }) errors.Add("At least one member is required."); + return Task.FromResult(errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors)); + } + + public Task CheckPermissionAsync(TeamCreateToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => false; + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Name\":{\"type\":\"string\"},\"Members\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"Description\":{\"type\":\"string\"}},\"required\":[\"Name\",\"Members\"]}").RootElement.Clone(); +} + +internal sealed record TeamRecord(string TeamId, string Name, List Members, string? Description); + +public sealed class TeamCreateToolInput +{ + public TeamCreateToolInput(string name, IReadOnlyList members, string? description = null) + { + Name = name; + Members = members; + Description = description; + } + + public string Name { get; } + public IReadOnlyList Members { get; } + public string? Description { get; } +} + +public sealed class TeamCreateToolOutput +{ + public TeamCreateToolOutput(string teamId, string name, int memberCount) + { + TeamId = teamId; + Name = name; + MemberCount = memberCount; + } + + public string TeamId { get; } + public string Name { get; } + public int MemberCount { get; } +} diff --git a/src/FreeCode.Tools/TeamDeleteTool.cs b/src/FreeCode.Tools/TeamDeleteTool.cs new file mode 100644 index 0000000..888efcf --- /dev/null +++ b/src/FreeCode.Tools/TeamDeleteTool.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class TeamDeleteTool : ToolBase, ITool +{ + public override string Name => "TeamDelete"; + public override ToolCategory Category => ToolCategory.AgentSwarm; + + public Task> ExecuteAsync(TeamDeleteToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var deleted = TeamCreateTool.Teams.TryRemove(input.TeamId, out _); + return Task.FromResult(new ToolResult(new TeamDeleteToolOutput(deleted, input.TeamId))); + } + catch (Exception ex) + { + return Task.FromResult(new ToolResult(new TeamDeleteToolOutput(false, input.TeamId), true, ex.Message)); + } + } + + public Task ValidateInputAsync(TeamDeleteToolInput input) + => Task.FromResult(string.IsNullOrWhiteSpace(input.TeamId) ? ValidationResult.Failure(new[] { "TeamId is required." }) : ValidationResult.Success()); + + public Task CheckPermissionAsync(TeamDeleteToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => false; + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"TeamId\":{\"type\":\"string\"}},\"required\":[\"TeamId\"]}").RootElement.Clone(); +} + +public sealed class TeamDeleteToolInput +{ + public TeamDeleteToolInput(string teamId) + { + TeamId = teamId; + } + + public string TeamId { get; } +} + +public sealed class TeamDeleteToolOutput +{ + public TeamDeleteToolOutput(bool deleted, string teamId) + { + Deleted = deleted; + TeamId = teamId; + } + + public bool Deleted { get; } + public string TeamId { get; } +} diff --git a/src/FreeCode.Tools/TerminalCaptureTool.cs b/src/FreeCode.Tools/TerminalCaptureTool.cs new file mode 100644 index 0000000..4bb80a2 --- /dev/null +++ b/src/FreeCode.Tools/TerminalCaptureTool.cs @@ -0,0 +1,117 @@ +using System.Text; +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class TerminalCaptureTool : ToolBase, ITool +{ + public override string Name => "TerminalCapture"; + public override ToolCategory Category => ToolCategory.UserInteraction; + public override string[]? Aliases => ["Capture"]; + public override bool IsEnabled() => true; + + public async Task> ExecuteAsync(TerminalCaptureToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var source = "environment"; + var content = string.Empty; + + if (!string.IsNullOrWhiteSpace(input.TaskId)) + { + if (context.TaskManager is not IBackgroundTaskManager manager) + { + return new ToolResult(new TerminalCaptureToolOutput(), true, "Background task manager is unavailable."); + } + + content = await manager.GetTaskOutputAsync(input.TaskId).ConfigureAwait(false) ?? string.Empty; + source = $"task:{input.TaskId}"; + } + else if (!string.IsNullOrWhiteSpace(input.FilePath)) + { + var path = ToolUtilities.ResolvePath(input.FilePath, context.WorkingDirectory); + content = await File.ReadAllTextAsync(path, Encoding.UTF8, ct).ConfigureAwait(false); + source = $"file:{path}"; + } + + if (input.IncludeEnvironment) + { + var environmentBlock = BuildEnvironmentBlock(context); + content = string.IsNullOrWhiteSpace(content) ? environmentBlock : $"{content}{Environment.NewLine}{Environment.NewLine}{environmentBlock}"; + } + + string? savedPath = null; + if (!string.IsNullOrWhiteSpace(input.OutputPath)) + { + savedPath = ToolUtilities.ResolvePath(input.OutputPath, context.WorkingDirectory); + Directory.CreateDirectory(Path.GetDirectoryName(savedPath) ?? context.WorkingDirectory); + await File.WriteAllTextAsync(savedPath, content, Encoding.UTF8, ct).ConfigureAwait(false); + } + + return new ToolResult(new TerminalCaptureToolOutput + { + Source = source, + Content = content, + SavedPath = savedPath, + TerminalProgram = Environment.GetEnvironmentVariable("TERM_PROGRAM") ?? Environment.GetEnvironmentVariable("TERM") ?? string.Empty, + CapturedAtUtc = DateTimeOffset.UtcNow.ToString("O") + }); + } + catch (Exception ex) + { + return new ToolResult(new TerminalCaptureToolOutput(), true, ex.Message); + } + } + + public Task ValidateInputAsync(TerminalCaptureToolInput input) + { + var errors = new List(); + if (!string.IsNullOrWhiteSpace(input.TaskId) && !string.IsNullOrWhiteSpace(input.FilePath)) + { + errors.Add("Specify either TaskId or FilePath, not both."); + } + + return Task.FromResult(errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors)); + } + + public Task CheckPermissionAsync(TerminalCaptureToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public override bool IsConcurrencySafe(object input) => true; + + public override bool IsReadOnly(object input) + { + if (input is TerminalCaptureToolInput terminalCaptureInput) + { + return string.IsNullOrWhiteSpace(terminalCaptureInput.OutputPath); + } + + return false; + } + + public override JsonElement GetInputSchema() + { + return JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"TaskId\":{\"type\":\"string\"},\"FilePath\":{\"type\":\"string\"},\"OutputPath\":{\"type\":\"string\"},\"IncludeEnvironment\":{\"type\":\"boolean\"}}}").RootElement.Clone(); + } + + public override Task GetDescriptionAsync(object? input = null) + { + return Task.FromResult("Capture terminal output and metadata"); + } + + private static string BuildEnvironmentBlock(ToolExecutionContext context) + { + var lines = new List + { + $"working_directory={context.WorkingDirectory}", + $"term_program={Environment.GetEnvironmentVariable("TERM_PROGRAM") ?? string.Empty}", + $"term={Environment.GetEnvironmentVariable("TERM") ?? string.Empty}", + $"shell={Environment.GetEnvironmentVariable("SHELL") ?? string.Empty}" + }; + + return string.Join(Environment.NewLine, lines); + } +} diff --git a/src/FreeCode.Tools/TerminalCaptureToolInput.cs b/src/FreeCode.Tools/TerminalCaptureToolInput.cs new file mode 100644 index 0000000..530ed1c --- /dev/null +++ b/src/FreeCode.Tools/TerminalCaptureToolInput.cs @@ -0,0 +1,9 @@ +namespace FreeCode.Tools; + +public class TerminalCaptureToolInput +{ + public string? TaskId { get; set; } + public string? FilePath { get; set; } + public string? OutputPath { get; set; } + public bool IncludeEnvironment { get; set; } = true; +} diff --git a/src/FreeCode.Tools/TerminalCaptureToolOutput.cs b/src/FreeCode.Tools/TerminalCaptureToolOutput.cs new file mode 100644 index 0000000..22edd94 --- /dev/null +++ b/src/FreeCode.Tools/TerminalCaptureToolOutput.cs @@ -0,0 +1,10 @@ +namespace FreeCode.Tools; + +public class TerminalCaptureToolOutput +{ + public string Source { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public string? SavedPath { get; set; } + public string TerminalProgram { get; set; } = string.Empty; + public string CapturedAtUtc { get; set; } = string.Empty; +} diff --git a/src/FreeCode.Tools/TodoWriteTool.cs b/src/FreeCode.Tools/TodoWriteTool.cs new file mode 100644 index 0000000..69187ff --- /dev/null +++ b/src/FreeCode.Tools/TodoWriteTool.cs @@ -0,0 +1,145 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class TodoWriteTool : ToolBase, ITool +{ + private static readonly string TodoDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".free-code"); + private static readonly string TodoFilePath = Path.Combine(TodoDirectory, "todos.json"); + private static readonly object Lock = new(); + + public override string Name => "TodoWrite"; + public override ToolCategory Category => ToolCategory.Todo; + + public async Task> ExecuteAsync(TodoWriteToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var action = input.Action.ToLowerInvariant(); + var todos = await LoadTodosAsync(ct).ConfigureAwait(false); + + if (action == "list") + { + return new ToolResult(new TodoWriteToolOutput(todos, "list")); + } + + if (action == "add" && input.Todos is { Count: > 0 }) + { + foreach (var item in input.Todos) + { + var existing = todos.FindIndex(t => string.Equals(t.Id, item.Id, StringComparison.OrdinalIgnoreCase)); + if (existing >= 0) + { + todos[existing] = item; + } + else + { + todos.Add(item with { Id = item.Id ?? Guid.NewGuid().ToString("N")[..8] }); + } + } + + await SaveTodosAsync(todos, ct).ConfigureAwait(false); + return new ToolResult(new TodoWriteToolOutput(todos, "add")); + } + + if (action == "complete" && input.Todos is { Count: > 0 }) + { + foreach (var item in input.Todos) + { + var existing = todos.FindIndex(t => string.Equals(t.Id, item.Id, StringComparison.OrdinalIgnoreCase)); + if (existing >= 0) + { + todos[existing] = todos[existing] with { IsCompleted = true }; + } + } + + await SaveTodosAsync(todos, ct).ConfigureAwait(false); + return new ToolResult(new TodoWriteToolOutput(todos, "complete")); + } + + if (action == "clear") + { + todos.Clear(); + await SaveTodosAsync(todos, ct).ConfigureAwait(false); + return new ToolResult(new TodoWriteToolOutput(todos, "clear")); + } + + if (action == "replace" && input.Todos is not null) + { + todos.Clear(); + todos.AddRange(input.Todos.Select(t => t with { Id = t.Id ?? Guid.NewGuid().ToString("N")[..8] })); + await SaveTodosAsync(todos, ct).ConfigureAwait(false); + return new ToolResult(new TodoWriteToolOutput(todos, "replace")); + } + + return new ToolResult(new TodoWriteToolOutput(todos, action), true, $"Unknown action: {input.Action}. Use 'list', 'add', 'complete', 'clear', or 'replace'."); + } + catch (Exception ex) + { + return new ToolResult(new TodoWriteToolOutput([], input.Action), true, ex.Message); + } + } + + public Task ValidateInputAsync(TodoWriteToolInput input) + => Task.FromResult(string.IsNullOrWhiteSpace(input.Action) ? ValidationResult.Failure(new[] { "Action is required." }) : ValidationResult.Success()); + + public Task CheckPermissionAsync(TodoWriteToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => false; + + public override bool IsReadOnly(object input) + { + if (input is TodoWriteToolInput todoInput) + { + return string.Equals(todoInput.Action, "list", StringComparison.OrdinalIgnoreCase); + } + + return false; + } + + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Action\":{\"type\":\"string\",\"enum\":[\"list\",\"add\",\"complete\",\"clear\",\"replace\"]},\"Todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"Id\":{\"type\":\"string\"},\"Content\":{\"type\":\"string\"},\"IsCompleted\":{\"type\":\"boolean\"},\"Priority\":{\"type\":\"string\"}}}}},\"required\":[\"Action\"]}").RootElement.Clone(); + + private static async Task> LoadTodosAsync(CancellationToken ct) + { + if (!File.Exists(TodoFilePath)) + { + return new List(); + } + + var json = await File.ReadAllTextAsync(TodoFilePath, ct).ConfigureAwait(false); + return JsonSerializer.Deserialize(json, SourceGenerationContext.Default.ListTodoItem) ?? new List(); + } + + private static async Task SaveTodosAsync(List todos, CancellationToken ct) + { + Directory.CreateDirectory(TodoDirectory); + var json = JsonSerializer.Serialize(todos, SourceGenerationContext.Default.ListTodoItem); + await File.WriteAllTextAsync(TodoFilePath, json, ct).ConfigureAwait(false); + } +} + +public sealed class TodoWriteToolInput +{ + public TodoWriteToolInput(string action, IReadOnlyList? todos = null) + { + Action = action; + Todos = todos; + } + + public string Action { get; } + public IReadOnlyList? Todos { get; } +} + +public sealed class TodoWriteToolOutput +{ + public TodoWriteToolOutput(IReadOnlyList todos, string action) + { + Todos = todos; + Action = action; + } + + public IReadOnlyList Todos { get; } + public string Action { get; } +} diff --git a/src/FreeCode.Tools/ToolBase.cs b/src/FreeCode.Tools/ToolBase.cs new file mode 100644 index 0000000..19b451b --- /dev/null +++ b/src/FreeCode.Tools/ToolBase.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; + +namespace FreeCode.Tools; + +public abstract class ToolBase : ITool +{ + private static readonly JsonElement EmptySchema = JsonDocument.Parse("{}").RootElement.Clone(); + + public abstract string Name { get; } + public virtual string[]? Aliases => null; + public virtual string? SearchHint => null; + public abstract ToolCategory Category { get; } + public abstract bool IsConcurrencySafe(object input); + public abstract bool IsReadOnly(object input); + public virtual bool IsEnabled() => true; + public virtual JsonElement GetInputSchema() => EmptySchema; + public virtual Task GetDescriptionAsync(object? input = null) => Task.FromResult($"Execute {Name}"); +} diff --git a/src/FreeCode.Tools/ToolRegistry.cs b/src/FreeCode.Tools/ToolRegistry.cs new file mode 100644 index 0000000..a7cf490 --- /dev/null +++ b/src/FreeCode.Tools/ToolRegistry.cs @@ -0,0 +1,365 @@ +using System.Text; +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using FreeCode.Mcp; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode.Tools; + +public sealed class ToolRegistry : IToolRegistry +{ + private readonly IServiceProvider _services; + private readonly IFeatureFlagService _features; + private readonly IMcpClientManager _mcpManager; + private readonly IAppStateStore _stateStore; + private readonly object _gate = new(); + private IReadOnlyList? _cache; + + public ToolRegistry(IServiceProvider services, IFeatureFlagService featureFlagService, IMcpClientManager mcpManager, IAppStateStore stateStore) + { + _services = services; + _features = featureFlagService; + _mcpManager = mcpManager; + _stateStore = stateStore; + } + + public async Task> GetToolsAsync(ToolPermissionContext? permissionContext = null) + { + var baseTools = GetBaseTools(); + var tools = new List(baseTools); + var mcpTools = await _mcpManager.GetToolsAsync().ConfigureAwait(false); + var baseNames = tools.Select(tool => tool.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var tool in mcpTools) + { + if (baseNames.Contains(tool.Name) || !tool.IsEnabled()) + { + continue; + } + + tools.Add(tool); + } + + if (permissionContext is not null) + { + tools = tools.Where(tool => IsAllowed(tool, permissionContext)).ToList(); + } + + return tools.OrderBy(tool => tool.Name, StringComparer.Ordinal).ToArray(); + } + + public IReadOnlyList GetBaseTools() + { + if (_cache is not null) + { + return _cache; + } + + lock (_gate) + { + if (_cache is null) + { + var tools = new List + { + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired(), + ResolveRequired() + }; + + if (_features.IsEnabled("ENABLE_LSP_TOOL")) + { + tools.Add(ResolveRequired()); + } + + if (_features.IsEnabled("NOTEBOOK_EDIT_TOOL")) + { + tools.Add(ResolveRequired()); + } + + if (_features.IsEnabled("AGENT_TRIGGERS")) + { + tools.Add(ResolveRequired()); + tools.Add(ResolveRequired()); + } + + _cache = tools + .Where(tool => tool.IsEnabled()) + .OrderBy(tool => tool.Name, StringComparer.Ordinal) + .ToArray(); + } + } + + return _cache; + } + + public async Task<(string Output, bool IsAllowed, bool ShouldContinue)> ExecuteToolAsync(string toolName, JsonElement input, IPermissionEngine permissionEngine, ToolPermissionContext? permissionContext, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(toolName); + + var tools = await GetToolsAsync(permissionContext).ConfigureAwait(false); + var tool = tools.FirstOrDefault(candidate => string.Equals(candidate.Name, toolName, StringComparison.OrdinalIgnoreCase)); + if (tool is null) + { + return ($"Tool not found: {toolName}", true, false); + } + + var executionContext = BuildExecutionContext(tool, input); + var permission = await permissionEngine.CheckAsync(tool.Name, input, executionContext).ConfigureAwait(false); + if (!permission.IsAllowed) + { + return (permission.Reason ?? $"Permission denied for tool '{tool.Name}'.", false, false); + } + + if (tool is not ToolBase) + { + var mcpOutput = await ExecuteMcpToolAsync(tool, input, ct).ConfigureAwait(false); + return (mcpOutput, true, true); + } + + return tool.Name switch + { + "Agent" => await ExecuteAsync((AgentTool)tool, input, executionContext, ct).ConfigureAwait(false), + "AskUserQuestion" => await ExecuteAsync((AskUserQuestionTool)tool, input, executionContext, ct).ConfigureAwait(false), + "Bash" => await ExecuteAsync((BashTool)tool, input, executionContext, ct).ConfigureAwait(false), + "Brief" => await ExecuteAsync((BriefTool)tool, input, executionContext, ct).ConfigureAwait(false), + "Config" => await ExecuteAsync((ConfigTool)tool, input, executionContext, ct).ConfigureAwait(false), + "Cron" => await ExecuteAsync((CronTool)tool, input, executionContext, ct).ConfigureAwait(false), + "CronCreate" => await ExecuteAsync((CronCreateTool)tool, input, executionContext, ct).ConfigureAwait(false), + "CronDelete" => await ExecuteAsync((CronDeleteTool)tool, input, executionContext, ct).ConfigureAwait(false), + "CronList" => await ExecuteAsync((CronListTool)tool, input, executionContext, ct).ConfigureAwait(false), + "DiscoverSkills" => await ExecuteAsync((DiscoverSkillsTool)tool, input, executionContext, ct).ConfigureAwait(false), + "Edit" => await ExecuteAsync((FileEditTool)tool, input, executionContext, ct).ConfigureAwait(false), + "EnterPlanMode" => await ExecuteAsync((EnterPlanModeTool)tool, input, executionContext, ct).ConfigureAwait(false), + "EnterWorktree" => await ExecuteAsync((EnterWorktreeTool)tool, input, executionContext, ct).ConfigureAwait(false), + "ExitPlanMode" => await ExecuteAsync((ExitPlanModeTool)tool, input, executionContext, ct).ConfigureAwait(false), + "ExitWorktree" => await ExecuteAsync((ExitWorktreeTool)tool, input, executionContext, ct).ConfigureAwait(false), + "Glob" => await ExecuteAsync((GlobTool)tool, input, executionContext, ct).ConfigureAwait(false), + "Grep" => await ExecuteAsync((GrepTool)tool, input, executionContext, ct).ConfigureAwait(false), + "ListMcpResources" => await ExecuteAsync((ListMcpResourcesTool)tool, input, executionContext, ct).ConfigureAwait(false), + "Lsp" => await ExecuteAsync((LspTool)tool, input, executionContext, ct).ConfigureAwait(false), + "MCP" => await ExecuteAsync((MCPTool)tool, input, executionContext, ct).ConfigureAwait(false), + "McpAuth" => await ExecuteAsync((McpAuthTool)tool, input, executionContext, ct).ConfigureAwait(false), + "Monitor" => await ExecuteAsync((MonitorTool)tool, input, executionContext, ct).ConfigureAwait(false), + "NotebookEdit" => await ExecuteAsync((NotebookEditTool)tool, input, executionContext, ct).ConfigureAwait(false), + "PowerShell" => await ExecuteAsync((PowerShellTool)tool, input, executionContext, ct).ConfigureAwait(false), + "Read" => await ExecuteAsync((FileReadTool)tool, input, executionContext, ct).ConfigureAwait(false), + "ReadMcpResource" => await ExecuteAsync((ReadMcpResourceTool)tool, input, executionContext, ct).ConfigureAwait(false), + "RemoteTrigger" => await ExecuteAsync((RemoteTriggerTool)tool, input, executionContext, ct).ConfigureAwait(false), + "SendMessage" => await ExecuteAsync((SendMessageTool)tool, input, executionContext, ct).ConfigureAwait(false), + "SendUserFile" => await ExecuteAsync((SendUserFileTool)tool, input, executionContext, ct).ConfigureAwait(false), + "Skill" => await ExecuteAsync((SkillTool)tool, input, executionContext, ct).ConfigureAwait(false), + "Sleep" => await ExecuteAsync((SleepTool)tool, input, executionContext, ct).ConfigureAwait(false), + "Snip" => await ExecuteAsync((SnipTool)tool, input, executionContext, ct).ConfigureAwait(false), + "TaskCreate" => await ExecuteAsync((TaskCreateTool)tool, input, executionContext, ct).ConfigureAwait(false), + "TaskGet" => await ExecuteAsync((TaskGetTool)tool, input, executionContext, ct).ConfigureAwait(false), + "TaskList" => await ExecuteAsync((TaskListTool)tool, input, executionContext, ct).ConfigureAwait(false), + "TaskOutput" => await ExecuteAsync((TaskOutputTool)tool, input, executionContext, ct).ConfigureAwait(false), + "TaskStop" => await ExecuteAsync((TaskStopTool)tool, input, executionContext, ct).ConfigureAwait(false), + "TaskUpdate" => await ExecuteAsync((TaskUpdateTool)tool, input, executionContext, ct).ConfigureAwait(false), + "TerminalCapture" => await ExecuteAsync((TerminalCaptureTool)tool, input, executionContext, ct).ConfigureAwait(false), + "TeamCreate" => await ExecuteAsync((TeamCreateTool)tool, input, executionContext, ct).ConfigureAwait(false), + "TeamDelete" => await ExecuteAsync((TeamDeleteTool)tool, input, executionContext, ct).ConfigureAwait(false), + "TodoWrite" => await ExecuteAsync((TodoWriteTool)tool, input, executionContext, ct).ConfigureAwait(false), + "ToolSearch" => await ExecuteAsync((ToolSearchTool)tool, input, executionContext, ct).ConfigureAwait(false), + "VerifyPlanExecution" => await ExecuteAsync((VerifyPlanExecutionTool)tool, input, executionContext, ct).ConfigureAwait(false), + "WebBrowser" => await ExecuteAsync((WebBrowserTool)tool, input, executionContext, ct).ConfigureAwait(false), + "WebFetch" => await ExecuteAsync((WebFetchTool)tool, input, executionContext, ct).ConfigureAwait(false), + "WebSearch" => await ExecuteAsync((WebSearchTool)tool, input, executionContext, ct).ConfigureAwait(false), + "Write" => await ExecuteAsync((FileWriteTool)tool, input, executionContext, ct).ConfigureAwait(false), + _ => ($"Unsupported tool execution for {tool.Name}", true, false) + }; + } + + private async Task<(string Output, bool IsAllowed, bool ShouldContinue)> ExecuteAsync(ITool tool, JsonElement input, FreeCode.Core.Models.ToolExecutionContext executionContext, CancellationToken ct) + where TInput : class + { + var typedInput = DeserializeInput(input); + var validation = await tool.ValidateInputAsync(typedInput).ConfigureAwait(false); + if (!validation.IsValid) + { + return (string.Join(Environment.NewLine, validation.Errors), true, false); + } + + var permission = await tool.CheckPermissionAsync(typedInput, executionContext).ConfigureAwait(false); + if (!permission.IsAllowed) + { + return (permission.Reason ?? $"Permission denied for tool '{tool.Name}'.", false, false); + } + + var result = await tool.ExecuteAsync(typedInput, executionContext, ct).ConfigureAwait(false); + var output = FormatToolResult(result); + return result.IsError ? (output, true, false) : (output, true, true); + } + + private async Task ExecuteMcpToolAsync(ITool tool, JsonElement input, CancellationToken ct) + { +#pragma warning disable IL2026, IL3050 + if (!tool.Name.StartsWith("mcp__", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Unsupported external tool: {tool.Name}"); + } + + var remainder = tool.Name["mcp__".Length..]; + var separatorIndex = remainder.IndexOf("__", StringComparison.Ordinal); + if (separatorIndex <= 0 || separatorIndex >= remainder.Length - 2) + { + throw new InvalidOperationException($"Invalid MCP tool name: {tool.Name}"); + } + + var serverToken = remainder[..separatorIndex]; + var innerToolName = remainder[(separatorIndex + 2)..]; + var connection = _mcpManager.GetConnections() + .OfType() + .FirstOrDefault(candidate => Sanitize(candidate.Name) == serverToken); + + if (connection?.Client is not McpClient client) + { + throw new InvalidOperationException($"Connected MCP server not found for tool: {tool.Name}"); + } + + object? parameters = input.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined + ? null + : JsonSerializer.Deserialize(input.GetRawText(), JsonSerializerOptions.Web); + + var result = await client.CallToolAsync(innerToolName, parameters, ct).ConfigureAwait(false); + return FormatValue(result.Content); +#pragma warning restore IL2026, IL3050 + } + + private FreeCode.Core.Models.ToolExecutionContext BuildExecutionContext(ITool tool, object input) + { + var workingDirectory = Environment.CurrentDirectory; + var permissionMode = ResolvePermissionMode(_stateStore.GetState()); + var scopedServices = new ToolExecutionServiceProvider(_services, tool); + + return new FreeCode.Core.Models.ToolExecutionContext( + WorkingDirectory: workingDirectory, + PermissionMode: permissionMode, + AdditionalWorkingDirectories: [], + PermissionEngine: _services.GetRequiredService(), + LspManager: _services.GetRequiredService(), + TaskManager: _services.GetRequiredService(), + Services: scopedServices); + } + + private static PermissionMode ResolvePermissionMode(object appState) + { + ArgumentNullException.ThrowIfNull(appState); + + return appState.GetType().GetProperty("PermissionMode")?.GetValue(appState) is PermissionMode permissionMode + ? permissionMode + : PermissionMode.Default; + } + + private sealed class ToolExecutionServiceProvider(IServiceProvider inner, ITool tool) : IServiceProvider + { + public object? GetService(Type serviceType) + => serviceType == typeof(ITool) ? tool : inner.GetService(serviceType); + } + + private static TInput DeserializeInput(JsonElement input) where TInput : class + { +#pragma warning disable IL2026, IL3050 + if (input.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + throw new InvalidOperationException($"Tool input for '{typeof(TInput).Name}' was null."); + } + + var value = JsonSerializer.Deserialize(input.GetRawText(), JsonSerializerOptions.Web); + return value ?? throw new InvalidOperationException($"Unable to deserialize tool input for '{typeof(TInput).Name}'."); +#pragma warning restore IL2026, IL3050 + } + + private static string FormatToolResult(ToolResult result) + { + var builder = new StringBuilder(); + if (!string.IsNullOrWhiteSpace(result.ErrorMessage)) + { + builder.AppendLine(result.ErrorMessage); + } + + builder.Append(FormatValue(result.Data)); + + if (result.SideMessages is { Count: > 0 }) + { + foreach (var sideMessage in result.SideMessages) + { + builder.AppendLine(); + builder.Append(FormatValue(sideMessage.Content)); + } + } + + return builder.ToString().Trim(); + } + + private static string FormatValue(object? value) + { +#pragma warning disable IL2026, IL3050 + return value switch + { + null => string.Empty, + string text => text, + JsonElement jsonElement => jsonElement.ToString(), + _ => JsonSerializer.Serialize(value, JsonSerializerOptions.Web) + }; +#pragma warning restore IL2026, IL3050 + } + + private static bool IsAllowed(ITool tool, ToolPermissionContext context) + { + if (context.AllowedTools.Count > 0 && !context.AllowedTools.Contains(tool.Name)) + { + return false; + } + + return !context.DeniedTools.Contains(tool.Name); + } + + private T ResolveRequired() where T : notnull => _services.GetRequiredService(); + + private static string Sanitize(string value) + => new(value.ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : '_').ToArray()); +} diff --git a/src/FreeCode.Tools/ToolSearchTool.cs b/src/FreeCode.Tools/ToolSearchTool.cs new file mode 100644 index 0000000..d840fb4 --- /dev/null +++ b/src/FreeCode.Tools/ToolSearchTool.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class ToolSearchTool : ToolBase, ITool +{ + public override string Name => "ToolSearch"; + public override ToolCategory Category => ToolCategory.Mcp; + + public async Task> ExecuteAsync(ToolSearchToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + if (context.Services.GetService(typeof(IToolRegistry)) is not IToolRegistry registry) + { + return new ToolResult(new ToolSearchToolOutput([], 0), true, "Tool registry is unavailable."); + } + + var tools = await registry.GetToolsAsync().ConfigureAwait(false); + var query = input.Query.ToLowerInvariant(); + var categoryFilter = input.Category; + + var matches = new List(); + foreach (var tool in tools) + { + ct.ThrowIfCancellationRequested(); + + if (categoryFilter is not null && !string.Equals(tool.Category.ToString(), categoryFilter, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var name = tool.Name.ToLowerInvariant(); + var description = (await tool.GetDescriptionAsync().ConfigureAwait(false)).ToLowerInvariant(); + var searchHint = tool.SearchHint?.ToLowerInvariant() ?? string.Empty; + + if (name.Contains(query, StringComparison.Ordinal) || description.Contains(query, StringComparison.Ordinal) || searchHint.Contains(query, StringComparison.Ordinal)) + { + matches.Add(new ToolInfoEntry(tool.Name, await tool.GetDescriptionAsync().ConfigureAwait(false), tool.Category.ToString())); + } + } + + return new ToolResult(new ToolSearchToolOutput(matches, matches.Count)); + } + catch (Exception ex) + { + return new ToolResult(new ToolSearchToolOutput([], 0), true, ex.Message); + } + } + + public Task ValidateInputAsync(ToolSearchToolInput input) + => Task.FromResult(string.IsNullOrWhiteSpace(input.Query) ? ValidationResult.Failure(new[] { "Query is required." }) : ValidationResult.Success()); + + public Task CheckPermissionAsync(ToolSearchToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => true; + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Query\":{\"type\":\"string\"},\"Category\":{\"type\":\"string\"}},\"required\":[\"Query\"]}").RootElement.Clone(); +} + +public sealed class ToolSearchToolInput +{ + public ToolSearchToolInput(string query, string? category = null) + { + Query = query; + Category = category; + } + + public string Query { get; } + public string? Category { get; } +} + +public sealed class ToolSearchToolOutput +{ + public ToolSearchToolOutput(IReadOnlyList tools, int totalCount) + { + Tools = tools; + TotalCount = totalCount; + } + + public IReadOnlyList Tools { get; } + public int TotalCount { get; } +} + +public sealed record ToolInfoEntry(string Name, string Description, string Category); diff --git a/src/FreeCode.Tools/ToolUtilities.cs b/src/FreeCode.Tools/ToolUtilities.cs new file mode 100644 index 0000000..a3f87c9 --- /dev/null +++ b/src/FreeCode.Tools/ToolUtilities.cs @@ -0,0 +1,86 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.Text.RegularExpressions; + +namespace FreeCode.Tools; + +internal static class ToolUtilities +{ + public static string ResolvePath(string path, string workingDirectory) + => Path.IsPathRooted(path) ? path : Path.GetFullPath(Path.Combine(workingDirectory, path)); + + public static string EnsureDirectory(string path) + { + Directory.CreateDirectory(path); + return path; + } + + public static Regex GlobToRegex(string pattern) + { + var builder = new StringBuilder("^"); + for (var i = 0; i < pattern.Length; i++) + { + var c = pattern[i]; + switch (c) + { + case '*': + if (i + 1 < pattern.Length && pattern[i + 1] == '*') + { + builder.Append(".*"); + i++; + } + else + { + builder.Append("[^/\\\\]*"); + } + break; + case '?': + builder.Append("."); + break; + case '.': case '+': case '(': case ')': case '$': case '^': case '|': case '{': case '}': case '[': case ']': case '\\': + builder.Append('\\').Append(c); + break; + default: + if (c == '/' || c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar) + { + builder.Append("[/\\\\]"); + } + else + { + builder.Append(c); + } + break; + } + } + + builder.Append('$'); + return new Regex(builder.ToString(), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + } + + public static bool MatchesGlob(string text, string pattern) + => GlobToRegex(pattern).IsMatch(text.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/')); + + public static async Task ReadJsonAsync(string path, CancellationToken ct = default) + { + if (!File.Exists(path)) + { + return default; + } + + await using var stream = File.OpenRead(path); + var typeInfo = ResolveTypeInfo(); + return await JsonSerializer.DeserializeAsync(stream, typeInfo, ct).ConfigureAwait(false) is T value ? value : default; + } + + public static async Task WriteJsonAsync(string path, T value, CancellationToken ct = default) + { + EnsureDirectory(Path.GetDirectoryName(path) ?? Directory.GetCurrentDirectory()); + await using var stream = File.Create(path); + await JsonSerializer.SerializeAsync(stream, value, ResolveTypeInfo(), ct).ConfigureAwait(false); + } + + private static JsonTypeInfo ResolveTypeInfo() + => (JsonTypeInfo?)SourceGenerationContext.Default.GetTypeInfo(typeof(T)) + ?? throw new InvalidOperationException($"No JSON source-generation metadata found for '{typeof(T)}'."); +} diff --git a/src/FreeCode.Tools/VerifyPlanExecutionTool.cs b/src/FreeCode.Tools/VerifyPlanExecutionTool.cs new file mode 100644 index 0000000..1ff2b8a --- /dev/null +++ b/src/FreeCode.Tools/VerifyPlanExecutionTool.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class VerifyPlanExecutionTool : ToolBase, ITool +{ + public override string Name => "VerifyPlanExecution"; + public override ToolCategory Category => ToolCategory.PlanMode; + + public Task> ExecuteAsync(VerifyPlanExecutionToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var issues = new List(); + + if (input.Completed is false) + { + issues.Add("The plan was explicitly marked as incomplete."); + } + + if (string.IsNullOrWhiteSpace(input.ActualOutput)) + { + issues.Add("ActualOutput is required to verify execution."); + } + + if (!string.IsNullOrWhiteSpace(input.ExpectedOutput) + && !ContainsNormalized(input.ActualOutput, input.ExpectedOutput)) + { + issues.Add("Actual output does not contain the expected output."); + } + + if (input.RequiredChecks is not null) + { + foreach (var check in input.RequiredChecks.Where(value => !string.IsNullOrWhiteSpace(value))) + { + if (!ContainsNormalized(input.ActualOutput, check)) + { + issues.Add($"Missing required check: {check}"); + } + } + } + + var completed = input.Completed ?? !string.IsNullOrWhiteSpace(input.ActualOutput); + var verified = completed && issues.Count == 0; + var message = verified + ? "Plan execution verified successfully." + : issues.Count == 0 + ? "Plan execution could not be verified." + : string.Join(" ", issues); + + var output = new VerifyPlanExecutionToolOutput(verified, completed, message, issues); + return Task.FromResult(new ToolResult(output)); + } + catch (Exception ex) + { + return Task.FromResult(new ToolResult(new VerifyPlanExecutionToolOutput(false, false, ex.Message, new[] { ex.Message }), true, ex.Message)); + } + } + + public Task ValidateInputAsync(VerifyPlanExecutionToolInput input) + { + return Task.FromResult(string.IsNullOrWhiteSpace(input.Plan) + ? ValidationResult.Failure(new[] { "Plan is required." }) + : ValidationResult.Success()); + } + + public Task CheckPermissionAsync(VerifyPlanExecutionToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => true; + + public override JsonElement GetInputSchema() + { + return JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Plan\":{\"type\":\"string\"},\"ActualOutput\":{\"type\":\"string\"},\"ExpectedOutput\":{\"type\":\"string\"},\"Completed\":{\"type\":\"boolean\"},\"RequiredChecks\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"Plan\"]}").RootElement.Clone(); + } + + public override Task GetDescriptionAsync(object? input = null) + => Task.FromResult("Verify whether a planned task completed successfully and whether the output matches expectations."); + + private static bool ContainsNormalized(string? source, string? value) + { + if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(value)) + { + return false; + } + + return Normalize(source).Contains(Normalize(value), StringComparison.OrdinalIgnoreCase); + } + + private static string Normalize(string value) + { + return string.Join(' ', value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + } +} diff --git a/src/FreeCode.Tools/VerifyPlanExecutionToolInput.cs b/src/FreeCode.Tools/VerifyPlanExecutionToolInput.cs new file mode 100644 index 0000000..da4fb3b --- /dev/null +++ b/src/FreeCode.Tools/VerifyPlanExecutionToolInput.cs @@ -0,0 +1,19 @@ +namespace FreeCode.Tools; + +public sealed class VerifyPlanExecutionToolInput +{ + public VerifyPlanExecutionToolInput(string plan, string? actualOutput = null, string? expectedOutput = null, bool? completed = null, string[]? requiredChecks = null) + { + Plan = plan; + ActualOutput = actualOutput; + ExpectedOutput = expectedOutput; + Completed = completed; + RequiredChecks = requiredChecks; + } + + public string Plan { get; } + public string? ActualOutput { get; } + public string? ExpectedOutput { get; } + public bool? Completed { get; } + public string[]? RequiredChecks { get; } +} diff --git a/src/FreeCode.Tools/VerifyPlanExecutionToolOutput.cs b/src/FreeCode.Tools/VerifyPlanExecutionToolOutput.cs new file mode 100644 index 0000000..c9c699a --- /dev/null +++ b/src/FreeCode.Tools/VerifyPlanExecutionToolOutput.cs @@ -0,0 +1,17 @@ +namespace FreeCode.Tools; + +public sealed class VerifyPlanExecutionToolOutput +{ + public VerifyPlanExecutionToolOutput(bool verified, bool completed, string message, IReadOnlyList issues) + { + Verified = verified; + Completed = completed; + Message = message; + Issues = issues; + } + + public bool Verified { get; } + public bool Completed { get; } + public string Message { get; } + public IReadOnlyList Issues { get; } +} diff --git a/src/FreeCode.Tools/WebBrowserTool.cs b/src/FreeCode.Tools/WebBrowserTool.cs new file mode 100644 index 0000000..0aee448 --- /dev/null +++ b/src/FreeCode.Tools/WebBrowserTool.cs @@ -0,0 +1,240 @@ +using System.Diagnostics; +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class WebBrowserTool : ToolBase, ITool +{ + private static readonly HashSet SupportedActions = new(StringComparer.OrdinalIgnoreCase) + { + "open", + "navigate", + "screenshot", + "close" + }; + + public override string Name => "WebBrowser"; + + public override string[]? Aliases => ["Browser"]; + + public override ToolCategory Category => ToolCategory.Web; + + public override bool IsConcurrencySafe(object input) => false; + + public override bool IsReadOnly(object input) + { + var action = input is WebBrowserToolInput toolInput ? toolInput.Action : input as string; + return string.Equals(action, "screenshot", StringComparison.OrdinalIgnoreCase); + } + + public override bool IsEnabled() => true; + + public override JsonElement GetInputSchema() + { + return JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Action\":{\"type\":\"string\"},\"Url\":{\"type\":\"string\"},\"OutputPath\":{\"type\":\"string\"},\"Selector\":{\"type\":\"string\"},\"Text\":{\"type\":\"string\"},\"Script\":{\"type\":\"string\"},\"NewWindow\":{\"type\":\"boolean\"}},\"required\":[\"Action\"]}").RootElement.Clone(); + } + + public override Task GetDescriptionAsync(object? input = null) + { + if (input is WebBrowserToolInput webInput) + { + var target = string.IsNullOrWhiteSpace(webInput.Url) ? "browser" : webInput.Url; + return Task.FromResult($"Web browser action {webInput.Action} for {target}"); + } + + return Task.FromResult("Open a browser, navigate URLs, and capture screenshots."); + } + + public Task ValidateInputAsync(WebBrowserToolInput input) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(input.Action) || !SupportedActions.Contains(input.Action)) + { + errors.Add("Action must be one of: open, navigate, screenshot, close."); + } + + if ((string.Equals(input.Action, "open", StringComparison.OrdinalIgnoreCase) || string.Equals(input.Action, "navigate", StringComparison.OrdinalIgnoreCase)) + && string.IsNullOrWhiteSpace(input.Url)) + { + errors.Add("Url is required for open and navigate actions."); + } + + if (string.Equals(input.Action, "screenshot", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(input.OutputPath)) + { + errors.Add("OutputPath is required for screenshot action."); + } + + return Task.FromResult(errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors)); + } + + public Task CheckPermissionAsync(WebBrowserToolInput input, ToolExecutionContext context) + => Task.FromResult(PermissionResult.Allowed()); + + public async Task> ExecuteAsync(WebBrowserToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + if (string.Equals(input.Action, "open", StringComparison.OrdinalIgnoreCase) || string.Equals(input.Action, "navigate", StringComparison.OrdinalIgnoreCase)) + { + OpenUrl(input.Url, input.NewWindow); + return new ToolResult(new WebBrowserToolOutput + { + Success = true, + Action = input.Action, + Url = input.Url, + Message = $"Opened {input.Url} in the system browser." + }); + } + + if (string.Equals(input.Action, "screenshot", StringComparison.OrdinalIgnoreCase)) + { + var outputPath = ToolUtilities.ResolvePath(input.OutputPath ?? "browser-screenshot.png", context.WorkingDirectory); + var captureResult = await CaptureScreenshotAsync(outputPath, ct).ConfigureAwait(false); + if (!captureResult.Success) + { + return new ToolResult(captureResult, true, captureResult.Message); + } + + return new ToolResult(captureResult); + } + + if (string.Equals(input.Action, "close", StringComparison.OrdinalIgnoreCase)) + { + return new ToolResult(new WebBrowserToolOutput + { + Success = true, + Action = input.Action, + Url = input.Url, + Message = "Browser session closed.", + OutputPath = input.OutputPath + }); + } + + var unsupportedMessage = $"Browser action '{input.Action}' is not supported by the current .NET runtime integration."; + return new ToolResult(new WebBrowserToolOutput + { + Success = false, + Action = input.Action, + Url = input.Url, + Message = unsupportedMessage, + OutputPath = input.OutputPath + }, true, unsupportedMessage); + } + catch (Exception ex) + { + return new ToolResult(new WebBrowserToolOutput + { + Success = false, + Action = input.Action, + Url = input.Url, + Message = ex.Message, + OutputPath = input.OutputPath + }, true, ex.Message); + } + } + + private static void OpenUrl(string url, bool newWindow) + { + var startInfo = new ProcessStartInfo + { + UseShellExecute = false, + RedirectStandardOutput = false, + RedirectStandardError = false, + CreateNoWindow = true + }; + + if (OperatingSystem.IsWindows()) + { + startInfo.FileName = "cmd"; + startInfo.ArgumentList.Add("/c"); + startInfo.ArgumentList.Add("start"); + startInfo.ArgumentList.Add(""); + if (newWindow) + { + startInfo.ArgumentList.Add("/new"); + } + + startInfo.ArgumentList.Add(url); + } + else if (OperatingSystem.IsMacOS()) + { + startInfo.FileName = "open"; + if (newWindow) + { + startInfo.ArgumentList.Add("-n"); + } + + startInfo.ArgumentList.Add(url); + } + else + { + startInfo.FileName = "xdg-open"; + startInfo.ArgumentList.Add(url); + } + + using var process = Process.Start(startInfo); + } + + private static async Task CaptureScreenshotAsync(string outputPath, CancellationToken ct) + { + ToolUtilities.EnsureDirectory(Path.GetDirectoryName(outputPath) ?? Directory.GetCurrentDirectory()); + + if (OperatingSystem.IsMacOS()) + { + var startInfo = new ProcessStartInfo + { + FileName = "/usr/sbin/screencapture", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + startInfo.ArgumentList.Add("-x"); + startInfo.ArgumentList.Add(outputPath); + + using var process = new Process { StartInfo = startInfo }; + if (!process.Start()) + { + return new WebBrowserToolOutput + { + Success = false, + Action = "screenshot", + Message = "Failed to start screenshot process.", + OutputPath = outputPath + }; + } + + await process.WaitForExitAsync(ct).ConfigureAwait(false); + if (process.ExitCode != 0) + { + var error = await process.StandardError.ReadToEndAsync(ct).ConfigureAwait(false); + return new WebBrowserToolOutput + { + Success = false, + Action = "screenshot", + Message = string.IsNullOrWhiteSpace(error) ? "Screenshot capture failed." : error.Trim(), + OutputPath = outputPath + }; + } + + return new WebBrowserToolOutput + { + Success = true, + Action = "screenshot", + Message = $"Saved screenshot to {outputPath}.", + OutputPath = outputPath + }; + } + + return new WebBrowserToolOutput + { + Success = false, + Action = "screenshot", + Message = "Screenshot capture is only available on macOS in the current runtime.", + OutputPath = outputPath + }; + } +} diff --git a/src/FreeCode.Tools/WebBrowserToolInput.cs b/src/FreeCode.Tools/WebBrowserToolInput.cs new file mode 100644 index 0000000..c28dfb1 --- /dev/null +++ b/src/FreeCode.Tools/WebBrowserToolInput.cs @@ -0,0 +1,22 @@ +namespace FreeCode.Tools; + +public sealed class WebBrowserToolInput +{ + public WebBrowserToolInput() + { + } + + public string Action { get; set; } = "open"; + + public string Url { get; set; } = string.Empty; + + public string? OutputPath { get; set; } + + public string? Selector { get; set; } + + public string? Text { get; set; } + + public string? Script { get; set; } + + public bool NewWindow { get; set; } +} diff --git a/src/FreeCode.Tools/WebBrowserToolOutput.cs b/src/FreeCode.Tools/WebBrowserToolOutput.cs new file mode 100644 index 0000000..212d238 --- /dev/null +++ b/src/FreeCode.Tools/WebBrowserToolOutput.cs @@ -0,0 +1,18 @@ +namespace FreeCode.Tools; + +public sealed class WebBrowserToolOutput +{ + public WebBrowserToolOutput() + { + } + + public bool Success { get; set; } + + public string Action { get; set; } = string.Empty; + + public string Url { get; set; } = string.Empty; + + public string Message { get; set; } = string.Empty; + + public string? OutputPath { get; set; } +} diff --git a/src/FreeCode.Tools/WebFetchTool.cs b/src/FreeCode.Tools/WebFetchTool.cs new file mode 100644 index 0000000..cb54518 --- /dev/null +++ b/src/FreeCode.Tools/WebFetchTool.cs @@ -0,0 +1,75 @@ +using System.Net.Http; +using System.Text.Json; +using System.Text.RegularExpressions; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class WebFetchTool : ToolBase, ITool +{ + private static readonly HttpClient Http = new(); + + public override string Name => "WebFetch"; + public override ToolCategory Category => ToolCategory.Web; + + public async Task> ExecuteAsync(WebFetchToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, input.Url); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromMilliseconds(Math.Max(1, input.Timeout))); + using var response = await Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cts.Token).ConfigureAwait(false); + var contentType = response.Content.Headers.ContentType?.MediaType; + var content = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false); + if (string.Equals(input.Format, "text", StringComparison.OrdinalIgnoreCase) && contentType?.Contains("html", StringComparison.OrdinalIgnoreCase) == true) + { + content = Regex.Replace(content, "||<[^>]+>", string.Empty, RegexOptions.IgnoreCase); + } + + return new ToolResult(new WebFetchToolOutput(content, (int)response.StatusCode, contentType)); + } + catch (Exception ex) + { + return new ToolResult(new WebFetchToolOutput(string.Empty, 0, null), true, ex.Message); + } + } + + public Task ValidateInputAsync(WebFetchToolInput input) + => Task.FromResult(string.IsNullOrWhiteSpace(input.Url) ? ValidationResult.Failure(new[] { "Url is required." }) : ValidationResult.Success()); + + public Task CheckPermissionAsync(WebFetchToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => true; + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Url\":{\"type\":\"string\"},\"Format\":{\"type\":\"string\"},\"Timeout\":{\"type\":\"integer\"}},\"required\":[\"Url\"]}").RootElement.Clone(); +} + +public sealed class WebFetchToolInput +{ + public WebFetchToolInput(string url, string format = "text", int timeout = 30000) + { + Url = url; + Format = format; + Timeout = timeout; + } + + public string Url { get; } + public string Format { get; } + public int Timeout { get; } +} + +public sealed class WebFetchToolOutput +{ + public WebFetchToolOutput(string content, int statusCode, string? contentType) + { + Content = content; + StatusCode = statusCode; + ContentType = contentType; + } + + public string Content { get; } + public int StatusCode { get; } + public string? ContentType { get; } +} diff --git a/src/FreeCode.Tools/WebSearchTool.cs b/src/FreeCode.Tools/WebSearchTool.cs new file mode 100644 index 0000000..682f8bf --- /dev/null +++ b/src/FreeCode.Tools/WebSearchTool.cs @@ -0,0 +1,117 @@ +using System.Net.Http; +using System.Text.Json; +using System.Text.RegularExpressions; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; + +namespace FreeCode.Tools; + +public sealed class WebSearchTool : ToolBase, ITool +{ + private static readonly HttpClient Http = new(); + + public override string Name => "WebSearch"; + public override ToolCategory Category => ToolCategory.Web; + + public async Task> ExecuteAsync(WebSearchToolInput input, ToolExecutionContext context, CancellationToken ct = default) + { + try + { + var endpoint = Environment.GetEnvironmentVariable("FREECODE_WEB_SEARCH_URL"); + if (!string.IsNullOrWhiteSpace(endpoint)) + { + var url = endpoint.Replace("{query}", Uri.EscapeDataString(input.Query), StringComparison.OrdinalIgnoreCase); + var json = await Http.GetStringAsync(url, ct).ConfigureAwait(false); + return new ToolResult(new WebSearchToolOutput(ParseResults(json), ParseResults(json).Count)); + } + + var searchUrl = $"https://html.duckduckgo.com/html/?q={Uri.EscapeDataString(input.Query)}"; + var html = await Http.GetStringAsync(searchUrl, ct).ConfigureAwait(false); + var results = ParseDuckDuckGo(html, input.MaxResults); + return new ToolResult(new WebSearchToolOutput(results, results.Count)); + } + catch (Exception ex) + { + return new ToolResult(new WebSearchToolOutput(new List { new("Search unavailable", string.Empty, ex.Message) }, 1), true, ex.Message); + } + } + + public Task ValidateInputAsync(WebSearchToolInput input) + => Task.FromResult(string.IsNullOrWhiteSpace(input.Query) ? ValidationResult.Failure(new[] { "Query is required." }) : ValidationResult.Success()); + + public Task CheckPermissionAsync(WebSearchToolInput input, ToolExecutionContext context) => Task.FromResult(PermissionResult.Allowed()); + public override bool IsConcurrencySafe(object input) => true; + public override bool IsReadOnly(object input) => true; + public override JsonElement GetInputSchema() => JsonDocument.Parse("{\"type\":\"object\",\"properties\":{\"Query\":{\"type\":\"string\"},\"MaxResults\":{\"type\":\"integer\"}},\"required\":[\"Query\"]}").RootElement.Clone(); + + private static List ParseDuckDuckGo(string html, int maxResults) + { + var results = new List(); + var matches = Regex.Matches(html, "]+class=\"result__a\"[^>]+href=\"(?[^\"]+)\"[^>]*>(?.*?)</a>.*?(?<snippet><a[^>]+class=\"result__snippet\"[^>]*>|<div class=\"result__snippet\"[^>]*>)(?<text>.*?)</", RegexOptions.Singleline | RegexOptions.IgnoreCase); + foreach (Match match in matches) + { + var title = Regex.Replace(match.Groups["title"].Value, "<.*?>", string.Empty); + var url = match.Groups["url"].Value; + var snippet = Regex.Replace(match.Groups["text"].Value, "<.*?>", string.Empty); + results.Add(new SearchResult(title, url, System.Net.WebUtility.HtmlDecode(snippet))); + if (maxResults > 0 && results.Count >= maxResults) + { + break; + } + } + + return results; + } + + private static List<SearchResult> ParseResults(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + var results = new List<SearchResult>(); + if (doc.RootElement.ValueKind == JsonValueKind.Array) + { + foreach (var item in doc.RootElement.EnumerateArray()) + { + results.Add(new SearchResult( + item.TryGetProperty("title", out var title) ? title.GetString() ?? string.Empty : string.Empty, + item.TryGetProperty("url", out var url) ? url.GetString() ?? string.Empty : string.Empty, + item.TryGetProperty("snippet", out var snippet) ? snippet.GetString() ?? string.Empty : string.Empty)); + } + } + + return results; + } + catch (JsonException) + { + return []; + } + } +} + +public sealed class WebSearchToolInput +{ + public WebSearchToolInput(string query, int maxResults = 10) + { + Query = query; + MaxResults = maxResults; + } + + public string Query { get; } + public int MaxResults { get; } +} + +public sealed class WebSearchToolOutput +{ + public WebSearchToolOutput(IReadOnlyList<SearchResult> results, int totalResults) + { + Results = results; + TotalResults = totalResults; + } + + public IReadOnlyList<SearchResult> Results { get; } + public int TotalResults { get; } +} + +public sealed record SearchResult(string Title, string Url, string Snippet); diff --git a/src/FreeCode/BridgeMain.cs b/src/FreeCode/BridgeMain.cs new file mode 100644 index 0000000..dd4ef4f --- /dev/null +++ b/src/FreeCode/BridgeMain.cs @@ -0,0 +1,82 @@ +using FreeCode.Bridge; +using FreeCode.Core.Models; +using FreeCode.Core.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode; + +public static class BridgeMain +{ + public static async Task<int> RunAsync(IServiceProvider services, CancellationToken ct) + { + var bridgeService = services.GetRequiredService<IBridgeService>(); + + try + { + await bridgeService.RegisterEnvironmentAsync().ConfigureAwait(false); + Console.WriteLine("Bridge connected and registered."); + + if (bridgeService is BridgeService concreteBridgeService) + { + await concreteBridgeService.RunAsync(ct).ConfigureAwait(false); + } + else + { + while (!ct.IsCancellationRequested) + { + try + { + var work = await bridgeService.PollForWorkAsync(ct).ConfigureAwait(false); + + if (work is not null) + { + await HandleWorkAsync(bridgeService, work, ct).ConfigureAwait(false); + } + else + { + await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Bridge loop error: {ex.Message}"); + await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false); + } + } + } + + return 0; + } + catch (OperationCanceledException) + { + return 0; + } + finally + { + try + { + await bridgeService.DeregisterEnvironmentAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Failed to deregister bridge environment: {ex.Message}"); + } + } + + return 0; + } + + private static async Task HandleWorkAsync(IBridgeService bridge, WorkItem work, CancellationToken ct) + { + if (!string.IsNullOrWhiteSpace(work.SessionToken)) + { + await bridge.AcknowledgeWorkAsync(work.Id, work.SessionToken).ConfigureAwait(false); + await bridge.HeartbeatAsync(work.Id, work.SessionToken).ConfigureAwait(false); + return; + } + } +} diff --git a/src/FreeCode/FreeCode.csproj b/src/FreeCode/FreeCode.csproj new file mode 100644 index 0000000..a4b8932 --- /dev/null +++ b/src/FreeCode/FreeCode.csproj @@ -0,0 +1,29 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <OutputType>Exe</OutputType> + <RootNamespace>FreeCode</RootNamespace> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" /> + <ProjectReference Include="..\FreeCode.Core\FreeCode.Core.csproj" /> + <ProjectReference Include="..\FreeCode.Features\FreeCode.Features.csproj" /> + <ProjectReference Include="..\FreeCode.State\FreeCode.State.csproj" /> + <ProjectReference Include="..\FreeCode.Engine\FreeCode.Engine.csproj" /> + <ProjectReference Include="..\FreeCode.ApiProviders\FreeCode.ApiProviders.csproj" /> + <ProjectReference Include="..\FreeCode.Tools\FreeCode.Tools.csproj" /> + <ProjectReference Include="..\FreeCode.Commands\FreeCode.Commands.csproj" /> + <ProjectReference Include="..\FreeCode.Mcp\FreeCode.Mcp.csproj" /> + <ProjectReference Include="..\FreeCode.Lsp\FreeCode.Lsp.csproj" /> + <ProjectReference Include="..\FreeCode.Bridge\FreeCode.Bridge.csproj" /> + <ProjectReference Include="..\FreeCode.Services\FreeCode.Services.csproj" /> + <ProjectReference Include="..\FreeCode.Tasks\FreeCode.Tasks.csproj" /> + <ProjectReference Include="..\FreeCode.Skills\FreeCode.Skills.csproj" /> + <ProjectReference Include="..\FreeCode.Plugins\FreeCode.Plugins.csproj" /> + <ProjectReference Include="..\FreeCode.TerminalUI\FreeCode.TerminalUI.csproj" /> + </ItemGroup> + <ItemGroup> + <None Update="appsettings.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + </ItemGroup> +</Project> diff --git a/src/FreeCode/McpDaemon.cs b/src/FreeCode/McpDaemon.cs new file mode 100644 index 0000000..022e7a9 --- /dev/null +++ b/src/FreeCode/McpDaemon.cs @@ -0,0 +1,334 @@ +using System.Text; +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode; + +public static class McpDaemon +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public static async Task<int> RunAsync(IServiceProvider services, CancellationToken ct) + { + var mcpManager = services.GetRequiredService<IMcpClientManager>(); + await mcpManager.ConnectServersAsync(ct).ConfigureAwait(false); + var toolExecutor = services.GetService<Func<string, JsonElement, IPermissionEngine, ToolPermissionContext?, CancellationToken, Task<(string Output, bool IsAllowed, bool ShouldContinue)>>>(); + var permissionEngine = services.GetRequiredService<IPermissionEngine>(); + + Console.Error.WriteLine("MCP daemon running on stdio. Send JSON-RPC messages via stdin."); + + try + { + await using var reader = Console.OpenStandardInput(); + await using var writer = Console.OpenStandardOutput(); + using var streamReader = new StreamReader(reader, Encoding.UTF8); + + while (!ct.IsCancellationRequested) + { + string? line; + + try + { + line = await streamReader.ReadLineAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + + if (line is null) + { + break; + } + + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + try + { + var response = await HandleMessageAsync(line, services, mcpManager, toolExecutor, permissionEngine, ct).ConfigureAwait(false); + if (response is not null) + { + await WriteResponseAsync(writer, response, ct).ConfigureAwait(false); + + if (IsShutdownResponse(response)) + { + break; + } + } + } + catch (JsonException ex) + { + await WriteResponseAsync(writer, CreateErrorResponse(null, -32700, $"Parse error: {ex.Message}"), ct).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + } + + return 0; + } + + private static async Task<object?> HandleMessageAsync( + string line, + IServiceProvider services, + IMcpClientManager mcpManager, + Func<string, JsonElement, IPermissionEngine, ToolPermissionContext?, CancellationToken, Task<(string Output, bool IsAllowed, bool ShouldContinue)>>? toolExecutor, + IPermissionEngine permissionEngine, + CancellationToken ct) + { + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + + if (!root.TryGetProperty("method", out var methodElement)) + { + return null; + } + + var method = methodElement.GetString() ?? string.Empty; + var id = root.TryGetProperty("id", out var idElement) ? idElement.Clone() : default(JsonElement?); + + return method switch + { + "initialize" => CreateResultResponse( + id, + new + { + protocolVersion = "2025-03-26", + capabilities = new + { + tools = new { listChanged = true }, + resources = new { listChanged = true } + }, + serverInfo = new + { + name = "free-code-mcp", + version = "0.1.0" + } + }), + "notifications/initialized" => null, + "tools/list" => CreateResultResponse(id, new + { + tools = await BuildToolDefinitionsAsync(mcpManager).ConfigureAwait(false) + }), + "tools/call" => await HandleToolCallAsync(root, id, services, toolExecutor, permissionEngine, ct).ConfigureAwait(false), + "resources/list" => CreateResultResponse(id, new + { + resources = await BuildResourceDefinitionsAsync(mcpManager, ct).ConfigureAwait(false) + }), + "resources/read" => await HandleReadResourceAsync(root, id, mcpManager, ct).ConfigureAwait(false), + "ping" => CreateResultResponse(id, new { }), + "shutdown" => CreateResultResponse(id, (object?)null!), + + _ => CreateErrorResponse(id, -32601, $"Method not found: {method}") + }; + } + + private static async Task<object> HandleToolCallAsync( + JsonElement root, + JsonElement? id, + IServiceProvider services, + Func<string, JsonElement, IPermissionEngine, ToolPermissionContext?, CancellationToken, Task<(string Output, bool IsAllowed, bool ShouldContinue)>>? toolExecutor, + IPermissionEngine permissionEngine, + CancellationToken ct) + { + if (toolExecutor is null) + { + return CreateErrorResponse(id, -32603, "Tool executor is unavailable."); + } + + if (!TryGetParams(root, out var parameters) + || !parameters.TryGetProperty("name", out var nameElement) + || string.IsNullOrWhiteSpace(nameElement.GetString())) + { + return CreateErrorResponse(id, -32602, "tools/call requires a tool name."); + } + + var toolName = nameElement.GetString()!; + var arguments = parameters.TryGetProperty("arguments", out var argsElement) + ? argsElement.Clone() + : JsonDocument.Parse("{}").RootElement.Clone(); + + var (output, isAllowed, shouldContinue) = await toolExecutor(toolName, arguments, permissionEngine, null, ct).ConfigureAwait(false); + if (!isAllowed) + { + return CreateErrorResponse(id, -32001, output); + } + + return CreateResultResponse(id, new + { + content = new object[] + { + new + { + type = "text", + text = output + } + }, + isError = !shouldContinue + }); + } + + private static async Task<object> HandleReadResourceAsync(JsonElement root, JsonElement? id, IMcpClientManager mcpManager, CancellationToken ct) + { + if (!TryGetParams(root, out var parameters) + || !parameters.TryGetProperty("uri", out var uriElement) + || string.IsNullOrWhiteSpace(uriElement.GetString())) + { + return CreateErrorResponse(id, -32602, "resources/read requires a uri."); + } + + var uri = uriElement.GetString()!; + var serverName = parameters.TryGetProperty("serverName", out var serverNameElement) ? serverNameElement.GetString() : null; + + if (string.IsNullOrWhiteSpace(serverName)) + { + var matches = (await mcpManager.ListResourcesAsync(ct: ct).ConfigureAwait(false)) + .Where(resource => string.Equals(resource.Uri, uri, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (matches.Count == 0) + { + return CreateErrorResponse(id, -32004, $"Resource not found: {uri}"); + } + + if (matches.Count > 1) + { + return CreateErrorResponse(id, -32602, $"Multiple MCP servers expose resource '{uri}'. Specify serverName."); + } + + serverName = ResolveServerNameForResource(mcpManager, uri); + } + + if (string.IsNullOrWhiteSpace(serverName)) + { + return CreateErrorResponse(id, -32004, $"Unable to resolve MCP server for resource: {uri}"); + } + + var content = await mcpManager.ReadResourceAsync(serverName, uri, ct).ConfigureAwait(false); + return CreateResultResponse(id, new + { + contents = new object[] + { + new + { + uri = content.Uri, + mimeType = content.MimeType, + text = content.Text + } + } + }); + } + + private static bool TryGetParams(JsonElement root, out JsonElement parameters) + { + if (root.TryGetProperty("params", out parameters) && parameters.ValueKind == JsonValueKind.Object) + { + return true; + } + + parameters = default; + return false; + } + + private static string? ResolveServerNameForResource(IMcpClientManager mcpManager, string uri) + { + foreach (var connection in mcpManager.GetConnections().Where(connection => connection.IsConnected)) + { + try + { + var resources = mcpManager.ListResourcesAsync(connection.Name).GetAwaiter().GetResult(); + if (resources.Any(resource => string.Equals(resource.Uri, uri, StringComparison.OrdinalIgnoreCase))) + { + return connection.Name; + } + } + catch (Exception) + { + continue; + } + } + + return null; + } + + private static bool IsShutdownResponse(object response) + { + if (response is not Dictionary<string, object?> dictionary) + { + return false; + } + + return dictionary.TryGetValue("result", out var result) && result is null; + } + + private static async Task<object[]> BuildToolDefinitionsAsync(IMcpClientManager mcpManager) + { + var tools = await mcpManager.GetToolsAsync().ConfigureAwait(false); + var toolDefinitions = new object[tools.Count]; + + for (var i = 0; i < tools.Count; i++) + { + var tool = tools[i]; + var description = await tool.GetDescriptionAsync().ConfigureAwait(false); + toolDefinitions[i] = new + { + name = tool.Name, + description, + inputSchema = tool.GetInputSchema() + }; + } + + return toolDefinitions; + } + + private static async Task<object[]> BuildResourceDefinitionsAsync(IMcpClientManager mcpManager, CancellationToken ct) + { + var resources = await mcpManager.ListResourcesAsync(ct: ct).ConfigureAwait(false); + return resources.Select(resource => (object)new + { + uri = resource.Uri, + name = resource.Name, + description = resource.Description, + mimeType = resource.MimeType + }).ToArray(); + } + + private static async Task WriteResponseAsync(Stream output, object response, CancellationToken ct) + { + var responseJson = JsonSerializer.Serialize(response, JsonOptions); + var responseBytes = Encoding.UTF8.GetBytes(responseJson + "\n"); + await output.WriteAsync(responseBytes, ct).ConfigureAwait(false); + await output.FlushAsync(ct).ConfigureAwait(false); + } + + private static object CreateResultResponse(JsonElement? id, object result) + { + return new Dictionary<string, object?> + { + ["jsonrpc"] = "2.0", + ["id"] = id.HasValue ? id.Value : null, + ["result"] = result + }; + } + + private static object CreateErrorResponse(JsonElement? id, int code, string message) + { + return new Dictionary<string, object?> + { + ["jsonrpc"] = "2.0", + ["id"] = id.HasValue ? id.Value : null, + ["error"] = new + { + code, + message + } + }; + } +} diff --git a/src/FreeCode/OneShotMode.cs b/src/FreeCode/OneShotMode.cs new file mode 100644 index 0000000..1db2b6c --- /dev/null +++ b/src/FreeCode/OneShotMode.cs @@ -0,0 +1,65 @@ +using FreeCode.Core.Interfaces; +using FreeCode.Core.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode; + +public static class OneShotMode +{ + public static async Task<int> ExecuteAsync(IServiceProvider services, string prompt, string? model, CancellationToken ct) + { + try + { + var queryEngine = services.GetRequiredService<IQueryEngine>(); + var options = string.IsNullOrWhiteSpace(model) ? null : new SubmitMessageOptions(Model: model); + + await foreach (var message in queryEngine.SubmitMessageAsync(prompt, options, ct).ConfigureAwait(false)) + { + RenderMessage(message); + } + + Console.WriteLine(); + return 0; + } + catch (OperationCanceledException) + { + return 1; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex.Message); + return 1; + } + } + + private static void RenderMessage(SDKMessage message) + { + switch (message) + { + case SDKMessage.StreamingDelta streamingDelta: + Console.Write(streamingDelta.Text); + break; + case SDKMessage.AssistantMessage assistantMessage: + Console.WriteLine(assistantMessage.Text); + break; + case SDKMessage.ToolUseStart toolUseStart: + Console.WriteLine(); + Console.WriteLine($"[{toolUseStart.ToolName}] starting"); + break; + case SDKMessage.ToolUseResult toolUseResult: + Console.WriteLine(); + Console.WriteLine(toolUseResult.Output); + break; + case SDKMessage.CompactBoundary compactBoundary: + Console.WriteLine(); + Console.WriteLine($"[{compactBoundary.Reason}]"); + break; + case SDKMessage.AssistantError assistantError: + Console.Error.WriteLine(assistantError.Error); + break; + case SDKMessage.PermissionDenial permissionDenial: + Console.Error.WriteLine($"Permission denied for {permissionDenial.ToolName} ({permissionDenial.ToolUseId})"); + break; + } + } +} diff --git a/src/FreeCode/Program.cs b/src/FreeCode/Program.cs new file mode 100644 index 0000000..9ce9530 --- /dev/null +++ b/src/FreeCode/Program.cs @@ -0,0 +1,120 @@ +using FreeCode.ApiProviders; +using FreeCode.Commands; +using FreeCode.Core.Interfaces; +using FreeCode.Engine; +using FreeCode.Features; +using FreeCode.Plugins; +using FreeCode.Services; +using FreeCode.Skills; +using FreeCode.State; +using FreeCode.TerminalUI; +using FreeCode.Tools; +using System.Linq; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace FreeCode; + +public static class Program +{ + public static async Task<int> Main(string[] args) + { + if (QuickPathHandler.TryHandle(args, out var quickExitCode)) + { + return quickExitCode; + } + + using var cts = new CancellationTokenSource(); + Console.CancelKeyPress += (_, e) => + { + e.Cancel = true; + cts.Cancel(); + }; + + using var host = Host.CreateDefaultBuilder(args) + .ConfigureAppConfiguration(cfg => cfg.AddJsonFile("appsettings.json", optional: true)) + .ConfigureServices((ctx, services) => + { + services.AddCoreServices(); + services.AddFeatures(); + services.AddState(); + services.AddEngine(); + services.AddFreeCodeApiProviders(); + services.AddFreeCodeTools(); + services.AddCommands(); + services.AddServices(); + services.AddBusinessServices(); + services.AddMcp(); + services.AddLsp(); + services.AddTasks(); + services.AddBridge(); + services.AddSkills(); + services.AddPlugins(); + services.AddTerminalUI(); + }) + .Build(); + + var pluginManager = host.Services.GetService<IPluginManager>(); + if (pluginManager is not null) + { + await pluginManager.LoadPluginsAsync().ConfigureAwait(false); + } + + var mcpManager = host.Services.GetService<IMcpClientManager>(); + if (mcpManager is not null) + { + try + { + await mcpManager.ConnectServersAsync(cts.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: MCP connection failed: {ex.Message}"); + } + } + + if (TryGetOptionValue(args, "-p", "--prompt", out var prompt)) + { + var model = TryGetOptionValue(args, "--model", null, out var modelValue) ? modelValue : null; + return await OneShotMode.ExecuteAsync(host.Services, prompt, model, cts.Token).ConfigureAwait(false); + } + + if (args.Any(arg => string.Equals(arg, "--mcp-daemon", StringComparison.OrdinalIgnoreCase))) + { + return await McpDaemon.RunAsync(host.Services, cts.Token).ConfigureAwait(false); + } + + if (args.Any(arg => string.Equals(arg, "--bridge", StringComparison.OrdinalIgnoreCase))) + { + return await BridgeMain.RunAsync(host.Services, cts.Token).ConfigureAwait(false); + } + + var appRunner = host.Services.GetRequiredService<IAppRunner>(); + await appRunner.RunAsync(cts.Token).ConfigureAwait(false); + return 0; + } + + private static bool TryGetOptionValue(string[] args, string optionName, string? alternateOptionName, out string value) + { + for (var i = 0; i < args.Length; i++) + { + if (!string.Equals(args[i], optionName, StringComparison.OrdinalIgnoreCase) + && !string.Equals(args[i], alternateOptionName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (i + 1 < args.Length) + { + value = args[i + 1]; + return true; + } + + break; + } + + value = string.Empty; + return false; + } +} diff --git a/src/FreeCode/QuickPathHandler.cs b/src/FreeCode/QuickPathHandler.cs new file mode 100644 index 0000000..94afdb7 --- /dev/null +++ b/src/FreeCode/QuickPathHandler.cs @@ -0,0 +1,44 @@ +namespace FreeCode; + +public static class QuickPathHandler +{ + public static bool TryHandle(string[] args, out int exitCode) + { + if (args.Any(arg => string.Equals(arg, "--version", StringComparison.OrdinalIgnoreCase) || string.Equals(arg, "-v", StringComparison.OrdinalIgnoreCase))) + { + exitCode = 0; + PrintVersion(); + return true; + } + + if (args.Any(arg => string.Equals(arg, "--help", StringComparison.OrdinalIgnoreCase) || string.Equals(arg, "-h", StringComparison.OrdinalIgnoreCase) || string.Equals(arg, "help", StringComparison.OrdinalIgnoreCase))) + { + exitCode = 0; + PrintHelp(); + return true; + } + + exitCode = 0; + return false; + } + + private static void PrintVersion() + { + var version = typeof(Program).Assembly.GetName().Version?.ToString() ?? "unknown"; + Console.WriteLine($"free-code {version}"); + } + + private static void PrintHelp() + { + Console.WriteLine("free-code - terminal AI coding agent"); + Console.WriteLine(); + Console.WriteLine("Usage:"); + Console.WriteLine(" free-code Start interactive mode"); + Console.WriteLine(" free-code -p <prompt> One-shot mode"); + Console.WriteLine(" free-code --model <id> Specify model"); + Console.WriteLine(" free-code --mcp-daemon Start MCP daemon"); + Console.WriteLine(" free-code --bridge Start IDE bridge mode"); + Console.WriteLine(" free-code --help Show this help"); + Console.WriteLine(" free-code --version Show version"); + } +} diff --git a/src/FreeCode/ServiceCollectionExtensions.cs b/src/FreeCode/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..a7d7bf9 --- /dev/null +++ b/src/FreeCode/ServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +using FreeCode.Bridge; +using FreeCode.Lsp; +using FreeCode.Mcp; +using FreeCode.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace FreeCode; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddCoreServices(this IServiceCollection services) => services; + + public static IServiceCollection AddBusinessServices(this IServiceCollection services) => services; + + public static IServiceCollection AddMcp(this IServiceCollection services) => services.AddFreeCodeMcp(); + + public static IServiceCollection AddLsp(this IServiceCollection services) => services.AddFreeCodeLsp(); + + public static IServiceCollection AddTasks(this IServiceCollection services) => services.AddFreeCodeTasks(); + + public static IServiceCollection AddBridge(this IServiceCollection services) => services.AddFreeCodeBridge(); +} diff --git a/src/FreeCode/appsettings.json b/src/FreeCode/appsettings.json new file mode 100644 index 0000000..92e090e --- /dev/null +++ b/src/FreeCode/appsettings.json @@ -0,0 +1,17 @@ +{ + "FeatureFlags": { + "ENABLE_LSP_TOOL": false, + "ENABLE_VOICE": false, + "ENABLE_REMOTE_CONTROL": false, + "ENABLE_COMPANION_SPRITE": false + }, + "ApiProviders": { + "DefaultProvider": "anthropic", + "DefaultModel": null + }, + "Logging": { + "LogLevel": { + "Default": "Warning" + } + } +} diff --git a/tests/FreeCode.ApiProviders.Tests/BasicTests.cs b/tests/FreeCode.ApiProviders.Tests/BasicTests.cs new file mode 100644 index 0000000..edae87c --- /dev/null +++ b/tests/FreeCode.ApiProviders.Tests/BasicTests.cs @@ -0,0 +1,12 @@ +using Xunit; + +namespace FreeCode.ApiProviders.Tests; + +public class BasicTests +{ + [Fact] + public void Sanity() + { + Assert.True(true); + } +} diff --git a/tests/FreeCode.ApiProviders.Tests/FreeCode.ApiProviders.Tests.csproj b/tests/FreeCode.ApiProviders.Tests/FreeCode.ApiProviders.Tests.csproj new file mode 100644 index 0000000..06379c8 --- /dev/null +++ b/tests/FreeCode.ApiProviders.Tests/FreeCode.ApiProviders.Tests.csproj @@ -0,0 +1,19 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <RootNamespace>FreeCode.ApiProviders.Tests</RootNamespace> + <Nullable>enable</Nullable> + <IsAotCompatible>false</IsAotCompatible> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\..\src\FreeCode.ApiProviders\FreeCode.ApiProviders.csproj" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> + <PackageReference Include="xunit" Version="2.9.2" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + </ItemGroup> +</Project> diff --git a/tests/FreeCode.Commands.Tests/BasicTests.cs b/tests/FreeCode.Commands.Tests/BasicTests.cs new file mode 100644 index 0000000..1379a81 --- /dev/null +++ b/tests/FreeCode.Commands.Tests/BasicTests.cs @@ -0,0 +1,12 @@ +using Xunit; + +namespace FreeCode.Commands.Tests; + +public class BasicTests +{ + [Fact] + public void Sanity() + { + Assert.True(true); + } +} diff --git a/tests/FreeCode.Commands.Tests/FreeCode.Commands.Tests.csproj b/tests/FreeCode.Commands.Tests/FreeCode.Commands.Tests.csproj new file mode 100644 index 0000000..aa8428a --- /dev/null +++ b/tests/FreeCode.Commands.Tests/FreeCode.Commands.Tests.csproj @@ -0,0 +1,19 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <RootNamespace>FreeCode.Commands.Tests</RootNamespace> + <Nullable>enable</Nullable> + <IsAotCompatible>false</IsAotCompatible> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\..\src\FreeCode.Commands\FreeCode.Commands.csproj" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> + <PackageReference Include="xunit" Version="2.9.2" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + </ItemGroup> +</Project> diff --git a/tests/FreeCode.Core.Tests/BasicTests.cs b/tests/FreeCode.Core.Tests/BasicTests.cs new file mode 100644 index 0000000..c56ba0e --- /dev/null +++ b/tests/FreeCode.Core.Tests/BasicTests.cs @@ -0,0 +1,61 @@ +using System.Text.Json; +using FreeCode.Core.Enums; +using FreeCode.Core.Models; +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] + +namespace FreeCode.Core.Tests; + +public class BasicTests +{ + [Fact] + public void ToolPermissionContext_UsesEmptySetsByDefault() + { + var context = new ToolPermissionContext(); + + Assert.Empty(context.DeniedTools); + Assert.Empty(context.AllowedTools); + } + + [Fact] + public void BridgeEnvironment_UsesRecordEquality() + { + var metadata = new Dictionary<string, string> { ["shell"] = "zsh" }; + var left = new BridgeEnvironment("env-1", "Local", SpawnMode.Worktree, "/tmp/work", metadata); + var right = new BridgeEnvironment("env-1", "Local", SpawnMode.Worktree, "/tmp/work", metadata); + + Assert.Equal(left, right); + } + + [Fact] + public void WorkItem_StoresPayloadAndOptionalSessionToken() + { + using var document = JsonDocument.Parse("{" + "\"command\":\"build\"" + "}"); + var workItem = new WorkItem("work-1", "shell", document.RootElement.Clone(), "session-token"); + + Assert.Equal("work-1", workItem.Id); + Assert.Equal("shell", workItem.Type); + Assert.Equal("build", workItem.Payload.GetProperty("command").GetString()); + Assert.Equal("session-token", workItem.SessionToken); + } + + [Fact] + public void SessionHandle_UsesNullUrlByDefault() + { + var handle = new SessionHandle("session-1", "token-1"); + + Assert.Equal("session-1", handle.SessionId); + Assert.Equal("token-1", handle.SessionToken); + Assert.Null(handle.Url); + } + + [Fact] + public void PermissionMode_EnumValuesMatchExpectedOrder() + { + Assert.Equal(0, (int)PermissionMode.Default); + Assert.Equal(1, (int)PermissionMode.Plan); + Assert.Equal(2, (int)PermissionMode.AutoAccept); + Assert.Equal(3, (int)PermissionMode.BypassPermissions); + } +} diff --git a/tests/FreeCode.Core.Tests/FreeCode.Core.Tests.csproj b/tests/FreeCode.Core.Tests/FreeCode.Core.Tests.csproj new file mode 100644 index 0000000..934dd97 --- /dev/null +++ b/tests/FreeCode.Core.Tests/FreeCode.Core.Tests.csproj @@ -0,0 +1,19 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <RootNamespace>FreeCode.Core.Tests</RootNamespace> + <Nullable>enable</Nullable> + <IsAotCompatible>false</IsAotCompatible> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\..\src\FreeCode.Core\FreeCode.Core.csproj" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> + <PackageReference Include="xunit" Version="2.9.2" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + </ItemGroup> +</Project> diff --git a/tests/FreeCode.Engine.Tests/BasicTests.cs b/tests/FreeCode.Engine.Tests/BasicTests.cs new file mode 100644 index 0000000..f6d2c30 --- /dev/null +++ b/tests/FreeCode.Engine.Tests/BasicTests.cs @@ -0,0 +1,12 @@ +using Xunit; + +namespace FreeCode.Engine.Tests; + +public class BasicTests +{ + [Fact] + public void Sanity() + { + Assert.True(true); + } +} diff --git a/tests/FreeCode.Engine.Tests/FreeCode.Engine.Tests.csproj b/tests/FreeCode.Engine.Tests/FreeCode.Engine.Tests.csproj new file mode 100644 index 0000000..f7c797b --- /dev/null +++ b/tests/FreeCode.Engine.Tests/FreeCode.Engine.Tests.csproj @@ -0,0 +1,19 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <RootNamespace>FreeCode.Engine.Tests</RootNamespace> + <Nullable>enable</Nullable> + <IsAotCompatible>false</IsAotCompatible> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\..\src\FreeCode.Engine\FreeCode.Engine.csproj" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> + <PackageReference Include="xunit" Version="2.9.2" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + </ItemGroup> +</Project> diff --git a/tests/FreeCode.Integration.Tests/BasicTests.cs b/tests/FreeCode.Integration.Tests/BasicTests.cs new file mode 100644 index 0000000..86a8c70 --- /dev/null +++ b/tests/FreeCode.Integration.Tests/BasicTests.cs @@ -0,0 +1,12 @@ +using Xunit; + +namespace FreeCode.Integration.Tests; + +public class BasicTests +{ + [Fact] + public void Sanity() + { + Assert.True(true); + } +} diff --git a/tests/FreeCode.Integration.Tests/FreeCode.Integration.Tests.csproj b/tests/FreeCode.Integration.Tests/FreeCode.Integration.Tests.csproj new file mode 100644 index 0000000..18a0332 --- /dev/null +++ b/tests/FreeCode.Integration.Tests/FreeCode.Integration.Tests.csproj @@ -0,0 +1,21 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <RootNamespace>FreeCode.Integration.Tests</RootNamespace> + <Nullable>enable</Nullable> + <IsAotCompatible>false</IsAotCompatible> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\..\src\FreeCode\FreeCode.csproj" /> + <ProjectReference Include="..\..\src\FreeCode.Engine\FreeCode.Engine.csproj" /> + <ProjectReference Include="..\..\src\FreeCode.Tools\FreeCode.Tools.csproj" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> + <PackageReference Include="xunit" Version="2.9.2" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + </ItemGroup> +</Project> diff --git a/tests/FreeCode.Mcp.Tests/BasicTests.cs b/tests/FreeCode.Mcp.Tests/BasicTests.cs new file mode 100644 index 0000000..eddbb2a --- /dev/null +++ b/tests/FreeCode.Mcp.Tests/BasicTests.cs @@ -0,0 +1,110 @@ +using FreeCode.Core.Enums; +using FreeCode.Core.Models; +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] + +namespace FreeCode.Mcp.Tests; + +public class BasicTests +{ + private static readonly Lock ConfigLock = new(); + + [Fact] + public async Task McpClientManager_LoadsConfiguredConnectionsFromDisk() + { + using var config = new McpConfigScope(""" + { + "mcpServers": { + "demo": { + "command": "definitely-not-a-real-command", + "args": ["--version"], + "scope": "User" + } + } + } + """); + + var manager = new McpClientManager(); + await manager.ConnectServersAsync(); + + var connections = manager.GetConnections(); + + Assert.Single(connections); + Assert.Equal("demo", connections[0].Name); + Assert.IsType<StdioServerConfig>(connections[0].Config); + } + + [Fact] + public void McpServerConnection_FlagPropertiesMatchConnectionSubtype() + { + var config = new StdioServerConfig { Scope = ConfigScope.User, Command = "dotnet" }; + var connected = new MCPServerConnection.Connected("demo", config, new object(), new object(), null, null, () => Task.CompletedTask) + { + Name = "demo", + Config = config + }; + var failed = new MCPServerConnection.Failed("demo", config, "boom") + { + Name = "demo", + Config = config + }; + + Assert.True(connected.IsConnected); + Assert.False(connected.IsFailed); + Assert.True(failed.IsFailed); + Assert.False(failed.IsConnected); + } + + [Fact] + public void McpTypes_ExposeExpectedDefaultsAndRecordShapes() + { + var capabilities = new ServerCapabilities(); + using var schema = System.Text.Json.JsonDocument.Parse("{\"type\":\"object\"}"); + var tool = new McpToolDefinition("read_file", "Reads files", schema.RootElement.Clone(), false); + + Assert.True(capabilities.Tools); + Assert.True(capabilities.Resources); + Assert.False(capabilities.Prompts); + Assert.Equal("read_file", tool.Name); + Assert.Equal("Reads files", tool.Description); + Assert.False(tool.HasDestructiveBehavior); + } + + private sealed class McpConfigScope : IDisposable + { + private readonly string _configPath; + private readonly string? _originalContent; + private readonly bool _hadOriginal; + + public McpConfigScope(string content) + { + Monitor.Enter(ConfigLock); + var configDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".free-code"); + Directory.CreateDirectory(configDirectory); + _configPath = Path.Combine(configDirectory, "config.json"); + _hadOriginal = File.Exists(_configPath); + _originalContent = _hadOriginal ? File.ReadAllText(_configPath) : null; + File.WriteAllText(_configPath, content); + } + + public void Dispose() + { + try + { + if (_hadOriginal) + { + File.WriteAllText(_configPath, _originalContent!); + } + else if (File.Exists(_configPath)) + { + File.Delete(_configPath); + } + } + finally + { + Monitor.Exit(ConfigLock); + } + } + } +} diff --git a/tests/FreeCode.Mcp.Tests/FreeCode.Mcp.Tests.csproj b/tests/FreeCode.Mcp.Tests/FreeCode.Mcp.Tests.csproj new file mode 100644 index 0000000..6a6abcc --- /dev/null +++ b/tests/FreeCode.Mcp.Tests/FreeCode.Mcp.Tests.csproj @@ -0,0 +1,19 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <RootNamespace>FreeCode.Mcp.Tests</RootNamespace> + <Nullable>enable</Nullable> + <IsAotCompatible>false</IsAotCompatible> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\..\src\FreeCode.Mcp\FreeCode.Mcp.csproj" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> + <PackageReference Include="xunit" Version="2.9.2" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + </ItemGroup> +</Project> diff --git a/tests/FreeCode.Services.Tests/BasicTests.cs b/tests/FreeCode.Services.Tests/BasicTests.cs new file mode 100644 index 0000000..7f24707 --- /dev/null +++ b/tests/FreeCode.Services.Tests/BasicTests.cs @@ -0,0 +1,12 @@ +using Xunit; + +namespace FreeCode.Services.Tests; + +public class BasicTests +{ + [Fact] + public void Sanity() + { + Assert.True(true); + } +} diff --git a/tests/FreeCode.Services.Tests/FreeCode.Services.Tests.csproj b/tests/FreeCode.Services.Tests/FreeCode.Services.Tests.csproj new file mode 100644 index 0000000..fc7cfba --- /dev/null +++ b/tests/FreeCode.Services.Tests/FreeCode.Services.Tests.csproj @@ -0,0 +1,19 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <RootNamespace>FreeCode.Services.Tests</RootNamespace> + <Nullable>enable</Nullable> + <IsAotCompatible>false</IsAotCompatible> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\..\src\FreeCode.Services\FreeCode.Services.csproj" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> + <PackageReference Include="xunit" Version="2.9.2" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + </ItemGroup> +</Project> diff --git a/tests/FreeCode.Tasks.Tests/BasicTests.cs b/tests/FreeCode.Tasks.Tests/BasicTests.cs new file mode 100644 index 0000000..fef55f1 --- /dev/null +++ b/tests/FreeCode.Tasks.Tests/BasicTests.cs @@ -0,0 +1,12 @@ +using Xunit; + +namespace FreeCode.Tasks.Tests; + +public class BasicTests +{ + [Fact] + public void Sanity() + { + Assert.True(true); + } +} diff --git a/tests/FreeCode.Tasks.Tests/FreeCode.Tasks.Tests.csproj b/tests/FreeCode.Tasks.Tests/FreeCode.Tasks.Tests.csproj new file mode 100644 index 0000000..ca93d81 --- /dev/null +++ b/tests/FreeCode.Tasks.Tests/FreeCode.Tasks.Tests.csproj @@ -0,0 +1,19 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <RootNamespace>FreeCode.Tasks.Tests</RootNamespace> + <Nullable>enable</Nullable> + <IsAotCompatible>false</IsAotCompatible> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\..\src\FreeCode.Tasks\FreeCode.Tasks.csproj" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> + <PackageReference Include="xunit" Version="2.9.2" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + </ItemGroup> +</Project> diff --git a/tests/FreeCode.Tools.Tests/BasicTests.cs b/tests/FreeCode.Tools.Tests/BasicTests.cs new file mode 100644 index 0000000..8b1a88a --- /dev/null +++ b/tests/FreeCode.Tools.Tests/BasicTests.cs @@ -0,0 +1,12 @@ +using Xunit; + +namespace FreeCode.Tools.Tests; + +public class BasicTests +{ + [Fact] + public void Sanity() + { + Assert.True(true); + } +} diff --git a/tests/FreeCode.Tools.Tests/FreeCode.Tools.Tests.csproj b/tests/FreeCode.Tools.Tests/FreeCode.Tools.Tests.csproj new file mode 100644 index 0000000..c411050 --- /dev/null +++ b/tests/FreeCode.Tools.Tests/FreeCode.Tools.Tests.csproj @@ -0,0 +1,19 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <RootNamespace>FreeCode.Tools.Tests</RootNamespace> + <Nullable>enable</Nullable> + <IsAotCompatible>false</IsAotCompatible> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\..\src\FreeCode.Tools\FreeCode.Tools.csproj" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> + <PackageReference Include="xunit" Version="2.9.2" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + </ItemGroup> +</Project>