From 7094ec194b50c79ffdc0778d4d37a29bdd4242e0 Mon Sep 17 00:00:00 2001 From: telli Date: Sun, 17 May 2026 00:40:34 -0700 Subject: [PATCH] Add Warp-inspired runtime extensions --- README.md | 4 + SharpClawCode.sln | 30 +++ docs/external-agents.md | 41 ++++ docs/skills.md | 44 ++++ docs/work-items.md | 28 +++ docs/workbench.md | 21 ++ .../CliServiceCollectionExtensions.cs | 12 +- .../AgentStatusSlashCommandHandler.cs | 19 ++ .../Handlers/AgentsCommandHandler.cs | 113 +++++++++ .../CheckpointsSlashCommandHandler.cs | 47 ++++ .../Handlers/ExternalCommandHandler.cs | 111 +++++++++ .../Handlers/SkillsCommandHandler.cs | 143 +++++++++++- .../Handlers/WorkCommandHandler.cs | 111 +++++++++ .../Handlers/WorkbenchCommandHandler.cs | 46 ++++ .../SharpClaw.Code.Commands.csproj | 2 + .../Abstractions/IExternalAgentAdapter.cs | 24 ++ .../IExternalAgentConfigProvider.cs | 14 ++ .../IExternalAgentExecutableResolver.cs | 12 + .../Abstractions/IExternalAgentRegistry.cs | 24 ++ .../Abstractions/IExternalAgentService.cs | 19 ++ .../Adapters/BuiltInExternalAgentAdapters.cs | 87 +++++++ .../Adapters/ExternalAgentAdapterBase.cs | 132 +++++++++++ .../AssemblyMarker.cs | 6 + ...ternalAgentsServiceCollectionExtensions.cs | 30 +++ .../DefaultExternalAgentConfigProvider.cs | 18 ++ .../Services/ExternalAgentRegistry.cs | 34 +++ .../Services/ExternalAgentService.cs | 212 +++++++++++++++++ .../PathExternalAgentExecutableResolver.cs | 43 ++++ .../SharpClaw.Code.ExternalAgents.csproj | 20 ++ .../Events/ExternalAgentEvents.cs | 40 ++++ .../Events/RuntimeEvent.cs | 8 + .../Events/SkillAndWorkItemEvents.cs | 57 +++++ .../Models/ExternalAgentModels.cs | 153 ++++++++++++ .../Models/OpenCodeParityModels.cs | 8 +- .../Models/SharpClawWorkflowMetadataKeys.cs | 12 + .../Models/SkillPackModels.cs | 92 ++++++++ .../Models/WorkItemModels.cs | 112 +++++++++ .../Operational/WorkbenchStatusReport.cs | 23 ++ .../Serialization/ProtocolJsonContext.cs | 47 ++++ .../Abstractions/IWorkbenchStatusService.cs | 14 ++ .../RuntimeServiceCollectionExtensions.cs | 9 + .../Configuration/SharpClawConfigService.cs | 29 ++- .../Checks/ExternalAgentHealthCheck.cs | 34 +++ .../OperationalDiagnosticsCoordinator.cs | 2 +- .../Export/SessionExportService.cs | 7 + .../RuntimeExternalAgentConfigProvider.cs | 18 ++ .../SharpClaw.Code.Runtime.csproj | 2 + .../Workflow/WorkbenchStatusService.cs | 101 ++++++++ .../Abstractions/ISkillPackRegistry.cs | 39 ++++ .../Services/SkillPackRegistry.cs | 217 ++++++++++++++++++ .../SkillsServiceCollectionExtensions.cs | 1 + .../Abstractions/IWorkItemProvider.cs | 24 ++ .../Abstractions/IWorkItemRegistry.cs | 12 + .../Abstractions/IWorkItemService.cs | 19 ++ .../AssemblyMarker.cs | 6 + .../Providers/GenericWorkItemProvider.cs | 31 +++ .../Providers/GitHubWorkItemProvider.cs | 94 ++++++++ .../Services/WorkItemRegistry.cs | 16 ++ .../Services/WorkItemService.cs | 185 +++++++++++++++ .../SharpClaw.Code.WorkItems.csproj | 18 ++ .../WorkItemsServiceCollectionExtensions.cs | 26 +++ .../ExternalAgentAdapterTests.cs | 48 ++++ .../SharpClaw.Code.UnitTests.csproj | 2 + .../Skills/SkillPackRegistryTests.cs | 66 ++++++ .../WorkItems/GitHubWorkItemProviderTests.cs | 21 ++ 65 files changed, 3029 insertions(+), 11 deletions(-) create mode 100644 docs/external-agents.md create mode 100644 docs/skills.md create mode 100644 docs/work-items.md create mode 100644 docs/workbench.md create mode 100644 src/SharpClaw.Code.Commands/Handlers/AgentStatusSlashCommandHandler.cs create mode 100644 src/SharpClaw.Code.Commands/Handlers/CheckpointsSlashCommandHandler.cs create mode 100644 src/SharpClaw.Code.Commands/Handlers/ExternalCommandHandler.cs create mode 100644 src/SharpClaw.Code.Commands/Handlers/WorkCommandHandler.cs create mode 100644 src/SharpClaw.Code.Commands/Handlers/WorkbenchCommandHandler.cs create mode 100644 src/SharpClaw.Code.ExternalAgents/Abstractions/IExternalAgentAdapter.cs create mode 100644 src/SharpClaw.Code.ExternalAgents/Abstractions/IExternalAgentConfigProvider.cs create mode 100644 src/SharpClaw.Code.ExternalAgents/Abstractions/IExternalAgentExecutableResolver.cs create mode 100644 src/SharpClaw.Code.ExternalAgents/Abstractions/IExternalAgentRegistry.cs create mode 100644 src/SharpClaw.Code.ExternalAgents/Abstractions/IExternalAgentService.cs create mode 100644 src/SharpClaw.Code.ExternalAgents/Adapters/BuiltInExternalAgentAdapters.cs create mode 100644 src/SharpClaw.Code.ExternalAgents/Adapters/ExternalAgentAdapterBase.cs create mode 100644 src/SharpClaw.Code.ExternalAgents/AssemblyMarker.cs create mode 100644 src/SharpClaw.Code.ExternalAgents/ExternalAgentsServiceCollectionExtensions.cs create mode 100644 src/SharpClaw.Code.ExternalAgents/Services/DefaultExternalAgentConfigProvider.cs create mode 100644 src/SharpClaw.Code.ExternalAgents/Services/ExternalAgentRegistry.cs create mode 100644 src/SharpClaw.Code.ExternalAgents/Services/ExternalAgentService.cs create mode 100644 src/SharpClaw.Code.ExternalAgents/Services/PathExternalAgentExecutableResolver.cs create mode 100644 src/SharpClaw.Code.ExternalAgents/SharpClaw.Code.ExternalAgents.csproj create mode 100644 src/SharpClaw.Code.Protocol/Events/ExternalAgentEvents.cs create mode 100644 src/SharpClaw.Code.Protocol/Events/SkillAndWorkItemEvents.cs create mode 100644 src/SharpClaw.Code.Protocol/Models/ExternalAgentModels.cs create mode 100644 src/SharpClaw.Code.Protocol/Models/SkillPackModels.cs create mode 100644 src/SharpClaw.Code.Protocol/Models/WorkItemModels.cs create mode 100644 src/SharpClaw.Code.Protocol/Operational/WorkbenchStatusReport.cs create mode 100644 src/SharpClaw.Code.Runtime/Abstractions/IWorkbenchStatusService.cs create mode 100644 src/SharpClaw.Code.Runtime/Diagnostics/Checks/ExternalAgentHealthCheck.cs create mode 100644 src/SharpClaw.Code.Runtime/ExternalAgents/RuntimeExternalAgentConfigProvider.cs create mode 100644 src/SharpClaw.Code.Runtime/Workflow/WorkbenchStatusService.cs create mode 100644 src/SharpClaw.Code.Skills/Abstractions/ISkillPackRegistry.cs create mode 100644 src/SharpClaw.Code.Skills/Services/SkillPackRegistry.cs create mode 100644 src/SharpClaw.Code.WorkItems/Abstractions/IWorkItemProvider.cs create mode 100644 src/SharpClaw.Code.WorkItems/Abstractions/IWorkItemRegistry.cs create mode 100644 src/SharpClaw.Code.WorkItems/Abstractions/IWorkItemService.cs create mode 100644 src/SharpClaw.Code.WorkItems/AssemblyMarker.cs create mode 100644 src/SharpClaw.Code.WorkItems/Providers/GenericWorkItemProvider.cs create mode 100644 src/SharpClaw.Code.WorkItems/Providers/GitHubWorkItemProvider.cs create mode 100644 src/SharpClaw.Code.WorkItems/Services/WorkItemRegistry.cs create mode 100644 src/SharpClaw.Code.WorkItems/Services/WorkItemService.cs create mode 100644 src/SharpClaw.Code.WorkItems/SharpClaw.Code.WorkItems.csproj create mode 100644 src/SharpClaw.Code.WorkItems/WorkItemsServiceCollectionExtensions.cs create mode 100644 tests/SharpClaw.Code.UnitTests/ExternalAgents/ExternalAgentAdapterTests.cs create mode 100644 tests/SharpClaw.Code.UnitTests/Skills/SkillPackRegistryTests.cs create mode 100644 tests/SharpClaw.Code.UnitTests/WorkItems/GitHubWorkItemProviderTests.cs diff --git a/README.md b/README.md index 91b5278..5740754 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,10 @@ Subcommands include `prompt`, `repl`, `doctor`, `status`, `session`, `index`, `m | [docs/tools.md](docs/tools.md) | Tool registry, executor behavior, and built-ins | | [docs/permissions.md](docs/permissions.md) | Permission modes, policies, and approvals | | [docs/agents.md](docs/agents.md) | Agent Framework integration | +| [docs/external-agents.md](docs/external-agents.md) | External agent adapters and supervised CLI runs | +| [docs/skills.md](docs/skills.md) | Skill packs, built-in workflows, and install/run commands | +| [docs/work-items.md](docs/work-items.md) | GitHub and generic work-item import/export | +| [docs/workbench.md](docs/workbench.md) | Static runtime workbench/status surfaces | | [docs/mcp.md](docs/mcp.md) | MCP registration, lifecycle, and orchestration | | [docs/acp.md](docs/acp.md) | ACP stdio host and protocol notes | | [docs/plugins.md](docs/plugins.md) | Plugin discovery, trust, and CLI flows | diff --git a/SharpClawCode.sln b/SharpClawCode.sln index 7487f23..6bb3d5f 100644 --- a/SharpClawCode.sln +++ b/SharpClawCode.sln @@ -73,6 +73,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpClaw.Testing.Cli", "sr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpClaw.Testing.Xunit", "src\SharpClaw.Testing.Xunit\SharpClaw.Testing.Xunit.csproj", "{C45BBEA7-5970-40BB-AE6D-B8F09D1E2EE1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpClaw.Code.ExternalAgents", "src\SharpClaw.Code.ExternalAgents\SharpClaw.Code.ExternalAgents.csproj", "{C76D91F9-EAED-4C8B-A07C-70A3BF65E42A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpClaw.Code.WorkItems", "src\SharpClaw.Code.WorkItems\SharpClaw.Code.WorkItems.csproj", "{9C45962D-F544-474F-B67A-1736807EEC7E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -467,6 +471,30 @@ Global {C45BBEA7-5970-40BB-AE6D-B8F09D1E2EE1}.Release|x64.Build.0 = Release|Any CPU {C45BBEA7-5970-40BB-AE6D-B8F09D1E2EE1}.Release|x86.ActiveCfg = Release|Any CPU {C45BBEA7-5970-40BB-AE6D-B8F09D1E2EE1}.Release|x86.Build.0 = Release|Any CPU + {C76D91F9-EAED-4C8B-A07C-70A3BF65E42A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C76D91F9-EAED-4C8B-A07C-70A3BF65E42A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C76D91F9-EAED-4C8B-A07C-70A3BF65E42A}.Debug|x64.ActiveCfg = Debug|Any CPU + {C76D91F9-EAED-4C8B-A07C-70A3BF65E42A}.Debug|x64.Build.0 = Debug|Any CPU + {C76D91F9-EAED-4C8B-A07C-70A3BF65E42A}.Debug|x86.ActiveCfg = Debug|Any CPU + {C76D91F9-EAED-4C8B-A07C-70A3BF65E42A}.Debug|x86.Build.0 = Debug|Any CPU + {C76D91F9-EAED-4C8B-A07C-70A3BF65E42A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C76D91F9-EAED-4C8B-A07C-70A3BF65E42A}.Release|Any CPU.Build.0 = Release|Any CPU + {C76D91F9-EAED-4C8B-A07C-70A3BF65E42A}.Release|x64.ActiveCfg = Release|Any CPU + {C76D91F9-EAED-4C8B-A07C-70A3BF65E42A}.Release|x64.Build.0 = Release|Any CPU + {C76D91F9-EAED-4C8B-A07C-70A3BF65E42A}.Release|x86.ActiveCfg = Release|Any CPU + {C76D91F9-EAED-4C8B-A07C-70A3BF65E42A}.Release|x86.Build.0 = Release|Any CPU + {9C45962D-F544-474F-B67A-1736807EEC7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9C45962D-F544-474F-B67A-1736807EEC7E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9C45962D-F544-474F-B67A-1736807EEC7E}.Debug|x64.ActiveCfg = Debug|Any CPU + {9C45962D-F544-474F-B67A-1736807EEC7E}.Debug|x64.Build.0 = Debug|Any CPU + {9C45962D-F544-474F-B67A-1736807EEC7E}.Debug|x86.ActiveCfg = Debug|Any CPU + {9C45962D-F544-474F-B67A-1736807EEC7E}.Debug|x86.Build.0 = Debug|Any CPU + {9C45962D-F544-474F-B67A-1736807EEC7E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9C45962D-F544-474F-B67A-1736807EEC7E}.Release|Any CPU.Build.0 = Release|Any CPU + {9C45962D-F544-474F-B67A-1736807EEC7E}.Release|x64.ActiveCfg = Release|Any CPU + {9C45962D-F544-474F-B67A-1736807EEC7E}.Release|x64.Build.0 = Release|Any CPU + {9C45962D-F544-474F-B67A-1736807EEC7E}.Release|x86.ActiveCfg = Release|Any CPU + {9C45962D-F544-474F-B67A-1736807EEC7E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -504,5 +532,7 @@ Global {A78CD9D6-54CF-422C-B5D8-B3BC4D99323E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {425E2495-940F-46A6-9F3E-ED05301504BD} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {C45BBEA7-5970-40BB-AE6D-B8F09D1E2EE1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {C76D91F9-EAED-4C8B-A07C-70A3BF65E42A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {9C45962D-F544-474F-B67A-1736807EEC7E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/docs/external-agents.md b/docs/external-agents.md new file mode 100644 index 0000000..19f06df --- /dev/null +++ b/docs/external-agents.md @@ -0,0 +1,41 @@ +# External Agents + +SharpClaw can run external agent CLIs as supervised, session-aware operations. External agents do not replace SharpClaw's runtime; they are child processes that must pass permission policy before mutating runs. + +## Configure + +Use `.sharpclaw/config.jsonc`: + +```jsonc +{ + "externalAgents": { + "enabled": true, + "requireApprovalForMutatingRuns": true, + "adapters": { + "codex": { + "executablePath": "codex", + "defaultArgs": [] + } + } + } +} +``` + +Built-in adapters are `claude`, `opencode`, `gemini`, and `codex`. + +## Use + +```bash +sharpclaw external list +sharpclaw external status +sharpclaw external run --adapter codex --prompt "Review this repository" +``` + +In the REPL: + +```text +/agents external list +/agents external run codex Review this repository +``` + +MVP support is text-mode process execution. Structured streaming is deferred until external CLIs expose stable machine-readable streams. diff --git a/docs/skills.md b/docs/skills.md new file mode 100644 index 0000000..3092b55 --- /dev/null +++ b/docs/skills.md @@ -0,0 +1,44 @@ +# Skill Packs + +Skill packs package reusable prompts, checklists, command templates, recommended tools, and compatible modes. Legacy workspace skills under `.sharpclaw/skills` are still supported; skill packs live under `.sharpclaw/skillpacks`. + +## Manifest + +`skillpack.json`: + +```json +{ + "id": "pr-review", + "name": "PR Review", + "version": "1.0.0", + "description": "Reviews diffs for correctness, tests, security, and maintainability.", + "author": "team", + "tags": ["review"], + "commands": [ + { + "name": "run", + "description": "Run the review", + "promptTemplate": "Review this diff: {{arguments}}" + } + ], + "prompts": [], + "checklists": [], + "recommendedTools": [], + "requiredPermissions": ["toolExecution"], + "compatibleModes": ["plan", "build"], + "entryPointPrompt": "Review this diff: {{arguments}}" +} +``` + +## Use + +```bash +sharpclaw skills list +sharpclaw skills show --id pr-review +sharpclaw skills install --path ./skillpack.json +sharpclaw skills enable --id pr-review +sharpclaw skills disable --id pr-review +sharpclaw skills run --id pr-review --args "main...feature" +``` + +Built-in packs include PR Review, Architecture Review, Release Notes, EFH Recovery, and Figma Handoff. diff --git a/docs/work-items.md b/docs/work-items.md new file mode 100644 index 0000000..250db8f --- /dev/null +++ b/docs/work-items.md @@ -0,0 +1,28 @@ +# Work Items + +Work-item integrations import real tasks into SharpClaw sessions and export session summaries without posting back automatically. + +## GitHub + +```bash +sharpclaw work import https://github.com/owner/repo/issues/123 +sharpclaw work import https://github.com/owner/repo/pull/123 +``` + +Public issues and PRs work unauthenticated. Set `GITHUB_TOKEN` for private repositories or higher rate limits. + +## Generic JSON + +```bash +sharpclaw work import ./task.json --provider generic +``` + +The JSON shape matches `GenericWorkItemFixture`: provider, id, title, description, url, status, labels, assignee, and metadata. + +## Export + +```bash +sharpclaw work export-summary --session session_123 +``` + +Export creates Markdown or JSON text in command output. It does not post comments to GitHub or Jira. diff --git a/docs/workbench.md b/docs/workbench.md new file mode 100644 index 0000000..218be21 --- /dev/null +++ b/docs/workbench.md @@ -0,0 +1,21 @@ +# Workbench + +The workbench is a static terminal status surface focused on SharpClaw runtime state. It is not a terminal emulator or live dashboard. + +```bash +sharpclaw workbench +``` + +REPL commands: + +```text +/workbench +/sessions +/approvals +/checkpoints +/agent-status +``` + +The report includes the current session, goal, primary mode, active agent, approval summary, latest checkpoint, recent runtime activity, external adapter health, and warnings from operational status checks. + +Use `--output-format json` for stable machine-readable output. diff --git a/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs b/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs index 802314a..b76a43f 100644 --- a/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs @@ -39,6 +39,7 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -58,8 +59,11 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -93,8 +97,12 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/SharpClaw.Code.Commands/Handlers/AgentStatusSlashCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/AgentStatusSlashCommandHandler.cs new file mode 100644 index 0000000..789ee95 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/AgentStatusSlashCommandHandler.cs @@ -0,0 +1,19 @@ +using SharpClaw.Code.Commands.Models; + +namespace SharpClaw.Code.Commands; + +/// +/// REPL alias for external agent status. +/// +public sealed class AgentStatusSlashCommandHandler(AgentsCommandHandler agentsCommandHandler) : ISlashCommandHandler +{ + /// + public string CommandName => "agent-status"; + + /// + public string Description => "Shows external agent adapter status."; + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + => agentsCommandHandler.ExecuteAsync(new SlashCommandParseResult(true, "agents", ["external", "status"]), context, cancellationToken); +} diff --git a/src/SharpClaw.Code.Commands/Handlers/AgentsCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/AgentsCommandHandler.cs index c8aa72e..69ea1f7 100644 --- a/src/SharpClaw.Code.Commands/Handlers/AgentsCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/AgentsCommandHandler.cs @@ -2,7 +2,9 @@ using System.Text.Json; using SharpClaw.Code.Commands.Models; using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.ExternalAgents.Abstractions; using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Enums; using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Protocol.Serialization; using SharpClaw.Code.Runtime.Abstractions; @@ -14,6 +16,7 @@ namespace SharpClaw.Code.Commands; /// public sealed class AgentsCommandHandler( IAgentCatalogService agentCatalogService, + IExternalAgentService externalAgentService, ReplInteractionState replInteractionState, OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler { @@ -46,6 +49,13 @@ public Command BuildCommand(GlobalCliOptions globalOptions) }); command.Subcommands.Add(use); + var external = new Command("external", "Lists, checks, and runs external agent adapters."); + external.Subcommands.Add(BuildExternalListCommand(globalOptions)); + external.Subcommands.Add(BuildExternalStatusCommand(globalOptions)); + external.Subcommands.Add(BuildExternalRunCommand(globalOptions)); + external.SetAction((parseResult, cancellationToken) => ExecuteExternalListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(external); + command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); return command; } @@ -58,9 +68,56 @@ public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionC return ExecuteUseAsync(command.Arguments[1], context, cancellationToken); } + if (command.Arguments.Length >= 1 && string.Equals(command.Arguments[0], "external", StringComparison.OrdinalIgnoreCase)) + { + if (command.Arguments.Length == 1 || string.Equals(command.Arguments[1], "list", StringComparison.OrdinalIgnoreCase) || string.Equals(command.Arguments[1], "status", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteExternalListAsync(context, cancellationToken); + } + + if (string.Equals(command.Arguments[1], "run", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 4) + { + return ExecuteExternalRunAsync(command.Arguments[2], string.Join(' ', command.Arguments.Skip(3)), ExternalAgentMode.WorkspaceWrite, context, cancellationToken); + } + + return RenderAsync(new CommandResult(false, 1, context.OutputFormat, "Usage: /agents external [list|status|run ]", null), context, cancellationToken); + } + return ExecuteListAsync(context, cancellationToken); } + private Command BuildExternalListCommand(GlobalCliOptions globalOptions) + { + var command = new Command("list", "Lists external agent adapters."); + command.SetAction((parseResult, cancellationToken) => ExecuteExternalListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + private Command BuildExternalStatusCommand(GlobalCliOptions globalOptions) + { + var command = new Command("status", "Shows external agent adapter health."); + command.SetAction((parseResult, cancellationToken) => ExecuteExternalListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + private Command BuildExternalRunCommand(GlobalCliOptions globalOptions) + { + var command = new Command("run", "Runs a prompt through an external agent."); + var adapter = new Option("--adapter") { Required = true, Description = "Adapter id, for example claude or codex." }; + var prompt = new Option("--prompt") { Required = true, Description = "Prompt text." }; + var mode = new Option("--mode") { DefaultValueFactory = _ => "workspaceWrite", Description = "readOnly or workspaceWrite." }; + command.Options.Add(adapter); + command.Options.Add(prompt); + command.Options.Add(mode); + command.SetAction((parseResult, cancellationToken) => ExecuteExternalRunAsync( + parseResult.GetValue(adapter)!, + parseResult.GetValue(prompt)!, + ParseMode(parseResult.GetValue(mode)), + globalOptions.Resolve(parseResult), + cancellationToken)); + return command; + } + private async Task ExecuteListAsync(CommandExecutionContext context, CancellationToken cancellationToken) { var agents = await agentCatalogService.ListAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); @@ -96,4 +153,60 @@ await outputRendererDispatcher.RenderCommandResultAsync( cancellationToken).ConfigureAwait(false); return 0; } + + private async Task ExecuteExternalListAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var report = await externalAgentService.ListAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); + var available = report.Agents.Count(agent => agent.Health == ExternalAgentHealth.Available); + var result = new CommandResult( + true, + 0, + context.OutputFormat, + $"External agents {(report.Enabled ? "enabled" : "disabled")}. {available}/{report.Agents.Count} adapter(s) available.", + JsonSerializer.Serialize(report, ProtocolJsonContext.Default.ExternalAgentCatalogReport)); + return await RenderAsync(result, context, cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteExternalRunAsync( + string adapterId, + string prompt, + ExternalAgentMode mode, + CommandExecutionContext context, + CancellationToken cancellationToken) + { + var result = await externalAgentService + .RunAsync( + new ExternalAgentRunRequest( + adapterId, + context.WorkingDirectory, + prompt, + mode, + context.SessionId, + PermissionMode: context.PermissionMode, + PrimaryMode: context.PrimaryMode, + IsInteractive: context.OutputFormat == OutputFormat.Text), + cancellationToken) + .ConfigureAwait(false); + var success = result.FailureKind == ExternalAgentFailureKind.None; + return await RenderAsync( + new CommandResult( + success, + success ? 0 : 1, + context.OutputFormat, + success ? result.OutputText : result.Error ?? "External agent run failed.", + JsonSerializer.Serialize(result, ProtocolJsonContext.Default.ExternalAgentRunResult)), + context, + cancellationToken).ConfigureAwait(false); + } + + private async Task RenderAsync(CommandResult result, CommandExecutionContext context, CancellationToken cancellationToken) + { + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return result.ExitCode; + } + + private static ExternalAgentMode ParseMode(string? value) + => string.Equals(value, "readOnly", StringComparison.OrdinalIgnoreCase) || string.Equals(value, "read", StringComparison.OrdinalIgnoreCase) + ? ExternalAgentMode.ReadOnly + : ExternalAgentMode.WorkspaceWrite; } diff --git a/src/SharpClaw.Code.Commands/Handlers/CheckpointsSlashCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/CheckpointsSlashCommandHandler.cs new file mode 100644 index 0000000..9c3ed06 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/CheckpointsSlashCommandHandler.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Sessions.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Shows the latest checkpoint for the active session. +/// +public sealed class CheckpointsSlashCommandHandler( + ICheckpointStore checkpointStore, + ISessionStore sessionStore, + OutputRendererDispatcher outputRendererDispatcher) : ISlashCommandHandler +{ + /// + public string CommandName => "checkpoints"; + + /// + public string Description => "Shows the latest runtime checkpoint."; + + /// + public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + var session = string.IsNullOrWhiteSpace(context.SessionId) + ? await sessionStore.GetLatestAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false) + : await sessionStore.GetByIdAsync(context.WorkingDirectory, context.SessionId, cancellationToken).ConfigureAwait(false); + if (session is null) + { + await outputRendererDispatcher.RenderCommandResultAsync(new CommandResult(false, 1, context.OutputFormat, "No session found.", null), context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 1; + } + + var checkpoint = await checkpointStore.GetLatestAsync(context.WorkingDirectory, session.Id, cancellationToken).ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult( + checkpoint is not null, + checkpoint is null ? 1 : 0, + context.OutputFormat, + checkpoint is null ? "No checkpoint found." : $"Latest checkpoint {checkpoint.Id}.", + checkpoint is null ? null : JsonSerializer.Serialize(checkpoint, ProtocolJsonContext.Default.RuntimeCheckpoint)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return checkpoint is null ? 1 : 0; + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/ExternalCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ExternalCommandHandler.cs new file mode 100644 index 0000000..cf9544c --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/ExternalCommandHandler.cs @@ -0,0 +1,111 @@ +using System.CommandLine; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.ExternalAgents.Abstractions; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; + +namespace SharpClaw.Code.Commands; + +/// +/// Top-level command surface for external agent adapters. +/// +public sealed class ExternalCommandHandler( + IExternalAgentService externalAgentService, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler +{ + /// + public string Name => "external"; + + /// + public string Description => "Lists and runs external agent adapters."; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + var list = new Command("list", "Lists external agent adapters."); + list.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(list); + + var status = new Command("status", "Shows external agent adapter health."); + status.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(status); + + var run = new Command("run", "Runs a prompt through an external agent."); + var adapter = new Option("--adapter") { Required = true, Description = "Adapter id." }; + var prompt = new Option("--prompt") { Required = true, Description = "Prompt text." }; + var mode = new Option("--mode") { DefaultValueFactory = _ => "workspaceWrite", Description = "readOnly or workspaceWrite." }; + run.Options.Add(adapter); + run.Options.Add(prompt); + run.Options.Add(mode); + run.SetAction((parseResult, cancellationToken) => ExecuteRunAsync( + parseResult.GetValue(adapter)!, + parseResult.GetValue(prompt)!, + ParseMode(parseResult.GetValue(mode)), + globalOptions.Resolve(parseResult), + cancellationToken)); + command.Subcommands.Add(run); + command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + private async Task ExecuteListAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var report = await externalAgentService.ListAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); + var available = report.Agents.Count(agent => agent.Health == ExternalAgentHealth.Available); + return await RenderAsync( + new CommandResult( + true, + 0, + context.OutputFormat, + $"External agents {(report.Enabled ? "enabled" : "disabled")}. {available}/{report.Agents.Count} adapter(s) available.", + JsonSerializer.Serialize(report, ProtocolJsonContext.Default.ExternalAgentCatalogReport)), + context, + cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteRunAsync( + string adapterId, + string prompt, + ExternalAgentMode mode, + CommandExecutionContext context, + CancellationToken cancellationToken) + { + var result = await externalAgentService.RunAsync( + new ExternalAgentRunRequest( + adapterId, + context.WorkingDirectory, + prompt, + mode, + context.SessionId, + PermissionMode: context.PermissionMode, + PrimaryMode: context.PrimaryMode, + IsInteractive: context.OutputFormat == OutputFormat.Text), + cancellationToken).ConfigureAwait(false); + var success = result.FailureKind == ExternalAgentFailureKind.None; + return await RenderAsync( + new CommandResult( + success, + success ? 0 : 1, + context.OutputFormat, + success ? result.OutputText : result.Error ?? "External agent run failed.", + JsonSerializer.Serialize(result, ProtocolJsonContext.Default.ExternalAgentRunResult)), + context, + cancellationToken).ConfigureAwait(false); + } + + private async Task RenderAsync(CommandResult result, CommandExecutionContext context, CancellationToken cancellationToken) + { + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return result.ExitCode; + } + + private static ExternalAgentMode ParseMode(string? value) + => string.Equals(value, "readOnly", StringComparison.OrdinalIgnoreCase) || string.Equals(value, "read", StringComparison.OrdinalIgnoreCase) + ? ExternalAgentMode.ReadOnly + : ExternalAgentMode.WorkspaceWrite; +} diff --git a/src/SharpClaw.Code.Commands/Handlers/SkillsCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/SkillsCommandHandler.cs index 4894bd3..a66b955 100644 --- a/src/SharpClaw.Code.Commands/Handlers/SkillsCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/SkillsCommandHandler.cs @@ -3,11 +3,15 @@ using SharpClaw.Code.Commands.Models; using SharpClaw.Code.Commands.Options; using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Events; using SharpClaw.Code.Protocol.Commands; using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime.Abstractions; using SharpClaw.Code.Skills.Abstractions; using SharpClaw.Code.Skills.Models; +using SharpClaw.Code.Telemetry; +using SharpClaw.Code.Telemetry.Abstractions; namespace SharpClaw.Code.Commands; @@ -16,6 +20,10 @@ namespace SharpClaw.Code.Commands; /// public sealed class SkillsCommandHandler( ISkillRegistry skillRegistry, + ISkillPackRegistry skillPackRegistry, + IRuntimeCommandService runtimeCommandService, + IRuntimeEventPublisher eventPublisher, + ISystemClock systemClock, IFileSystem fileSystem, OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler { @@ -35,6 +43,9 @@ public Command BuildCommand(GlobalCliOptions globalOptions) command.Subcommands.Add(BuildListCommand(globalOptions)); command.Subcommands.Add(BuildShowCommand(globalOptions)); command.Subcommands.Add(BuildInstallCommand(globalOptions)); + command.Subcommands.Add(BuildEnableCommand(globalOptions)); + command.Subcommands.Add(BuildDisableCommand(globalOptions)); + command.Subcommands.Add(BuildRunCommand(globalOptions)); command.Subcommands.Add(BuildUninstallCommand(globalOptions)); command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); return command; @@ -50,7 +61,13 @@ _ when string.Equals(command.Arguments[0], "list", StringComparison.OrdinalIgnor _ when string.Equals(command.Arguments[0], "show", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 2 => ExecuteShowAsync(command.Arguments[1], context, cancellationToken), _ when string.Equals(command.Arguments[0], "install", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 2 - => ExecuteInstallAsync(command.Arguments[1], context, cancellationToken), + => ExecuteInstallPackAsync(command.Arguments[1], context, cancellationToken), + _ when string.Equals(command.Arguments[0], "enable", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 2 + => ExecuteEnableAsync(command.Arguments[1], context, cancellationToken), + _ when string.Equals(command.Arguments[0], "disable", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 2 + => ExecuteDisableAsync(command.Arguments[1], context, cancellationToken), + _ when string.Equals(command.Arguments[0], "run", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 2 + => ExecuteRunAsync(command.Arguments[1], string.Join(' ', command.Arguments.Skip(2)), null, context, cancellationToken), _ when string.Equals(command.Arguments[0], "uninstall", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 2 => ExecuteUninstallAsync(command.Arguments[1], context, cancellationToken), _ => ExecuteListAsync(context, cancellationToken) @@ -75,9 +92,61 @@ private Command BuildShowCommand(GlobalCliOptions globalOptions) private Command BuildInstallCommand(GlobalCliOptions globalOptions) { var command = new Command("install", "Installs a skill from a JSON manifest."); - var manifestOption = new Option("--manifest") { Required = true, Description = "Path to a serialized SkillInstallRequest JSON document." }; + var pathArgument = new Argument("path") + { + Description = "Path to a skillpack.json file or skill-pack directory.", + Arity = ArgumentArity.ZeroOrOne + }; + var manifestOption = new Option("--manifest") { Description = "Path to a serialized legacy SkillInstallRequest JSON document." }; + var pathOption = new Option("--path") { Description = "Path to a skillpack.json file or skill-pack directory." }; + command.Arguments.Add(pathArgument); command.Options.Add(manifestOption); - command.SetAction((parseResult, cancellationToken) => ExecuteInstallAsync(parseResult.GetValue(manifestOption)!, globalOptions.Resolve(parseResult), cancellationToken)); + command.Options.Add(pathOption); + command.SetAction((parseResult, cancellationToken) => + { + var legacyManifest = parseResult.GetValue(manifestOption); + var path = parseResult.GetValue(pathOption) ?? parseResult.GetValue(pathArgument); + return string.IsNullOrWhiteSpace(path) + ? ExecuteInstallAsync(legacyManifest ?? throw new InvalidOperationException("--manifest or --path is required."), globalOptions.Resolve(parseResult), cancellationToken) + : ExecuteInstallPackAsync(path, globalOptions.Resolve(parseResult), cancellationToken); + }); + return command; + } + + private Command BuildEnableCommand(GlobalCliOptions globalOptions) + { + var command = new Command("enable", "Enables a workspace skill pack."); + var idOption = new Option("--id") { Required = true, Description = "Skill pack id." }; + command.Options.Add(idOption); + command.SetAction((parseResult, cancellationToken) => ExecuteEnableAsync(parseResult.GetValue(idOption)!, globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + private Command BuildDisableCommand(GlobalCliOptions globalOptions) + { + var command = new Command("disable", "Disables a workspace skill pack."); + var idOption = new Option("--id") { Required = true, Description = "Skill pack id." }; + command.Options.Add(idOption); + command.SetAction((parseResult, cancellationToken) => ExecuteDisableAsync(parseResult.GetValue(idOption)!, globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + private Command BuildRunCommand(GlobalCliOptions globalOptions) + { + var command = new Command("run", "Runs a skill pack through the normal runtime prompt pipeline."); + var idArgument = new Argument("id") + { + Description = "Skill pack id.", + Arity = ArgumentArity.ZeroOrOne + }; + var idOption = new Option("--id") { Description = "Skill pack id." }; + var argsOption = new Option("--args") { Description = "Arguments inserted into the skill prompt." }; + var commandOption = new Option("--command") { Description = "Named skill command to run." }; + command.Arguments.Add(idArgument); + command.Options.Add(idOption); + command.Options.Add(argsOption); + command.Options.Add(commandOption); + command.SetAction((parseResult, cancellationToken) => ExecuteRunAsync(parseResult.GetValue(idOption) ?? parseResult.GetValue(idArgument) ?? throw new InvalidOperationException("Skill pack id is required."), parseResult.GetValue(argsOption), parseResult.GetValue(commandOption), globalOptions.Resolve(parseResult), cancellationToken)); return command; } @@ -92,19 +161,32 @@ private Command BuildUninstallCommand(GlobalCliOptions globalOptions) private async Task ExecuteListAsync(CommandExecutionContext context, CancellationToken cancellationToken) { - var skills = await skillRegistry.ListAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); + var skills = await skillPackRegistry.ListAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); var result = new CommandResult( true, 0, context.OutputFormat, skills.Count == 0 ? "No skills installed." : $"{skills.Count} skill(s).", - JsonSerializer.Serialize(skills.ToList(), ProtocolJsonContext.Default.ListSkillDefinition)); + JsonSerializer.Serialize(skills.ToList(), ProtocolJsonContext.Default.ListSkillPack)); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return 0; } private async Task ExecuteShowAsync(string id, CommandExecutionContext context, CancellationToken cancellationToken) { + var pack = await skillPackRegistry.ResolveAsync(context.WorkingDirectory, id, cancellationToken).ConfigureAwait(false); + if (pack is not null) + { + var expanded = await skillPackRegistry + .BuildPromptAsync(context.WorkingDirectory, new SkillPackRunRequest(pack.Manifest.Id, null), cancellationToken) + .ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, $"{pack.Manifest.Id}: {pack.Manifest.Description}", JsonSerializer.Serialize(new SkillPackInspectionRecord(pack, expanded), ProtocolJsonContext.Default.SkillPackInspectionRecord)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } + var skill = await skillRegistry.ResolveAsync(context.WorkingDirectory, id, cancellationToken).ConfigureAwait(false); if (skill is null) { @@ -138,6 +220,57 @@ await outputRendererDispatcher.RenderCommandResultAsync( return 0; } + private async Task ExecuteInstallPackAsync(string sourcePath, CommandExecutionContext context, CancellationToken cancellationToken) + { + var installed = await skillPackRegistry + .InstallAsync(context.WorkingDirectory, new SkillPackInstallRequest(sourcePath), cancellationToken) + .ConfigureAwait(false); + await eventPublisher.PublishAsync( + new SkillPackInstalledEvent($"event_{Guid.NewGuid():N}", context.SessionId ?? "system", null, systemClock.UtcNow, installed.Manifest.Id, installed.Manifest.Version, installed.Source), + cancellationToken: cancellationToken).ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, $"Installed skill pack '{installed.Manifest.Id}'.", JsonSerializer.Serialize(installed, ProtocolJsonContext.Default.SkillPack)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteEnableAsync(string id, CommandExecutionContext context, CancellationToken cancellationToken) + { + var enabled = await skillPackRegistry.EnableAsync(context.WorkingDirectory, id, cancellationToken).ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(enabled, enabled ? 0 : 1, context.OutputFormat, enabled ? $"Enabled skill pack '{id}'." : $"Skill pack '{id}' was not found.", null), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return enabled ? 0 : 1; + } + + private async Task ExecuteDisableAsync(string id, CommandExecutionContext context, CancellationToken cancellationToken) + { + var disabled = await skillPackRegistry.DisableAsync(context.WorkingDirectory, id, cancellationToken).ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(disabled, disabled ? 0 : 1, context.OutputFormat, disabled ? $"Disabled skill pack '{id}'." : $"Skill pack '{id}' was not found.", null), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return disabled ? 0 : 1; + } + + private async Task ExecuteRunAsync(string id, string? arguments, string? commandName, CommandExecutionContext context, CancellationToken cancellationToken) + { + var prompt = await skillPackRegistry + .BuildPromptAsync(context.WorkingDirectory, new SkillPackRunRequest(id, arguments, context.PrimaryMode, commandName), cancellationToken) + .ConfigureAwait(false); + var result = await runtimeCommandService + .ExecutePromptAsync(prompt, context.ToRuntimeCommandContext(), cancellationToken) + .ConfigureAwait(false); + await eventPublisher.PublishAsync( + new SkillInvokedEvent($"event_{Guid.NewGuid():N}", result.Session.Id, result.Turn.Id, systemClock.UtcNow, id, commandName), + new RuntimeEventPublishOptions(context.WorkingDirectory, result.Session.Id, PersistToSessionStore: true), + cancellationToken).ConfigureAwait(false); + await outputRendererDispatcher.RenderTurnExecutionResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } + private async Task ExecuteUninstallAsync(string id, CommandExecutionContext context, CancellationToken cancellationToken) { var removed = await skillRegistry.UninstallAsync(context.WorkingDirectory, id, cancellationToken).ConfigureAwait(false); diff --git a/src/SharpClaw.Code.Commands/Handlers/WorkCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/WorkCommandHandler.cs new file mode 100644 index 0000000..dce2012 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/WorkCommandHandler.cs @@ -0,0 +1,111 @@ +using System.CommandLine; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.WorkItems.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Imports and exports workflow work items. +/// +public sealed class WorkCommandHandler( + IWorkItemService workItemService, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "work"; + + /// + public string Description => "Imports work items and exports session summaries."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + var import = new Command("import", "Imports a GitHub URL or generic JSON fixture into a session."); + var url = new Argument("url") { Description = "Work item URL or JSON fixture path." }; + var provider = new Option("--provider") { DefaultValueFactory = _ => "github", Description = "Provider id: github or generic." }; + import.Arguments.Add(url); + import.Options.Add(provider); + import.SetAction((parseResult, cancellationToken) => ExecuteImportAsync(parseResult.GetValue(provider)!, parseResult.GetValue(url)!, globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(import); + + var show = new Command("show", "Shows the imported work item for the current session."); + show.SetAction((parseResult, cancellationToken) => ExecuteExportSummaryAsync("markdown", globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(show); + + var export = new Command("export-summary", "Exports a session summary for the linked work item."); + var format = new Option("--format") { DefaultValueFactory = _ => "markdown", Description = "markdown or json." }; + export.Options.Add(format); + export.SetAction((parseResult, cancellationToken) => ExecuteExportSummaryAsync(parseResult.GetValue(format)!, globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(export); + command.SetAction((parseResult, cancellationToken) => ExecuteExportSummaryAsync("markdown", globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + if (command.Arguments.Length >= 2 && string.Equals(command.Arguments[0], "import", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteImportAsync("github", command.Arguments[1], context, cancellationToken); + } + + if (command.Arguments.Length == 0 || string.Equals(command.Arguments[0], "show", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteExportSummaryAsync("markdown", context, cancellationToken); + } + + if (string.Equals(command.Arguments[0], "export-summary", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteExportSummaryAsync("markdown", context, cancellationToken); + } + + return RenderAsync(new CommandResult(false, 1, context.OutputFormat, "Usage: /work [import |show|export-summary]", null), context, cancellationToken); + } + + private async Task ExecuteImportAsync(string provider, string idOrUrl, CommandExecutionContext context, CancellationToken cancellationToken) + { + var result = await workItemService + .ImportAsync(new WorkItemImportRequest(provider, idOrUrl, context.WorkingDirectory, SessionId: context.SessionId), cancellationToken) + .ConfigureAwait(false); + return await RenderAsync( + new CommandResult( + true, + 0, + context.OutputFormat, + $"Imported {result.WorkItem.Provider} work item '{result.WorkItem.Id}' into session {result.SessionId}.", + JsonSerializer.Serialize(result, ProtocolJsonContext.Default.WorkItemImportResult)), + context, + cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteExportSummaryAsync(string format, CommandExecutionContext context, CancellationToken cancellationToken) + { + var result = await workItemService + .ExportSummaryAsync(new WorkItemExportRequest("github", context.SessionId, null, format), context.WorkingDirectory, cancellationToken) + .ConfigureAwait(false); + return await RenderAsync( + new CommandResult( + true, + 0, + context.OutputFormat, + result.Content, + JsonSerializer.Serialize(result, ProtocolJsonContext.Default.WorkItemSummaryExport)), + context, + cancellationToken).ConfigureAwait(false); + } + + private async Task RenderAsync(CommandResult result, CommandExecutionContext context, CancellationToken cancellationToken) + { + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return result.ExitCode; + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/WorkbenchCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/WorkbenchCommandHandler.cs new file mode 100644 index 0000000..7c422ab --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/WorkbenchCommandHandler.cs @@ -0,0 +1,46 @@ +using System.CommandLine; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Shows a static runtime workbench view. +/// +public sealed class WorkbenchCommandHandler( + IWorkbenchStatusService workbenchStatusService, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "workbench"; + + /// + public string Description => "Shows sessions, approvals, checkpoints, tool activity, and adapter health."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + command.SetAction((parseResult, cancellationToken) => ExecuteAsync(new SlashCommandParseResult(true, Name, []), globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + /// + public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + var report = await workbenchStatusService.BuildAsync(context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false); + var message = $"Session {report.CurrentSessionId ?? "none"} · mode {report.PrimaryMode} · agent {report.ActiveAgentId ?? "default"} · checkpoint {report.LatestCheckpoint?.Id ?? "none"} · external {report.ExternalAgents.Count(agent => agent.Health == Protocol.Models.ExternalAgentHealth.Available)}/{report.ExternalAgents.Count}"; + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, message, JsonSerializer.Serialize(report, ProtocolJsonContext.Default.WorkbenchStatusReport)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } +} diff --git a/src/SharpClaw.Code.Commands/SharpClaw.Code.Commands.csproj b/src/SharpClaw.Code.Commands/SharpClaw.Code.Commands.csproj index ad44836..7d5d1a4 100644 --- a/src/SharpClaw.Code.Commands/SharpClaw.Code.Commands.csproj +++ b/src/SharpClaw.Code.Commands/SharpClaw.Code.Commands.csproj @@ -10,6 +10,7 @@ + @@ -18,6 +19,7 @@ + diff --git a/src/SharpClaw.Code.ExternalAgents/Abstractions/IExternalAgentAdapter.cs b/src/SharpClaw.Code.ExternalAgents/Abstractions/IExternalAgentAdapter.cs new file mode 100644 index 0000000..bbb8d5c --- /dev/null +++ b/src/SharpClaw.Code.ExternalAgents/Abstractions/IExternalAgentAdapter.cs @@ -0,0 +1,24 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.ExternalAgents.Abstractions; + +/// +/// Adapter for one external agent CLI. +/// +public interface IExternalAgentAdapter +{ + /// + /// Gets the adapter descriptor. + /// + ExternalAgentDescriptor Descriptor { get; } + + /// + /// Probes the adapter status. + /// + Task GetStatusAsync(string workspaceRoot, CancellationToken cancellationToken); + + /// + /// Runs a prompt through the external agent. + /// + Task RunAsync(ExternalAgentRunRequest request, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.ExternalAgents/Abstractions/IExternalAgentConfigProvider.cs b/src/SharpClaw.Code.ExternalAgents/Abstractions/IExternalAgentConfigProvider.cs new file mode 100644 index 0000000..a7a6981 --- /dev/null +++ b/src/SharpClaw.Code.ExternalAgents/Abstractions/IExternalAgentConfigProvider.cs @@ -0,0 +1,14 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.ExternalAgents.Abstractions; + +/// +/// Supplies merged external agent configuration for a workspace. +/// +public interface IExternalAgentConfigProvider +{ + /// + /// Gets external agent configuration for a workspace. + /// + Task GetConfigAsync(string workspaceRoot, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.ExternalAgents/Abstractions/IExternalAgentExecutableResolver.cs b/src/SharpClaw.Code.ExternalAgents/Abstractions/IExternalAgentExecutableResolver.cs new file mode 100644 index 0000000..e535257 --- /dev/null +++ b/src/SharpClaw.Code.ExternalAgents/Abstractions/IExternalAgentExecutableResolver.cs @@ -0,0 +1,12 @@ +namespace SharpClaw.Code.ExternalAgents.Abstractions; + +/// +/// Resolves an executable path from configuration or PATH. +/// +public interface IExternalAgentExecutableResolver +{ + /// + /// Resolves the executable, returning null when it is unavailable. + /// + string? Resolve(string executableNameOrPath); +} diff --git a/src/SharpClaw.Code.ExternalAgents/Abstractions/IExternalAgentRegistry.cs b/src/SharpClaw.Code.ExternalAgents/Abstractions/IExternalAgentRegistry.cs new file mode 100644 index 0000000..f4d8a8c --- /dev/null +++ b/src/SharpClaw.Code.ExternalAgents/Abstractions/IExternalAgentRegistry.cs @@ -0,0 +1,24 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.ExternalAgents.Abstractions; + +/// +/// Registry of available external agent adapters. +/// +public interface IExternalAgentRegistry +{ + /// + /// Lists registered adapters. + /// + IReadOnlyList ListAdapters(); + + /// + /// Resolves an adapter by id. + /// + IExternalAgentAdapter? Resolve(string adapterId); + + /// + /// Builds a status report. + /// + Task BuildReportAsync(string workspaceRoot, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.ExternalAgents/Abstractions/IExternalAgentService.cs b/src/SharpClaw.Code.ExternalAgents/Abstractions/IExternalAgentService.cs new file mode 100644 index 0000000..043d99a --- /dev/null +++ b/src/SharpClaw.Code.ExternalAgents/Abstractions/IExternalAgentService.cs @@ -0,0 +1,19 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.ExternalAgents.Abstractions; + +/// +/// Permission-aware external agent execution service. +/// +public interface IExternalAgentService +{ + /// + /// Lists external agent statuses. + /// + Task ListAsync(string workspaceRoot, CancellationToken cancellationToken); + + /// + /// Runs an external agent and persists session events. + /// + Task RunAsync(ExternalAgentRunRequest request, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.ExternalAgents/Adapters/BuiltInExternalAgentAdapters.cs b/src/SharpClaw.Code.ExternalAgents/Adapters/BuiltInExternalAgentAdapters.cs new file mode 100644 index 0000000..d7a490b --- /dev/null +++ b/src/SharpClaw.Code.ExternalAgents/Adapters/BuiltInExternalAgentAdapters.cs @@ -0,0 +1,87 @@ +using SharpClaw.Code.ExternalAgents.Abstractions; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.ExternalAgents.Adapters; + +/// +/// Claude Code text-mode adapter. +/// +public sealed class ClaudeCodeAdapter(IExternalAgentExecutableResolver executableResolver, IProcessRunner processRunner, IExternalAgentConfigProvider configProvider) + : ExternalAgentAdapterBase(executableResolver, processRunner, configProvider) +{ + /// + public override ExternalAgentDescriptor Descriptor { get; } = Create("claude", "Claude Code", "claude", supportsResume: true); + + /// + protected override string[] BuildArguments(ExternalAgentRunRequest request) => ["-p", request.Prompt]; + + private static ExternalAgentDescriptor Create(string id, string name, string executable, bool supportsResume) + => new(id, name, executable, [ExternalAgentMode.ReadOnly, ExternalAgentMode.WorkspaceWrite], false, false, true, supportsResume, ["textPrompt"]); +} + +/// +/// OpenCode text-mode adapter. +/// +public sealed class OpenCodeAdapter(IExternalAgentExecutableResolver executableResolver, IProcessRunner processRunner, IExternalAgentConfigProvider configProvider) + : ExternalAgentAdapterBase(executableResolver, processRunner, configProvider) +{ + /// + public override ExternalAgentDescriptor Descriptor { get; } = new( + "opencode", + "OpenCode", + "opencode", + [ExternalAgentMode.ReadOnly, ExternalAgentMode.WorkspaceWrite], + false, + false, + true, + true, + ["textPrompt"]); + + /// + protected override string[] BuildArguments(ExternalAgentRunRequest request) => ["run", request.Prompt]; +} + +/// +/// Gemini CLI text-mode adapter. +/// +public sealed class GeminiCliAdapter(IExternalAgentExecutableResolver executableResolver, IProcessRunner processRunner, IExternalAgentConfigProvider configProvider) + : ExternalAgentAdapterBase(executableResolver, processRunner, configProvider) +{ + /// + public override ExternalAgentDescriptor Descriptor { get; } = new( + "gemini", + "Gemini CLI", + "gemini", + [ExternalAgentMode.ReadOnly, ExternalAgentMode.WorkspaceWrite], + false, + false, + true, + false, + ["textPrompt"]); + + /// + protected override string[] BuildArguments(ExternalAgentRunRequest request) => ["--prompt", request.Prompt]; +} + +/// +/// Codex CLI text-mode adapter. +/// +public sealed class CodexCliAdapter(IExternalAgentExecutableResolver executableResolver, IProcessRunner processRunner, IExternalAgentConfigProvider configProvider) + : ExternalAgentAdapterBase(executableResolver, processRunner, configProvider) +{ + /// + public override ExternalAgentDescriptor Descriptor { get; } = new( + "codex", + "Codex CLI", + "codex", + [ExternalAgentMode.ReadOnly, ExternalAgentMode.WorkspaceWrite], + false, + false, + true, + true, + ["textPrompt"]); + + /// + protected override string[] BuildArguments(ExternalAgentRunRequest request) => ["exec", request.Prompt]; +} diff --git a/src/SharpClaw.Code.ExternalAgents/Adapters/ExternalAgentAdapterBase.cs b/src/SharpClaw.Code.ExternalAgents/Adapters/ExternalAgentAdapterBase.cs new file mode 100644 index 0000000..60159f9 --- /dev/null +++ b/src/SharpClaw.Code.ExternalAgents/Adapters/ExternalAgentAdapterBase.cs @@ -0,0 +1,132 @@ +using SharpClaw.Code.ExternalAgents.Abstractions; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Infrastructure.Models; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.ExternalAgents.Adapters; + +/// +/// Base implementation for text-mode CLI adapters. +/// +public abstract class ExternalAgentAdapterBase( + IExternalAgentExecutableResolver executableResolver, + IProcessRunner processRunner, + IExternalAgentConfigProvider configProvider) : IExternalAgentAdapter +{ + /// + public abstract ExternalAgentDescriptor Descriptor { get; } + + /// + /// Gets the configured adapter executable path. + /// + protected virtual string ResolveExecutable(ExternalAgentAdapterConfig? config) => config?.ExecutablePath ?? Descriptor.ExecutableName; + + /// + /// Builds process arguments. + /// + protected abstract string[] BuildArguments(ExternalAgentRunRequest request); + + /// + public virtual async Task GetStatusAsync(string workspaceRoot, CancellationToken cancellationToken) + { + var config = await configProvider.GetConfigAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + var adapterConfig = FindAdapterConfig(config); + var enabled = config.Enabled && (adapterConfig?.Enabled ?? true); + if (!enabled) + { + return new ExternalAgentStatus(Descriptor, ExternalAgentHealth.Disabled, false, null, "Adapter is disabled by configuration."); + } + + var path = executableResolver.Resolve(ResolveExecutable(adapterConfig)); + return new ExternalAgentStatus( + Descriptor, + path is null ? ExternalAgentHealth.Missing : ExternalAgentHealth.Available, + Enabled: enabled, + ExecutablePath: path, + Detail: path is null ? $"Executable '{ResolveExecutable(adapterConfig)}' was not found." : "Executable found."); + } + + /// + public async Task RunAsync(ExternalAgentRunRequest request, CancellationToken cancellationToken) + { + var config = await configProvider.GetConfigAsync(request.WorkspacePath, cancellationToken).ConfigureAwait(false); + var adapterConfig = FindAdapterConfig(config); + var enabled = config.Enabled && (adapterConfig?.Enabled ?? true); + if (!enabled) + { + return Failure(request.AdapterId, ExternalAgentFailureKind.Disabled, "External agent adapter is disabled."); + } + + var executable = executableResolver.Resolve(ResolveExecutable(adapterConfig)); + if (executable is null) + { + return Failure(request.AdapterId, ExternalAgentFailureKind.ExecutableMissing, $"Executable '{Descriptor.ExecutableName}' was not found."); + } + + try + { + var result = await processRunner + .RunAsync( + new ProcessRunRequest( + executable, + MergeArguments(adapterConfig, request), + request.WorkspacePath, + request.Environment), + cancellationToken) + .ConfigureAwait(false); + var output = string.IsNullOrWhiteSpace(result.StandardOutput) + ? result.StandardError + : string.IsNullOrWhiteSpace(result.StandardError) + ? result.StandardOutput + : result.StandardOutput + Environment.NewLine + result.StandardError; + var failure = result.ExitCode == 0 ? ExternalAgentFailureKind.None : ExternalAgentFailureKind.ProcessFailed; + return new ExternalAgentRunResult( + request.AdapterId, + result.ExitCode, + output, + [], + null, + failure, + failure == ExternalAgentFailureKind.None ? null : Truncate(result.StandardError)); + } + catch (OperationCanceledException) + { + return Failure(request.AdapterId, ExternalAgentFailureKind.Cancelled, "External agent run was cancelled."); + } + catch (Exception ex) + { + return Failure(request.AdapterId, ExternalAgentFailureKind.Unexpected, ex.Message); + } + } + + /// + /// Creates a stable failed result. + /// + protected static ExternalAgentRunResult Failure(string adapterId, ExternalAgentFailureKind kind, string error) + => new(adapterId, 1, string.Empty, [], null, kind, error); + + private ExternalAgentAdapterConfig? FindAdapterConfig(ExternalAgentsConfig config) + => config.Adapters is not null && config.Adapters.TryGetValue(Descriptor.Id, out var adapterConfig) + ? adapterConfig + : null; + + private string[] MergeArguments(ExternalAgentAdapterConfig? adapterConfig, ExternalAgentRunRequest request) + { + var defaultArgs = adapterConfig?.DefaultArgs ?? []; + var requestArgs = request.AdditionalArgs ?? []; + return [.. defaultArgs, .. BuildArguments(request), .. requestArgs]; + } + + /// + /// Truncates text for operational summaries. + /// + protected static string Truncate(string? value, int max = 800) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + return value.Length <= max ? value : value[..max]; + } +} diff --git a/src/SharpClaw.Code.ExternalAgents/AssemblyMarker.cs b/src/SharpClaw.Code.ExternalAgents/AssemblyMarker.cs new file mode 100644 index 0000000..f45c522 --- /dev/null +++ b/src/SharpClaw.Code.ExternalAgents/AssemblyMarker.cs @@ -0,0 +1,6 @@ +namespace SharpClaw.Code.ExternalAgents; + +/// +/// Marker type for external agent assemblies. +/// +public sealed class AssemblyMarker; diff --git a/src/SharpClaw.Code.ExternalAgents/ExternalAgentsServiceCollectionExtensions.cs b/src/SharpClaw.Code.ExternalAgents/ExternalAgentsServiceCollectionExtensions.cs new file mode 100644 index 0000000..22db801 --- /dev/null +++ b/src/SharpClaw.Code.ExternalAgents/ExternalAgentsServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.ExternalAgents.Abstractions; +using SharpClaw.Code.ExternalAgents.Adapters; +using SharpClaw.Code.ExternalAgents.Services; +using SharpClaw.Code.Infrastructure; + +namespace SharpClaw.Code.ExternalAgents; + +/// +/// Registers external agent adapter services. +/// +public static class ExternalAgentsServiceCollectionExtensions +{ + /// + /// Adds SharpClaw external agent adapters. + /// + public static IServiceCollection AddSharpClawExternalAgents(this IServiceCollection services) + { + services.AddSharpClawInfrastructure(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/SharpClaw.Code.ExternalAgents/Services/DefaultExternalAgentConfigProvider.cs b/src/SharpClaw.Code.ExternalAgents/Services/DefaultExternalAgentConfigProvider.cs new file mode 100644 index 0000000..f3c088d --- /dev/null +++ b/src/SharpClaw.Code.ExternalAgents/Services/DefaultExternalAgentConfigProvider.cs @@ -0,0 +1,18 @@ +using SharpClaw.Code.ExternalAgents.Abstractions; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.ExternalAgents.Services; + +/// +/// Default external agent configuration used when runtime config is unavailable. +/// +public sealed class DefaultExternalAgentConfigProvider : IExternalAgentConfigProvider +{ + /// + public Task GetConfigAsync(string workspaceRoot, CancellationToken cancellationToken) + { + _ = workspaceRoot; + _ = cancellationToken; + return Task.FromResult(new ExternalAgentsConfig(Enabled: true)); + } +} diff --git a/src/SharpClaw.Code.ExternalAgents/Services/ExternalAgentRegistry.cs b/src/SharpClaw.Code.ExternalAgents/Services/ExternalAgentRegistry.cs new file mode 100644 index 0000000..c72f5d6 --- /dev/null +++ b/src/SharpClaw.Code.ExternalAgents/Services/ExternalAgentRegistry.cs @@ -0,0 +1,34 @@ +using SharpClaw.Code.ExternalAgents.Abstractions; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.ExternalAgents.Services; + +/// +/// Default in-process external agent adapter registry. +/// +public sealed class ExternalAgentRegistry( + IEnumerable adapters, + IExternalAgentConfigProvider configProvider) : IExternalAgentRegistry +{ + private readonly IExternalAgentAdapter[] orderedAdapters = adapters.OrderBy(adapter => adapter.Descriptor.Id, StringComparer.OrdinalIgnoreCase).ToArray(); + + /// + public IReadOnlyList ListAdapters() => orderedAdapters; + + /// + public IExternalAgentAdapter? Resolve(string adapterId) + => orderedAdapters.FirstOrDefault(adapter => string.Equals(adapter.Descriptor.Id, adapterId, StringComparison.OrdinalIgnoreCase)); + + /// + public async Task BuildReportAsync(string workspaceRoot, CancellationToken cancellationToken) + { + var config = await configProvider.GetConfigAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + var statuses = new List(orderedAdapters.Length); + foreach (var adapter in orderedAdapters) + { + statuses.Add(await adapter.GetStatusAsync(workspaceRoot, cancellationToken).ConfigureAwait(false)); + } + + return new ExternalAgentCatalogReport(config.Enabled, config.RequireApprovalForMutatingRuns, statuses); + } +} diff --git a/src/SharpClaw.Code.ExternalAgents/Services/ExternalAgentService.cs b/src/SharpClaw.Code.ExternalAgents/Services/ExternalAgentService.cs new file mode 100644 index 0000000..0554952 --- /dev/null +++ b/src/SharpClaw.Code.ExternalAgents/Services/ExternalAgentService.cs @@ -0,0 +1,212 @@ +using System.Text.Json; +using SharpClaw.Code.ExternalAgents.Abstractions; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Permissions.Abstractions; +using SharpClaw.Code.Permissions.Models; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Sessions.Abstractions; +using SharpClaw.Code.Telemetry; +using SharpClaw.Code.Telemetry.Abstractions; + +namespace SharpClaw.Code.ExternalAgents.Services; + +/// +/// Coordinates external agent runs with permissions, sessions, and runtime events. +/// +public sealed class ExternalAgentService( + IExternalAgentRegistry registry, + IExternalAgentConfigProvider configProvider, + IPermissionPolicyEngine permissionPolicyEngine, + ISessionStore sessionStore, + IRuntimeEventPublisher eventPublisher, + IPathService pathService, + ISystemClock systemClock) : IExternalAgentService +{ + /// + public Task ListAsync(string workspaceRoot, CancellationToken cancellationToken) + => registry.BuildReportAsync(workspaceRoot, cancellationToken); + + /// + public async Task RunAsync(ExternalAgentRunRequest request, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(request.AdapterId); + ArgumentException.ThrowIfNullOrWhiteSpace(request.WorkspacePath); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Prompt); + + var workspace = pathService.GetFullPath(request.WorkspacePath); + var adapter = registry.Resolve(request.AdapterId); + if (adapter is null) + { + return Failure(request.AdapterId, ExternalAgentFailureKind.UnknownAdapter, $"External agent adapter '{request.AdapterId}' is not registered."); + } + + var session = await ResolveSessionAsync(workspace, request, cancellationToken).ConfigureAwait(false); + var config = await configProvider.GetConfigAsync(workspace, cancellationToken).ConfigureAwait(false); + if (request.Mode == ExternalAgentMode.WorkspaceWrite && config.RequireApprovalForMutatingRuns) + { + var permission = await permissionPolicyEngine + .EvaluateAsync( + CreateToolRequest(request, session.Id), + new PermissionEvaluationContext( + session.Id, + workspace, + workspace, + request.PermissionMode, + AllowedTools: null, + AllowDangerousBypass: request.PermissionMode == PermissionMode.DangerFullAccess, + IsInteractive: request.IsInteractive, + SourceKind: PermissionRequestSourceKind.Runtime, + SourceName: "external-agents", + TrustedPluginNames: null, + TrustedMcpServerNames: null, + PrimaryMode: request.PrimaryMode), + cancellationToken) + .ConfigureAwait(false); + if (!permission.IsAllowed) + { + var denied = Failure(request.AdapterId, ExternalAgentFailureKind.PermissionDenied, permission.Reason ?? "External agent execution was denied."); + await PublishFailedAsync(workspace, session.Id, request.AdapterId, denied, cancellationToken).ConfigureAwait(false); + return denied; + } + } + + await PublishAsync( + workspace, + session.Id, + new ExternalAgentRunStartedEvent( + CreateIdentifier("event"), + session.Id, + null, + systemClock.UtcNow, + request.AdapterId, + workspace, + request.Mode), + cancellationToken).ConfigureAwait(false); + + var result = await adapter.RunAsync(request with { WorkspacePath = workspace, SessionId = session.Id }, cancellationToken).ConfigureAwait(false); + if (result.FailureKind is ExternalAgentFailureKind.None) + { + await PublishAsync( + workspace, + session.Id, + new ExternalAgentRunCompletedEvent( + CreateIdentifier("event"), + session.Id, + null, + systemClock.UtcNow, + request.AdapterId, + result.ExitCode, + result.ExternalSessionId, + Truncate(result.OutputText)), + cancellationToken).ConfigureAwait(false); + await SaveSessionMetadataAsync(workspace, session, request.AdapterId, cancellationToken).ConfigureAwait(false); + } + else + { + await PublishFailedAsync(workspace, session.Id, request.AdapterId, result, cancellationToken).ConfigureAwait(false); + } + + return result; + } + + private async Task ResolveSessionAsync(string workspace, ExternalAgentRunRequest request, CancellationToken cancellationToken) + { + ConversationSession? session = null; + if (!string.IsNullOrWhiteSpace(request.SessionId)) + { + session = await sessionStore.GetByIdAsync(workspace, request.SessionId, cancellationToken).ConfigureAwait(false); + } + + session ??= await sessionStore.GetLatestAsync(workspace, cancellationToken).ConfigureAwait(false); + if (session is not null) + { + return session; + } + + var created = new ConversationSession( + CreateIdentifier("session"), + "External agent session", + SessionLifecycleState.Active, + request.PermissionMode, + OutputFormat.Text, + workspace, + workspace, + systemClock.UtcNow, + systemClock.UtcNow, + null, + null, + new Dictionary()); + await sessionStore.SaveAsync(workspace, created, cancellationToken).ConfigureAwait(false); + await PublishAsync( + workspace, + created.Id, + new SessionCreatedEvent(CreateIdentifier("event"), created.Id, null, systemClock.UtcNow, created), + cancellationToken).ConfigureAwait(false); + return created; + } + + private static ToolExecutionRequest CreateToolRequest(ExternalAgentRunRequest request, string sessionId) + => new( + Guid.NewGuid().ToString("N"), + sessionId, + "external-agent", + $"external-agent:{request.AdapterId}", + JsonSerializer.Serialize( + new Dictionary + { + ["adapterId"] = request.AdapterId, + ["mode"] = request.Mode.ToString() + }, + ProtocolJsonContext.Default.DictionaryStringString), + ApprovalScope.ShellExecution, + request.WorkspacePath, + RequiresApproval: true, + IsDestructive: request.Mode == ExternalAgentMode.WorkspaceWrite); + + private async Task SaveSessionMetadataAsync(string workspace, ConversationSession session, string adapterId, CancellationToken cancellationToken) + { + var metadata = session.Metadata is null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(session.Metadata, StringComparer.Ordinal); + metadata[SharpClawWorkflowMetadataKeys.LastExternalAgentId] = adapterId; + await sessionStore.SaveAsync(workspace, session with { Metadata = metadata, UpdatedAtUtc = systemClock.UtcNow }, cancellationToken).ConfigureAwait(false); + } + + private async Task PublishFailedAsync(string workspace, string sessionId, string adapterId, ExternalAgentRunResult result, CancellationToken cancellationToken) + => await PublishAsync( + workspace, + sessionId, + new ExternalAgentRunFailedEvent( + CreateIdentifier("event"), + sessionId, + null, + systemClock.UtcNow, + adapterId, + result.FailureKind, + result.Error ?? "External agent run failed."), + cancellationToken).ConfigureAwait(false); + + private ValueTask PublishAsync(string workspace, string sessionId, RuntimeEvent runtimeEvent, CancellationToken cancellationToken) + => eventPublisher.PublishAsync( + runtimeEvent, + new RuntimeEventPublishOptions(workspace, sessionId, PersistToSessionStore: true), + cancellationToken); + + private static ExternalAgentRunResult Failure(string adapterId, ExternalAgentFailureKind kind, string error) + => new(adapterId, 1, string.Empty, [], null, kind, error); + + private static string CreateIdentifier(string prefix) => $"{prefix}_{Guid.NewGuid():N}"; + + private static string Truncate(string? value, int max = 1000) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + return value.Length <= max ? value : value[..max]; + } +} diff --git a/src/SharpClaw.Code.ExternalAgents/Services/PathExternalAgentExecutableResolver.cs b/src/SharpClaw.Code.ExternalAgents/Services/PathExternalAgentExecutableResolver.cs new file mode 100644 index 0000000..f77c3cf --- /dev/null +++ b/src/SharpClaw.Code.ExternalAgents/Services/PathExternalAgentExecutableResolver.cs @@ -0,0 +1,43 @@ +using SharpClaw.Code.ExternalAgents.Abstractions; + +namespace SharpClaw.Code.ExternalAgents.Services; + +/// +/// Resolves external agent executables using absolute paths and PATH lookup. +/// +public sealed class PathExternalAgentExecutableResolver : IExternalAgentExecutableResolver +{ + /// + public string? Resolve(string executableNameOrPath) + { + if (string.IsNullOrWhiteSpace(executableNameOrPath)) + { + return null; + } + + if (Path.IsPathFullyQualified(executableNameOrPath) || executableNameOrPath.Contains(Path.DirectorySeparatorChar) || executableNameOrPath.Contains(Path.AltDirectorySeparatorChar)) + { + return File.Exists(executableNameOrPath) ? executableNameOrPath : null; + } + + var paths = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var extensions = OperatingSystem.IsWindows() + ? (Environment.GetEnvironmentVariable("PATHEXT") ?? ".EXE;.CMD;.BAT").Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + : [string.Empty]; + + foreach (var directory in paths) + { + foreach (var extension in extensions) + { + var candidate = Path.Combine(directory, executableNameOrPath + extension); + if (File.Exists(candidate)) + { + return candidate; + } + } + } + + return null; + } +} diff --git a/src/SharpClaw.Code.ExternalAgents/SharpClaw.Code.ExternalAgents.csproj b/src/SharpClaw.Code.ExternalAgents/SharpClaw.Code.ExternalAgents.csproj new file mode 100644 index 0000000..390daca --- /dev/null +++ b/src/SharpClaw.Code.ExternalAgents/SharpClaw.Code.ExternalAgents.csproj @@ -0,0 +1,20 @@ + + + + External agent adapter framework for SharpClaw Code. + + + + + + + + + + + + + + + + diff --git a/src/SharpClaw.Code.Protocol/Events/ExternalAgentEvents.cs b/src/SharpClaw.Code.Protocol/Events/ExternalAgentEvents.cs new file mode 100644 index 0000000..f472c40 --- /dev/null +++ b/src/SharpClaw.Code.Protocol/Events/ExternalAgentEvents.cs @@ -0,0 +1,40 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Protocol.Events; + +/// +/// Raised before an external agent process is invoked. +/// +public sealed record ExternalAgentRunStartedEvent( + string EventId, + string SessionId, + string? TurnId, + DateTimeOffset OccurredAtUtc, + string AdapterId, + string WorkspacePath, + ExternalAgentMode Mode) : RuntimeEvent(EventId, SessionId, TurnId, OccurredAtUtc); + +/// +/// Raised after an external agent process completes successfully. +/// +public sealed record ExternalAgentRunCompletedEvent( + string EventId, + string SessionId, + string? TurnId, + DateTimeOffset OccurredAtUtc, + string AdapterId, + int ExitCode, + string? ExternalSessionId, + string OutputPreview) : RuntimeEvent(EventId, SessionId, TurnId, OccurredAtUtc); + +/// +/// Raised when an external agent process cannot run or exits unsuccessfully. +/// +public sealed record ExternalAgentRunFailedEvent( + string EventId, + string SessionId, + string? TurnId, + DateTimeOffset OccurredAtUtc, + string AdapterId, + ExternalAgentFailureKind FailureKind, + string Error) : RuntimeEvent(EventId, SessionId, TurnId, OccurredAtUtc); diff --git a/src/SharpClaw.Code.Protocol/Events/RuntimeEvent.cs b/src/SharpClaw.Code.Protocol/Events/RuntimeEvent.cs index 7426a57..62b6a4b 100644 --- a/src/SharpClaw.Code.Protocol/Events/RuntimeEvent.cs +++ b/src/SharpClaw.Code.Protocol/Events/RuntimeEvent.cs @@ -43,6 +43,14 @@ namespace SharpClaw.Code.Protocol.Events; [JsonDerivedType(typeof(RedoRequestedEvent), "redoRequested")] [JsonDerivedType(typeof(RedoCompletedEvent), "redoCompleted")] [JsonDerivedType(typeof(RedoFailedEvent), "redoFailed")] +[JsonDerivedType(typeof(ExternalAgentRunStartedEvent), "externalAgentRunStarted")] +[JsonDerivedType(typeof(ExternalAgentRunCompletedEvent), "externalAgentRunCompleted")] +[JsonDerivedType(typeof(ExternalAgentRunFailedEvent), "externalAgentRunFailed")] +[JsonDerivedType(typeof(SkillPackInstalledEvent), "skillPackInstalled")] +[JsonDerivedType(typeof(SkillInvokedEvent), "skillInvoked")] +[JsonDerivedType(typeof(WorkItemImportedEvent), "workItemImported")] +[JsonDerivedType(typeof(WorkItemSummaryExportedEvent), "workItemSummaryExported")] +[JsonDerivedType(typeof(WorkbenchViewedEvent), "workbenchViewed")] public abstract record RuntimeEvent( string EventId, string SessionId, diff --git a/src/SharpClaw.Code.Protocol/Events/SkillAndWorkItemEvents.cs b/src/SharpClaw.Code.Protocol/Events/SkillAndWorkItemEvents.cs new file mode 100644 index 0000000..7997157 --- /dev/null +++ b/src/SharpClaw.Code.Protocol/Events/SkillAndWorkItemEvents.cs @@ -0,0 +1,57 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Protocol.Events; + +/// +/// Raised when a skill pack is installed. +/// +public sealed record SkillPackInstalledEvent( + string EventId, + string SessionId, + string? TurnId, + DateTimeOffset OccurredAtUtc, + string SkillId, + string Version, + string Source) : RuntimeEvent(EventId, SessionId, TurnId, OccurredAtUtc); + +/// +/// Raised when a skill pack is invoked. +/// +public sealed record SkillInvokedEvent( + string EventId, + string SessionId, + string? TurnId, + DateTimeOffset OccurredAtUtc, + string SkillId, + string? CommandName) : RuntimeEvent(EventId, SessionId, TurnId, OccurredAtUtc); + +/// +/// Raised when a work item is imported into a session. +/// +public sealed record WorkItemImportedEvent( + string EventId, + string SessionId, + string? TurnId, + DateTimeOffset OccurredAtUtc, + WorkItem WorkItem) : RuntimeEvent(EventId, SessionId, TurnId, OccurredAtUtc); + +/// +/// Raised when a work-item-aware summary is generated. +/// +public sealed record WorkItemSummaryExportedEvent( + string EventId, + string SessionId, + string? TurnId, + DateTimeOffset OccurredAtUtc, + string Provider, + string ExportFormat) : RuntimeEvent(EventId, SessionId, TurnId, OccurredAtUtc); + +/// +/// Raised when a static workbench view is assembled. +/// +public sealed record WorkbenchViewedEvent( + string EventId, + string SessionId, + string? TurnId, + DateTimeOffset OccurredAtUtc, + string WorkspaceRoot) : RuntimeEvent(EventId, SessionId, TurnId, OccurredAtUtc); diff --git a/src/SharpClaw.Code.Protocol/Models/ExternalAgentModels.cs b/src/SharpClaw.Code.Protocol/Models/ExternalAgentModels.cs new file mode 100644 index 0000000..5f422c6 --- /dev/null +++ b/src/SharpClaw.Code.Protocol/Models/ExternalAgentModels.cs @@ -0,0 +1,153 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SharpClaw.Code.Protocol.Enums; + +namespace SharpClaw.Code.Protocol.Models; + +/// +/// External agent execution mode. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ExternalAgentMode +{ + /// Read-oriented prompt execution. + ReadOnly, + + /// Workspace-mutating prompt execution. + WorkspaceWrite, +} + +/// +/// Coarse external agent health state. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ExternalAgentHealth +{ + /// The adapter is disabled by configuration. + Disabled, + + /// The configured executable is available. + Available, + + /// The configured executable is missing. + Missing, + + /// The adapter probe failed unexpectedly. + Faulted, +} + +/// +/// Failure classification for external agent execution. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ExternalAgentFailureKind +{ + /// No failure was recorded. + None, + + /// The requested adapter is not registered. + UnknownAdapter, + + /// The adapter or external-agent feature is disabled. + Disabled, + + /// The executable could not be found. + ExecutableMissing, + + /// Permission policy blocked execution. + PermissionDenied, + + /// The process exited unsuccessfully. + ProcessFailed, + + /// The operation was cancelled. + Cancelled, + + /// An unexpected runtime error occurred. + Unexpected, +} + +/// +/// Describes one external agent adapter. +/// +public sealed record ExternalAgentDescriptor( + string Id, + string DisplayName, + string ExecutableName, + IReadOnlyList SupportedModes, + bool SupportsStreaming, + bool SupportsJson, + bool SupportsWorkspace, + bool SupportsResume, + IReadOnlyList Capabilities); + +/// +/// Resolved external agent status. +/// +public sealed record ExternalAgentStatus( + ExternalAgentDescriptor Descriptor, + ExternalAgentHealth Health, + bool Enabled, + string? ExecutablePath, + string? Detail); + +/// +/// External agent execution request. +/// +public sealed record ExternalAgentRunRequest( + string AdapterId, + string WorkspacePath, + string Prompt, + ExternalAgentMode Mode, + string? SessionId = null, + IReadOnlyDictionary? Environment = null, + IReadOnlyList? AdditionalArgs = null, + PermissionMode PermissionMode = PermissionMode.WorkspaceWrite, + PrimaryMode PrimaryMode = PrimaryMode.Build, + bool IsInteractive = true); + +/// +/// Structured event captured from an external agent. +/// +public sealed record ExternalAgentEvent( + DateTimeOffset TimestampUtc, + string AdapterId, + string EventType, + JsonElement? Payload); + +/// +/// External agent execution result. +/// +public sealed record ExternalAgentRunResult( + string AdapterId, + int ExitCode, + string OutputText, + IReadOnlyList StructuredEvents, + string? ExternalSessionId, + ExternalAgentFailureKind FailureKind, + string? Error); + +/// +/// Configures one external agent adapter. +/// +public sealed record ExternalAgentAdapterConfig( + bool? Enabled = null, + string? ExecutablePath = null, + string[]? DefaultArgs = null, + string? WorkingDirectoryMode = null); + +/// +/// Configures external agent support. +/// +public sealed record ExternalAgentsConfig( + bool Enabled = false, + Dictionary? Adapters = null, + bool RequireApprovalForMutatingRuns = true); + +/// +/// Command payload for external agent lists and status. +/// +public sealed record ExternalAgentCatalogReport( + bool Enabled, + bool RequireApprovalForMutatingRuns, + IReadOnlyList Agents); diff --git a/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs b/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs index 490dca0..6240e13 100644 --- a/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs +++ b/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs @@ -179,6 +179,9 @@ public sealed record ConnectLinkDefinition( /// Configured diagnostics sources. /// Configured runtime hooks. /// Optional auth/connect entry points. +/// External agent adapter configuration. +/// Skill-pack ecosystem configuration. +/// Work-item integration configuration. public sealed record SharpClawConfigDocument( ShareMode? ShareMode, SharpClawServerOptions? Server, @@ -186,7 +189,10 @@ public sealed record SharpClawConfigDocument( List? Agents, List? LspServers, List? Hooks, - List? ConnectLinks); + List? ConnectLinks, + ExternalAgentsConfig? ExternalAgents = null, + SkillPacksConfig? SkillPacks = null, + WorkItemsConfig? WorkItems = null); /// /// Materialized configuration snapshot after user/workspace precedence is applied. diff --git a/src/SharpClaw.Code.Protocol/Models/SharpClawWorkflowMetadataKeys.cs b/src/SharpClaw.Code.Protocol/Models/SharpClawWorkflowMetadataKeys.cs index 97b6f86..08e7c5e 100644 --- a/src/SharpClaw.Code.Protocol/Models/SharpClawWorkflowMetadataKeys.cs +++ b/src/SharpClaw.Code.Protocol/Models/SharpClawWorkflowMetadataKeys.cs @@ -86,6 +86,18 @@ public static class SharpClawWorkflowMetadataKeys /// Serialized persisted for the session. public const string SessionModelPreferenceJson = "sharpclaw.sessionModelPreferenceJson"; + /// Serialized imported work item persisted for the session. + public const string WorkItemJson = "sharpclaw.workItemJson"; + + /// Short work item goal pinned to the session. + public const string WorkItemGoal = "sharpclaw.workItemGoal"; + + /// Most recently invoked skill pack id. + public const string ActiveSkillPackId = "sharpclaw.activeSkillPackId"; + + /// Most recently invoked external agent id. + public const string LastExternalAgentId = "sharpclaw.lastExternalAgentId"; + /// Prefix for managed todo id maps keyed by owner agent id. public const string ManagedSessionTodoMapPrefix = "sharpclaw.managedSessionTodoMap."; diff --git a/src/SharpClaw.Code.Protocol/Models/SkillPackModels.cs b/src/SharpClaw.Code.Protocol/Models/SkillPackModels.cs new file mode 100644 index 0000000..a85c4d2 --- /dev/null +++ b/src/SharpClaw.Code.Protocol/Models/SkillPackModels.cs @@ -0,0 +1,92 @@ +using SharpClaw.Code.Protocol.Enums; + +namespace SharpClaw.Code.Protocol.Models; + +/// +/// A named command exposed by a skill pack. +/// +public sealed record SkillCommand( + string Name, + string Description, + string PromptTemplate); + +/// +/// A checklist packaged with a reusable skill workflow. +/// +public sealed record SkillChecklist( + string Name, + IReadOnlyList Items); + +/// +/// A named prompt template packaged with a skill pack. +/// +public sealed record SkillPromptTemplate( + string Name, + string Description, + string Template); + +/// +/// A tool recommendation surfaced by a skill pack. +/// +public sealed record SkillToolRecommendation( + string ToolName, + string Reason, + bool Required = false); + +/// +/// Skill pack manifest contract. +/// +public sealed record SkillPackManifest( + string Id, + string Name, + string Version, + string Description, + string? Author, + string[]? Tags, + IReadOnlyList? Commands, + IReadOnlyList? Prompts, + IReadOnlyList? Checklists, + IReadOnlyList? RecommendedTools, + IReadOnlyList? RequiredPermissions, + IReadOnlyList? CompatibleModes, + string EntryPointPrompt); + +/// +/// Resolved skill pack with source and enabled state. +/// +public sealed record SkillPack( + SkillPackManifest Manifest, + string Source, + bool IsBuiltIn, + bool Enabled, + DateTimeOffset? InstalledAtUtc = null); + +/// +/// Skill-pack install request. +/// +public sealed record SkillPackInstallRequest( + string SourcePath, + bool EnableAfterInstall = true); + +/// +/// Request to invoke a skill pack through the normal runtime. +/// +public sealed record SkillPackRunRequest( + string SkillId, + string? Arguments, + PrimaryMode? PrimaryMode = null, + string? CommandName = null); + +/// +/// Configures skill-pack behavior. +/// +public sealed record SkillPacksConfig( + bool Enabled = true, + string[]? AdditionalRoots = null); + +/// +/// Skill-pack inspection payload. +/// +public sealed record SkillPackInspectionRecord( + SkillPack Pack, + string ExpandedEntryPrompt); diff --git a/src/SharpClaw.Code.Protocol/Models/WorkItemModels.cs b/src/SharpClaw.Code.Protocol/Models/WorkItemModels.cs new file mode 100644 index 0000000..9e8e4af --- /dev/null +++ b/src/SharpClaw.Code.Protocol/Models/WorkItemModels.cs @@ -0,0 +1,112 @@ +using System.Text.Json; + +namespace SharpClaw.Code.Protocol.Models; + +/// +/// Imported work-item source kind. +/// +public enum WorkItemProviderKind +{ + /// GitHub issue or pull request. + GitHub, + + /// Generic Jira-style/manual JSON work item. + Generic, +} + +/// +/// Normalized work item. +/// +public sealed record WorkItem( + string Provider, + string Id, + string Title, + string? Description, + string? Url, + string? Status, + IReadOnlyList? Labels, + string? Assignee, + IReadOnlyDictionary? Metadata); + +/// +/// Request to import a work item into a session. +/// +public sealed record WorkItemImportRequest( + string Provider, + string IdOrUrl, + string WorkspacePath, + string? Mode = null, + string? SessionId = null); + +/// +/// Request to export a work-item-aware session summary. +/// +public sealed record WorkItemExportRequest( + string Provider, + string? SessionId, + string? TargetIdOrUrl, + string ExportFormat = "markdown"); + +/// +/// Imported work item plus linked session id. +/// +public sealed record WorkItemImportResult( + WorkItem WorkItem, + string SessionId, + bool CreatedSession); + +/// +/// Work-item summary export result. +/// +public sealed record WorkItemSummaryExport( + string SessionId, + string Format, + string Content, + WorkItem? WorkItem); + +/// +/// GitHub issue or pull request URL parse result. +/// +public sealed record GitHubWorkItemReference( + string Owner, + string Repository, + string Kind, + int Number, + string Url); + +/// +/// Configures work-item integrations. +/// +public sealed record WorkItemsConfig( + bool Enabled = true, + string? GitHubTokenEnvironmentVariable = "GITHUB_TOKEN"); + +/// +/// JSON fixture wrapper for generic work item ingestion. +/// +public sealed record GenericWorkItemFixture( + string Provider, + string Id, + string Title, + string? Description, + string? Url, + string? Status, + string[]? Labels, + string? Assignee, + Dictionary? Metadata) +{ + /// + /// Converts the fixture to a normalized work item. + /// + public WorkItem ToWorkItem() + => new( + Provider, + Id, + Title, + Description, + Url, + Status, + Labels, + Assignee, + Metadata); +} diff --git a/src/SharpClaw.Code.Protocol/Operational/WorkbenchStatusReport.cs b/src/SharpClaw.Code.Protocol/Operational/WorkbenchStatusReport.cs new file mode 100644 index 0000000..cae4ee9 --- /dev/null +++ b/src/SharpClaw.Code.Protocol/Operational/WorkbenchStatusReport.cs @@ -0,0 +1,23 @@ +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Protocol.Operational; + +/// +/// Static workbench/status payload focused on runtime state rather than terminal decoration. +/// +public sealed record WorkbenchStatusReport( + string SchemaVersion, + DateTimeOffset GeneratedAtUtc, + string WorkspaceRoot, + string? CurrentSessionId, + string? CurrentGoal, + PrimaryMode PrimaryMode, + string? ActiveAgentId, + string RuntimeState, + ApprovalSettings? ApprovalSettings, + int PendingApprovalCount, + RuntimeCheckpoint? LatestCheckpoint, + IReadOnlyList RecentActivity, + IReadOnlyList ExternalAgents, + IReadOnlyList Warnings); diff --git a/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs b/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs index 4f1570c..31e2795 100644 --- a/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs +++ b/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs @@ -26,6 +26,7 @@ namespace SharpClaw.Code.Protocol.Serialization; [JsonSerializable(typeof(CommandResult))] [JsonSerializable(typeof(DoctorReport))] [JsonSerializable(typeof(RuntimeStatusReport))] +[JsonSerializable(typeof(WorkbenchStatusReport))] [JsonSerializable(typeof(SessionInspectionReport))] [JsonSerializable(typeof(OperationalCheckItem))] [JsonSerializable(typeof(OperationalCheckStatus))] @@ -107,6 +108,14 @@ namespace SharpClaw.Code.Protocol.Serialization; [JsonSerializable(typeof(RuntimeEvent))] [JsonSerializable(typeof(SessionCreatedEvent))] [JsonSerializable(typeof(SessionStateChangedEvent))] +[JsonSerializable(typeof(ExternalAgentRunStartedEvent))] +[JsonSerializable(typeof(ExternalAgentRunCompletedEvent))] +[JsonSerializable(typeof(ExternalAgentRunFailedEvent))] +[JsonSerializable(typeof(SkillPackInstalledEvent))] +[JsonSerializable(typeof(SkillInvokedEvent))] +[JsonSerializable(typeof(WorkItemImportedEvent))] +[JsonSerializable(typeof(WorkItemSummaryExportedEvent))] +[JsonSerializable(typeof(WorkbenchViewedEvent))] [JsonSerializable(typeof(SkillDefinition))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(ToolCompletedEvent))] @@ -217,6 +226,44 @@ namespace SharpClaw.Code.Protocol.Serialization; [JsonSerializable(typeof(SharpClawServerOptions))] [JsonSerializable(typeof(ConnectLinkDefinition))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ExternalAgentMode))] +[JsonSerializable(typeof(ExternalAgentHealth))] +[JsonSerializable(typeof(ExternalAgentFailureKind))] +[JsonSerializable(typeof(ExternalAgentDescriptor))] +[JsonSerializable(typeof(ExternalAgentStatus))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ExternalAgentRunRequest))] +[JsonSerializable(typeof(ExternalAgentEvent))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ExternalAgentRunResult))] +[JsonSerializable(typeof(ExternalAgentAdapterConfig))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(ExternalAgentsConfig))] +[JsonSerializable(typeof(ExternalAgentCatalogReport))] +[JsonSerializable(typeof(SkillCommand))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(SkillChecklist))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(SkillPromptTemplate))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(SkillToolRecommendation))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(SkillPackManifest))] +[JsonSerializable(typeof(SkillPack))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(SkillPackInstallRequest))] +[JsonSerializable(typeof(SkillPackRunRequest))] +[JsonSerializable(typeof(SkillPacksConfig))] +[JsonSerializable(typeof(SkillPackInspectionRecord))] +[JsonSerializable(typeof(WorkItemProviderKind))] +[JsonSerializable(typeof(WorkItem))] +[JsonSerializable(typeof(WorkItemImportRequest))] +[JsonSerializable(typeof(WorkItemExportRequest))] +[JsonSerializable(typeof(WorkItemImportResult))] +[JsonSerializable(typeof(WorkItemSummaryExport))] +[JsonSerializable(typeof(GitHubWorkItemReference))] +[JsonSerializable(typeof(WorkItemsConfig))] +[JsonSerializable(typeof(GenericWorkItemFixture))] [JsonSerializable(typeof(SharpClawConfigDocument))] [JsonSerializable(typeof(SharpClawConfigSnapshot))] [JsonSerializable(typeof(AgentCatalogEntry))] diff --git a/src/SharpClaw.Code.Runtime/Abstractions/IWorkbenchStatusService.cs b/src/SharpClaw.Code.Runtime/Abstractions/IWorkbenchStatusService.cs new file mode 100644 index 0000000..eaba588 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Abstractions/IWorkbenchStatusService.cs @@ -0,0 +1,14 @@ +using SharpClaw.Code.Protocol.Operational; + +namespace SharpClaw.Code.Runtime.Abstractions; + +/// +/// Builds static workbench status reports for CLI and REPL views. +/// +public interface IWorkbenchStatusService +{ + /// + /// Builds a workbench status report. + /// + Task BuildAsync(RuntimeCommandContext context, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs b/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs index 27c3f7f..835e572 100644 --- a/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SharpClaw.Code.Agents; +using SharpClaw.Code.ExternalAgents; +using SharpClaw.Code.ExternalAgents.Abstractions; using SharpClaw.Code.Git; using SharpClaw.Code.Infrastructure; using SharpClaw.Code.Infrastructure.Abstractions; @@ -19,6 +21,7 @@ using SharpClaw.Code.Runtime.Diagnostics; using SharpClaw.Code.Runtime.Diagnostics.Checks; using SharpClaw.Code.Runtime.Export; +using SharpClaw.Code.Runtime.ExternalAgents; using SharpClaw.Code.Runtime.Lifecycle; using SharpClaw.Code.Runtime.Mutations; using SharpClaw.Code.Runtime.Prompts; @@ -32,6 +35,7 @@ using SharpClaw.Code.Telemetry; using SharpClaw.Code.Telemetry.Abstractions; using SharpClaw.Code.Telemetry.Services; +using SharpClaw.Code.WorkItems; namespace SharpClaw.Code.Runtime.Composition; @@ -92,9 +96,12 @@ private static IServiceCollection AddSharpClawRuntimeCore( } services.AddSharpClawAgents(); + services.AddSharpClawExternalAgents(); + services.AddSingleton(); services.AddSharpClawMemory(); services.AddSharpClawSkills(); services.AddSharpClawGit(); + services.AddSharpClawWorkItems(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); @@ -140,6 +147,7 @@ private static IServiceCollection AddSharpClawRuntimeCore( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -164,6 +172,7 @@ private static void AddOperationalDiagnostics(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => new McpRegistryHealthCheck( sp.GetRequiredService(), sp.GetService())); diff --git a/src/SharpClaw.Code.Runtime/Configuration/SharpClawConfigService.cs b/src/SharpClaw.Code.Runtime/Configuration/SharpClawConfigService.cs index 316135b..8f170a6 100644 --- a/src/SharpClaw.Code.Runtime/Configuration/SharpClawConfigService.cs +++ b/src/SharpClaw.Code.Runtime/Configuration/SharpClawConfigService.cs @@ -92,7 +92,10 @@ private static SharpClawConfigDocument Merge(SharpClawConfigDocument? user, Shar MergeByKey(user?.Agents, workspace?.Agents, static item => item.Id), MergeByKey(user?.LspServers, workspace?.LspServers, static item => item.Id), MergeByKey(user?.Hooks, workspace?.Hooks, static item => item.Name), - MergeByKey(user?.ConnectLinks, workspace?.ConnectLinks, static item => item.Target)); + MergeByKey(user?.ConnectLinks, workspace?.ConnectLinks, static item => item.Target), + MergeExternalAgents(user?.ExternalAgents, workspace?.ExternalAgents), + workspace?.SkillPacks ?? user?.SkillPacks ?? new SkillPacksConfig(), + workspace?.WorkItems ?? user?.WorkItems ?? new WorkItemsConfig()); } private static List? MergeByKey( @@ -123,7 +126,29 @@ private static SharpClawConfigDocument CreateDefaultDocument() null, null, null, - null); + null, + new ExternalAgentsConfig(), + new SkillPacksConfig(), + new WorkItemsConfig()); + + private static ExternalAgentsConfig MergeExternalAgents(ExternalAgentsConfig? user, ExternalAgentsConfig? workspace) + { + var adapters = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in user?.Adapters ?? []) + { + adapters[pair.Key] = pair.Value; + } + + foreach (var pair in workspace?.Adapters ?? []) + { + adapters[pair.Key] = pair.Value; + } + + return new ExternalAgentsConfig( + workspace?.Enabled ?? user?.Enabled ?? false, + adapters.Count == 0 ? null : adapters, + workspace?.RequireApprovalForMutatingRuns ?? user?.RequireApprovalForMutatingRuns ?? true); + } private static string GetUserConfigPath() { diff --git a/src/SharpClaw.Code.Runtime/Diagnostics/Checks/ExternalAgentHealthCheck.cs b/src/SharpClaw.Code.Runtime/Diagnostics/Checks/ExternalAgentHealthCheck.cs new file mode 100644 index 0000000..8562be0 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Diagnostics/Checks/ExternalAgentHealthCheck.cs @@ -0,0 +1,34 @@ +using SharpClaw.Code.ExternalAgents.Abstractions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Operational; + +namespace SharpClaw.Code.Runtime.Diagnostics.Checks; + +/// +/// Reports configured external agent adapter health. +/// +public sealed class ExternalAgentHealthCheck(IExternalAgentRegistry registry) : IOperationalCheck +{ + /// + public string Id => "external-agents.registry"; + + /// + public async Task ExecuteAsync(OperationalDiagnosticsContext context, CancellationToken cancellationToken) + { + var report = await registry.BuildReportAsync(context.NormalizedWorkspacePath, cancellationToken).ConfigureAwait(false); + if (!report.Enabled) + { + return new OperationalCheckItem(Id, OperationalCheckStatus.Skipped, "External agents are disabled.", null); + } + + var available = report.Agents.Count(agent => agent.Health == ExternalAgentHealth.Available); + var missing = report.Agents.Count(agent => agent.Health == ExternalAgentHealth.Missing); + var status = missing == report.Agents.Count ? OperationalCheckStatus.Warn : OperationalCheckStatus.Ok; + var detail = string.Join("; ", report.Agents.Select(agent => $"{agent.Descriptor.Id}: {agent.Health}")); + return new OperationalCheckItem( + Id, + status, + $"{available}/{report.Agents.Count} external agent adapter(s) available.", + detail); + } +} diff --git a/src/SharpClaw.Code.Runtime/Diagnostics/OperationalDiagnosticsCoordinator.cs b/src/SharpClaw.Code.Runtime/Diagnostics/OperationalDiagnosticsCoordinator.cs index 3b3fe17..ab82dd0 100644 --- a/src/SharpClaw.Code.Runtime/Diagnostics/OperationalDiagnosticsCoordinator.cs +++ b/src/SharpClaw.Code.Runtime/Diagnostics/OperationalDiagnosticsCoordinator.cs @@ -69,7 +69,7 @@ public async Task BuildStatusReportAsync(OperationalDiagnos : input.WorkingDirectory); var context = new OperationalDiagnosticsContext(workspacePath, input.Model, input.PermissionMode); var quickChecks = new List(); - foreach (var id in new[] { "workspace.access", "session.store", "mcp.registry", "plugins.registry", "approval.auth", "provider.local-runtimes" }) + foreach (var id in new[] { "workspace.access", "session.store", "mcp.registry", "plugins.registry", "approval.auth", "provider.local-runtimes", "external-agents.registry" }) { var check = orderedChecks.FirstOrDefault(c => c.Id == id); if (check is not null) diff --git a/src/SharpClaw.Code.Runtime/Export/SessionExportService.cs b/src/SharpClaw.Code.Runtime/Export/SessionExportService.cs index 66e0e4a..3c046f7 100644 --- a/src/SharpClaw.Code.Runtime/Export/SessionExportService.cs +++ b/src/SharpClaw.Code.Runtime/Export/SessionExportService.cs @@ -145,6 +145,13 @@ private static string Summarize(RuntimeEvent @event) TurnStartedEvent => $"turnStarted {((TurnStartedEvent)@event).Turn.Id}", TurnCompletedEvent done => $"turnCompleted {done.Turn.Id} ok={done.Succeeded}", ToolCompletedEvent tool => $"toolCompleted {tool.Result.ToolName}", + ExternalAgentRunStartedEvent external => $"externalAgentRunStarted {external.AdapterId}", + ExternalAgentRunCompletedEvent external => $"externalAgentRunCompleted {external.AdapterId}", + ExternalAgentRunFailedEvent external => $"externalAgentRunFailed {external.AdapterId} {external.FailureKind}", + SkillInvokedEvent skill => $"skillInvoked {skill.SkillId}", + WorkItemImportedEvent work => $"workItemImported {work.WorkItem.Provider}:{work.WorkItem.Id}", + WorkItemSummaryExportedEvent work => $"workItemSummaryExported {work.Provider}", + WorkbenchViewedEvent => "workbenchViewed", _ => @event.GetType().Name, }; } diff --git a/src/SharpClaw.Code.Runtime/ExternalAgents/RuntimeExternalAgentConfigProvider.cs b/src/SharpClaw.Code.Runtime/ExternalAgents/RuntimeExternalAgentConfigProvider.cs new file mode 100644 index 0000000..e5b7d99 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/ExternalAgents/RuntimeExternalAgentConfigProvider.cs @@ -0,0 +1,18 @@ +using SharpClaw.Code.ExternalAgents.Abstractions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Runtime.ExternalAgents; + +/// +/// Supplies external agent configuration from merged SharpClaw user/workspace config. +/// +public sealed class RuntimeExternalAgentConfigProvider(ISharpClawConfigService configService) : IExternalAgentConfigProvider +{ + /// + public async Task GetConfigAsync(string workspaceRoot, CancellationToken cancellationToken) + { + var snapshot = await configService.GetConfigAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + return snapshot.Document.ExternalAgents ?? new ExternalAgentsConfig(); + } +} diff --git a/src/SharpClaw.Code.Runtime/SharpClaw.Code.Runtime.csproj b/src/SharpClaw.Code.Runtime/SharpClaw.Code.Runtime.csproj index ed39a60..9dccc06 100644 --- a/src/SharpClaw.Code.Runtime/SharpClaw.Code.Runtime.csproj +++ b/src/SharpClaw.Code.Runtime/SharpClaw.Code.Runtime.csproj @@ -17,6 +17,7 @@ + @@ -29,6 +30,7 @@ + diff --git a/src/SharpClaw.Code.Runtime/Workflow/WorkbenchStatusService.cs b/src/SharpClaw.Code.Runtime/Workflow/WorkbenchStatusService.cs new file mode 100644 index 0000000..988a894 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Workflow/WorkbenchStatusService.cs @@ -0,0 +1,101 @@ +using SharpClaw.Code.ExternalAgents.Abstractions; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Operational; +using SharpClaw.Code.Runtime.Abstractions; +using SharpClaw.Code.Runtime.Diagnostics; +using SharpClaw.Code.Sessions.Abstractions; +using SharpClaw.Code.Telemetry; +using SharpClaw.Code.Telemetry.Abstractions; + +namespace SharpClaw.Code.Runtime.Workflow; + +/// +/// Assembles static workbench state from session stores, checkpoints, diagnostics, and external adapter health. +/// +public sealed class WorkbenchStatusService( + ISessionStore sessionStore, + IEventStore eventStore, + ICheckpointStore checkpointStore, + IExternalAgentService externalAgentService, + IOperationalDiagnosticsCoordinator diagnosticsCoordinator, + IRuntimeEventPublisher eventPublisher, + IPathService pathService, + ISystemClock systemClock) : IWorkbenchStatusService +{ + /// + public async Task BuildAsync(RuntimeCommandContext context, CancellationToken cancellationToken) + { + var workspace = pathService.GetFullPath(context.WorkingDirectory); + var session = string.IsNullOrWhiteSpace(context.SessionId) + ? await sessionStore.GetLatestAsync(workspace, cancellationToken).ConfigureAwait(false) + : await sessionStore.GetByIdAsync(workspace, context.SessionId, cancellationToken).ConfigureAwait(false); + var checkpoint = session is null + ? null + : await checkpointStore.GetLatestAsync(workspace, session.Id, cancellationToken).ConfigureAwait(false); + var events = session is null + ? Array.Empty() + : (await eventStore.ReadAllAsync(workspace, session.Id, cancellationToken).ConfigureAwait(false)).TakeLast(8).ToArray(); + var external = await externalAgentService.ListAsync(workspace, cancellationToken).ConfigureAwait(false); + var status = await diagnosticsCoordinator.BuildStatusReportAsync( + new OperationalDiagnosticsInput(workspace, context.Model, context.PermissionMode, context.OutputFormat, context.PrimaryMode, context.ApprovalSettings), + cancellationToken).ConfigureAwait(false); + var primaryMode = context.PrimaryMode ?? ResolvePrimaryMode(session); + var report = new WorkbenchStatusReport( + "1.0", + systemClock.UtcNow, + workspace, + session?.Id, + ResolveGoal(session), + primaryMode, + ResolveMetadata(session, SharpClawWorkflowMetadataKeys.ActiveAgentId), + status.RuntimeState, + status.ApprovalSettings, + 0, + checkpoint, + events.Select(Summarize).ToArray(), + external.Agents, + status.Checks.Where(check => check.Status is not OperationalCheckStatus.Ok and not OperationalCheckStatus.Skipped).ToArray()); + + if (session is not null) + { + await eventPublisher.PublishAsync( + new WorkbenchViewedEvent($"event_{Guid.NewGuid():N}", session.Id, null, systemClock.UtcNow, workspace), + new RuntimeEventPublishOptions(workspace, session.Id, PersistToSessionStore: true), + cancellationToken).ConfigureAwait(false); + } + + return report; + } + + private static string? ResolveGoal(ConversationSession? session) + => ResolveMetadata(session, SharpClawWorkflowMetadataKeys.WorkItemGoal) + ?? ResolveMetadata(session, SharpClawWorkflowMetadataKeys.DeepPlanningSummary) + ?? session?.Title; + + private static string? ResolveMetadata(ConversationSession? session, string key) + => session?.Metadata is not null && session.Metadata.TryGetValue(key, out var value) ? value : null; + + private static PrimaryMode ResolvePrimaryMode(ConversationSession? session) + => ResolveMetadata(session, SharpClawWorkflowMetadataKeys.PrimaryMode) is { } stored + && Enum.TryParse(stored, ignoreCase: true, out var parsed) + ? parsed + : PrimaryMode.Build; + + private static string Summarize(RuntimeEvent runtimeEvent) + => runtimeEvent switch + { + TurnStartedEvent turn => $"turnStarted {turn.Turn.SequenceNumber}", + TurnCompletedEvent turn => $"turnCompleted {turn.Turn.SequenceNumber} ok={turn.Succeeded}", + ToolCompletedEvent tool => $"toolCompleted {tool.Result.ToolName} ok={tool.Result.Succeeded}", + ExternalAgentRunStartedEvent external => $"externalAgentStarted {external.AdapterId}", + ExternalAgentRunCompletedEvent external => $"externalAgentCompleted {external.AdapterId}", + ExternalAgentRunFailedEvent external => $"externalAgentFailed {external.AdapterId}: {external.FailureKind}", + SkillInvokedEvent skill => $"skillInvoked {skill.SkillId}", + WorkItemImportedEvent work => $"workItemImported {work.WorkItem.Provider}:{work.WorkItem.Id}", + WorkbenchViewedEvent => "workbenchViewed", + _ => runtimeEvent.GetType().Name, + }; +} diff --git a/src/SharpClaw.Code.Skills/Abstractions/ISkillPackRegistry.cs b/src/SharpClaw.Code.Skills/Abstractions/ISkillPackRegistry.cs new file mode 100644 index 0000000..a0bc335 --- /dev/null +++ b/src/SharpClaw.Code.Skills/Abstractions/ISkillPackRegistry.cs @@ -0,0 +1,39 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Skills.Abstractions; + +/// +/// Manages reusable skill packs. +/// +public interface ISkillPackRegistry +{ + /// + /// Lists built-in, user, and workspace skill packs. + /// + Task> ListAsync(string workspaceRoot, CancellationToken cancellationToken); + + /// + /// Resolves a skill pack by id. + /// + Task ResolveAsync(string workspaceRoot, string skillId, CancellationToken cancellationToken); + + /// + /// Installs a local skill pack manifest or directory into the workspace. + /// + Task InstallAsync(string workspaceRoot, SkillPackInstallRequest request, CancellationToken cancellationToken); + + /// + /// Enables a workspace skill pack. + /// + Task EnableAsync(string workspaceRoot, string skillId, CancellationToken cancellationToken); + + /// + /// Disables a workspace skill pack. + /// + Task DisableAsync(string workspaceRoot, string skillId, CancellationToken cancellationToken); + + /// + /// Expands the skill pack entry prompt for execution. + /// + Task BuildPromptAsync(string workspaceRoot, SkillPackRunRequest request, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Skills/Services/SkillPackRegistry.cs b/src/SharpClaw.Code.Skills/Services/SkillPackRegistry.cs new file mode 100644 index 0000000..1c1fb78 --- /dev/null +++ b/src/SharpClaw.Code.Skills/Services/SkillPackRegistry.cs @@ -0,0 +1,217 @@ +using System.Text.Json; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Skills.Abstractions; + +namespace SharpClaw.Code.Skills.Services; + +/// +/// File-backed skill-pack registry with built-in pack support. +/// +public sealed class SkillPackRegistry( + IFileSystem fileSystem, + IPathService pathService, + IUserProfilePaths userProfilePaths, + ISystemClock systemClock) : ISkillPackRegistry +{ + private const string WorkspaceRootDirectoryName = ".sharpclaw"; + private const string SkillPacksDirectoryName = "skillpacks"; + private const string ManifestFileName = "skillpack.json"; + private const string DisabledFileName = ".disabled"; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + }; + + /// + public async Task> ListAsync(string workspaceRoot, CancellationToken cancellationToken) + { + var packs = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pack in BuiltInPacks()) + { + packs[pack.Manifest.Id] = pack; + } + + foreach (var root in GetRoots(workspaceRoot)) + { + if (!fileSystem.DirectoryExists(root)) + { + continue; + } + + foreach (var directory in fileSystem.EnumerateDirectories(root)) + { + var pack = await ResolveFromDirectoryAsync(directory, cancellationToken).ConfigureAwait(false); + if (pack is not null) + { + packs[pack.Manifest.Id] = pack; + } + } + } + + return packs.Values.OrderBy(pack => pack.Manifest.Name, StringComparer.OrdinalIgnoreCase).ToArray(); + } + + /// + public async Task ResolveAsync(string workspaceRoot, string skillId, CancellationToken cancellationToken) + => (await ListAsync(workspaceRoot, cancellationToken).ConfigureAwait(false)) + .FirstOrDefault(pack => string.Equals(pack.Manifest.Id, skillId, StringComparison.OrdinalIgnoreCase) + || string.Equals(pack.Manifest.Name, skillId, StringComparison.OrdinalIgnoreCase)); + + /// + public async Task InstallAsync(string workspaceRoot, SkillPackInstallRequest request, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(request.SourcePath); + + var manifestPath = fileSystem.DirectoryExists(request.SourcePath) + ? pathService.Combine(pathService.GetFullPath(request.SourcePath), ManifestFileName) + : pathService.GetFullPath(request.SourcePath); + var manifestText = await fileSystem.ReadAllTextIfExistsAsync(manifestPath, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Skill pack manifest '{manifestPath}' was not found."); + var manifest = JsonSerializer.Deserialize(manifestText, ProtocolJsonContext.Default.SkillPackManifest) + ?? throw new InvalidOperationException($"Skill pack manifest '{manifestPath}' could not be parsed."); + Validate(manifest); + + var targetDirectory = pathService.Combine(GetWorkspaceRoot(workspaceRoot, ensureExists: true), manifest.Id); + fileSystem.CreateDirectory(targetDirectory); + await fileSystem.WriteAllTextAsync( + pathService.Combine(targetDirectory, ManifestFileName), + JsonSerializer.Serialize(manifest, ProtocolJsonContext.Default.SkillPackManifest), + cancellationToken).ConfigureAwait(false); + + if (request.EnableAfterInstall) + { + fileSystem.TryDeleteFile(pathService.Combine(targetDirectory, DisabledFileName)); + } + else + { + await fileSystem.WriteAllTextAsync(pathService.Combine(targetDirectory, DisabledFileName), systemClock.UtcNow.ToString("O"), cancellationToken).ConfigureAwait(false); + } + + return new SkillPack(manifest, targetDirectory, false, request.EnableAfterInstall, systemClock.UtcNow); + } + + /// + public Task EnableAsync(string workspaceRoot, string skillId, CancellationToken cancellationToken) + { + _ = cancellationToken; + var directory = pathService.Combine(GetWorkspaceRoot(workspaceRoot, ensureExists: false), skillId); + if (!fileSystem.DirectoryExists(directory)) + { + return Task.FromResult(false); + } + + fileSystem.TryDeleteFile(pathService.Combine(directory, DisabledFileName)); + return Task.FromResult(true); + } + + /// + public async Task DisableAsync(string workspaceRoot, string skillId, CancellationToken cancellationToken) + { + var directory = pathService.Combine(GetWorkspaceRoot(workspaceRoot, ensureExists: false), skillId); + if (!fileSystem.DirectoryExists(directory)) + { + return false; + } + + await fileSystem.WriteAllTextAsync(pathService.Combine(directory, DisabledFileName), systemClock.UtcNow.ToString("O"), cancellationToken).ConfigureAwait(false); + return true; + } + + /// + public async Task BuildPromptAsync(string workspaceRoot, SkillPackRunRequest request, CancellationToken cancellationToken) + { + var pack = await ResolveAsync(workspaceRoot, request.SkillId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Skill pack '{request.SkillId}' was not found."); + if (!pack.Enabled) + { + throw new InvalidOperationException($"Skill pack '{request.SkillId}' is disabled."); + } + + var template = request.CommandName is null + ? pack.Manifest.EntryPointPrompt + : pack.Manifest.Commands?.FirstOrDefault(command => string.Equals(command.Name, request.CommandName, StringComparison.OrdinalIgnoreCase))?.PromptTemplate + ?? throw new InvalidOperationException($"Skill pack command '{request.CommandName}' was not found."); + + return template + .Replace("{{arguments}}", request.Arguments ?? string.Empty, StringComparison.Ordinal) + .Replace("{{workspace}}", pathService.GetFullPath(workspaceRoot), StringComparison.Ordinal); + } + + private async Task ResolveFromDirectoryAsync(string directory, CancellationToken cancellationToken) + { + var manifestText = await fileSystem.ReadAllTextIfExistsAsync(pathService.Combine(directory, ManifestFileName), cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(manifestText)) + { + return null; + } + + var manifest = JsonSerializer.Deserialize(manifestText, ProtocolJsonContext.Default.SkillPackManifest); + if (manifest is null) + { + return null; + } + + return new SkillPack(manifest, directory, false, !fileSystem.FileExists(pathService.Combine(directory, DisabledFileName))); + } + + private IEnumerable GetRoots(string workspaceRoot) + { + yield return pathService.Combine(userProfilePaths.GetUserSharpClawRoot(), SkillPacksDirectoryName); + yield return GetWorkspaceRoot(workspaceRoot, ensureExists: false); + } + + private string GetWorkspaceRoot(string workspaceRoot, bool ensureExists) + { + var normalized = pathService.GetFullPath(workspaceRoot); + var root = pathService.Combine(normalized, WorkspaceRootDirectoryName, SkillPacksDirectoryName); + if (ensureExists) + { + fileSystem.CreateDirectory(root); + } + + return root; + } + + private static void Validate(SkillPackManifest manifest) + { + ArgumentException.ThrowIfNullOrWhiteSpace(manifest.Id); + ArgumentException.ThrowIfNullOrWhiteSpace(manifest.Name); + ArgumentException.ThrowIfNullOrWhiteSpace(manifest.Version); + ArgumentException.ThrowIfNullOrWhiteSpace(manifest.Description); + ArgumentException.ThrowIfNullOrWhiteSpace(manifest.EntryPointPrompt); + } + + private static IReadOnlyList BuiltInPacks() + => + [ + BuiltIn("pr-review", "PR Review Skill", "Review the diff or PR context for correctness, tests, security, and maintainability.", "Review this PR or diff. Use this checklist: correctness, tests, security, maintainability. User context: {{arguments}}"), + BuiltIn("architecture-review", "Architecture Review Skill", "Review boundaries, dependencies, state, and observability.", "Review the architecture for boundaries, dependencies, state handling, and observability. User context: {{arguments}}"), + BuiltIn("release-notes", "Release Notes Skill", "Summarize commits or session events into release notes.", "Create release notes from the supplied commits, session events, or context. User context: {{arguments}}"), + BuiltIn("efh-recovery", "EFH Recovery Skill", "Reorient from checkpoints, unresolved intentions, and goal state.", "Recover context from checkpoints, unresolved intentions, and the current goal state. User context: {{arguments}}"), + BuiltIn("figma-handoff", "Figma Handoff Skill", "Convert UI or design notes into implementation tasks.", "Convert these Figma/design notes into concrete implementation tasks. User context: {{arguments}}"), + ]; + + private static SkillPack BuiltIn(string id, string name, string description, string prompt) + => new( + new SkillPackManifest( + id, + name, + "1.0.0", + description, + "SharpClaw", + ["built-in"], + [new SkillCommand("run", "Runs the skill entrypoint.", prompt)], + [new SkillPromptTemplate("entrypoint", description, prompt)], + [new SkillChecklist("default", ["Correctness", "Tests", "Security", "Maintainability"])], + null, + [ApprovalScope.ToolExecution], + [PrimaryMode.Plan, PrimaryMode.Build, PrimaryMode.Research], + prompt), + "built-in", + true, + true); +} diff --git a/src/SharpClaw.Code.Skills/SkillsServiceCollectionExtensions.cs b/src/SharpClaw.Code.Skills/SkillsServiceCollectionExtensions.cs index 32a998c..c56b812 100644 --- a/src/SharpClaw.Code.Skills/SkillsServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Skills/SkillsServiceCollectionExtensions.cs @@ -19,6 +19,7 @@ public static IServiceCollection AddSharpClawSkills(this IServiceCollection serv { services.AddSharpClawInfrastructure(); services.AddSingleton(); + services.AddSingleton(); return services; } } diff --git a/src/SharpClaw.Code.WorkItems/Abstractions/IWorkItemProvider.cs b/src/SharpClaw.Code.WorkItems/Abstractions/IWorkItemProvider.cs new file mode 100644 index 0000000..4cda196 --- /dev/null +++ b/src/SharpClaw.Code.WorkItems/Abstractions/IWorkItemProvider.cs @@ -0,0 +1,24 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.WorkItems.Abstractions; + +/// +/// Imports work items from one workflow system. +/// +public interface IWorkItemProvider +{ + /// + /// Gets the provider id. + /// + string Provider { get; } + + /// + /// Returns whether this provider can import the identifier or URL. + /// + bool CanImport(string idOrUrl); + + /// + /// Imports a work item. + /// + Task ImportAsync(WorkItemImportRequest request, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.WorkItems/Abstractions/IWorkItemRegistry.cs b/src/SharpClaw.Code.WorkItems/Abstractions/IWorkItemRegistry.cs new file mode 100644 index 0000000..afd9692 --- /dev/null +++ b/src/SharpClaw.Code.WorkItems/Abstractions/IWorkItemRegistry.cs @@ -0,0 +1,12 @@ +namespace SharpClaw.Code.WorkItems.Abstractions; + +/// +/// Registry for work-item providers. +/// +public interface IWorkItemRegistry +{ + /// + /// Resolves a provider for an import request. + /// + IWorkItemProvider? Resolve(string provider, string idOrUrl); +} diff --git a/src/SharpClaw.Code.WorkItems/Abstractions/IWorkItemService.cs b/src/SharpClaw.Code.WorkItems/Abstractions/IWorkItemService.cs new file mode 100644 index 0000000..b93d011 --- /dev/null +++ b/src/SharpClaw.Code.WorkItems/Abstractions/IWorkItemService.cs @@ -0,0 +1,19 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.WorkItems.Abstractions; + +/// +/// Session-aware work-item import and export service. +/// +public interface IWorkItemService +{ + /// + /// Imports a work item and links it to a session. + /// + Task ImportAsync(WorkItemImportRequest request, CancellationToken cancellationToken); + + /// + /// Exports a work-item-aware session summary. + /// + Task ExportSummaryAsync(WorkItemExportRequest request, string workspaceRoot, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.WorkItems/AssemblyMarker.cs b/src/SharpClaw.Code.WorkItems/AssemblyMarker.cs new file mode 100644 index 0000000..2294fbe --- /dev/null +++ b/src/SharpClaw.Code.WorkItems/AssemblyMarker.cs @@ -0,0 +1,6 @@ +namespace SharpClaw.Code.WorkItems; + +/// +/// Marker type for work-item assemblies. +/// +public sealed class AssemblyMarker; diff --git a/src/SharpClaw.Code.WorkItems/Providers/GenericWorkItemProvider.cs b/src/SharpClaw.Code.WorkItems/Providers/GenericWorkItemProvider.cs new file mode 100644 index 0000000..b2f85c5 --- /dev/null +++ b/src/SharpClaw.Code.WorkItems/Providers/GenericWorkItemProvider.cs @@ -0,0 +1,31 @@ +using System.Text.Json; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.WorkItems.Abstractions; + +namespace SharpClaw.Code.WorkItems.Providers; + +/// +/// Imports generic Jira-style work items from local JSON. +/// +public sealed class GenericWorkItemProvider(IFileSystem fileSystem, IPathService pathService) : IWorkItemProvider +{ + /// + public string Provider => "generic"; + + /// + public bool CanImport(string idOrUrl) => idOrUrl.EndsWith(".json", StringComparison.OrdinalIgnoreCase) || idOrUrl.TrimStart().StartsWith('{'); + + /// + public async Task ImportAsync(WorkItemImportRequest request, CancellationToken cancellationToken) + { + var json = request.IdOrUrl.TrimStart().StartsWith('{') + ? request.IdOrUrl + : await fileSystem.ReadAllTextIfExistsAsync(pathService.GetFullPath(request.IdOrUrl), cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Generic work-item fixture '{request.IdOrUrl}' was not found."); + var fixture = JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.GenericWorkItemFixture) + ?? throw new InvalidOperationException("Generic work-item fixture could not be parsed."); + return fixture.ToWorkItem(); + } +} diff --git a/src/SharpClaw.Code.WorkItems/Providers/GitHubWorkItemProvider.cs b/src/SharpClaw.Code.WorkItems/Providers/GitHubWorkItemProvider.cs new file mode 100644 index 0000000..d9c4d70 --- /dev/null +++ b/src/SharpClaw.Code.WorkItems/Providers/GitHubWorkItemProvider.cs @@ -0,0 +1,94 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.WorkItems.Abstractions; + +namespace SharpClaw.Code.WorkItems.Providers; + +/// +/// Imports GitHub issues and pull requests from public REST endpoints or a local token. +/// +public sealed class GitHubWorkItemProvider : IWorkItemProvider +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + /// + public string Provider => "github"; + + /// + public bool CanImport(string idOrUrl) => TryParse(idOrUrl, out _); + + /// + public async Task ImportAsync(WorkItemImportRequest request, CancellationToken cancellationToken) + { + if (!TryParse(request.IdOrUrl, out var reference)) + { + throw new InvalidOperationException($"'{request.IdOrUrl}' is not a supported GitHub issue or PR URL."); + } + + using var client = new HttpClient(); + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("SharpClawCode", "1.0")); + var token = Environment.GetEnvironmentVariable("GITHUB_TOKEN"); + if (!string.IsNullOrWhiteSpace(token)) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + var endpoint = $"https://api.github.com/repos/{reference.Owner}/{reference.Repository}/{(reference.Kind == "pull" ? "pulls" : "issues")}/{reference.Number}"; + using var response = await client.GetAsync(endpoint, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + var root = document.RootElement; + var labels = root.TryGetProperty("labels", out var labelArray) && labelArray.ValueKind == JsonValueKind.Array + ? labelArray.EnumerateArray().Select(label => label.TryGetProperty("name", out var name) ? name.GetString() ?? string.Empty : string.Empty).Where(static label => !string.IsNullOrWhiteSpace(label)).ToArray() + : []; + var assignee = root.TryGetProperty("assignee", out var assigneeElement) && assigneeElement.ValueKind == JsonValueKind.Object && assigneeElement.TryGetProperty("login", out var login) + ? login.GetString() + : null; + return new WorkItem( + Provider, + $"{reference.Owner}/{reference.Repository}#{reference.Number}", + root.GetProperty("title").GetString() ?? reference.Url, + root.TryGetProperty("body", out var body) ? body.GetString() : null, + reference.Url, + root.TryGetProperty("state", out var state) ? state.GetString() : null, + labels, + assignee, + new Dictionary + { + ["owner"] = reference.Owner, + ["repository"] = reference.Repository, + ["kind"] = reference.Kind, + ["number"] = reference.Number.ToString() + }); + } + + /// + /// Parses GitHub issue and pull request URLs. + /// + public static bool TryParse(string idOrUrl, out GitHubWorkItemReference reference) + { + reference = default!; + if (!Uri.TryCreate(idOrUrl, UriKind.Absolute, out var uri) + || !string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var parts = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length < 4 || !int.TryParse(parts[3], out var number)) + { + return false; + } + + if (!string.Equals(parts[2], "issues", StringComparison.OrdinalIgnoreCase) + && !string.Equals(parts[2], "pull", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + reference = new GitHubWorkItemReference(parts[0], parts[1], parts[2].ToLowerInvariant(), number, idOrUrl); + return true; + } +} diff --git a/src/SharpClaw.Code.WorkItems/Services/WorkItemRegistry.cs b/src/SharpClaw.Code.WorkItems/Services/WorkItemRegistry.cs new file mode 100644 index 0000000..1a03aea --- /dev/null +++ b/src/SharpClaw.Code.WorkItems/Services/WorkItemRegistry.cs @@ -0,0 +1,16 @@ +using SharpClaw.Code.WorkItems.Abstractions; + +namespace SharpClaw.Code.WorkItems.Services; + +/// +/// Default work-item provider registry. +/// +public sealed class WorkItemRegistry(IEnumerable providers) : IWorkItemRegistry +{ + private readonly IWorkItemProvider[] orderedProviders = providers.ToArray(); + + /// + public IWorkItemProvider? Resolve(string provider, string idOrUrl) + => orderedProviders.FirstOrDefault(item => string.Equals(item.Provider, provider, StringComparison.OrdinalIgnoreCase)) + ?? orderedProviders.FirstOrDefault(item => item.CanImport(idOrUrl)); +} diff --git a/src/SharpClaw.Code.WorkItems/Services/WorkItemService.cs b/src/SharpClaw.Code.WorkItems/Services/WorkItemService.cs new file mode 100644 index 0000000..d043948 --- /dev/null +++ b/src/SharpClaw.Code.WorkItems/Services/WorkItemService.cs @@ -0,0 +1,185 @@ +using System.Text; +using System.Text.Json; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Sessions.Abstractions; +using SharpClaw.Code.Telemetry; +using SharpClaw.Code.Telemetry.Abstractions; +using SharpClaw.Code.WorkItems.Abstractions; + +namespace SharpClaw.Code.WorkItems.Services; + +/// +/// Imports work items into session metadata and exports session summaries. +/// +public sealed class WorkItemService( + IWorkItemRegistry registry, + ISessionStore sessionStore, + IEventStore eventStore, + IRuntimeEventPublisher eventPublisher, + IPathService pathService, + ISystemClock systemClock) : IWorkItemService +{ + /// + public async Task ImportAsync(WorkItemImportRequest request, CancellationToken cancellationToken) + { + var workspace = pathService.GetFullPath(request.WorkspacePath); + var provider = registry.Resolve(request.Provider, request.IdOrUrl) + ?? throw new InvalidOperationException($"No work-item provider can import '{request.IdOrUrl}'."); + var workItem = await provider.ImportAsync(request with { WorkspacePath = workspace }, cancellationToken).ConfigureAwait(false); + var (session, created) = await ResolveSessionAsync(workspace, request.SessionId, workItem, cancellationToken).ConfigureAwait(false); + + var metadata = session.Metadata is null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(session.Metadata, StringComparer.Ordinal); + metadata[SharpClawWorkflowMetadataKeys.WorkItemJson] = JsonSerializer.Serialize(workItem, ProtocolJsonContext.Default.WorkItem); + metadata[SharpClawWorkflowMetadataKeys.WorkItemGoal] = workItem.Title; + + var updated = session with + { + Title = workItem.Title, + Metadata = metadata, + UpdatedAtUtc = systemClock.UtcNow, + }; + await sessionStore.SaveAsync(workspace, updated, cancellationToken).ConfigureAwait(false); + await PublishAsync( + workspace, + updated.Id, + new WorkItemImportedEvent(CreateIdentifier("event"), updated.Id, null, systemClock.UtcNow, workItem), + cancellationToken).ConfigureAwait(false); + return new WorkItemImportResult(workItem, updated.Id, created); + } + + /// + public async Task ExportSummaryAsync(WorkItemExportRequest request, string workspaceRoot, CancellationToken cancellationToken) + { + var workspace = pathService.GetFullPath(workspaceRoot); + var session = string.IsNullOrWhiteSpace(request.SessionId) + ? await sessionStore.GetLatestAsync(workspace, cancellationToken).ConfigureAwait(false) + : await sessionStore.GetByIdAsync(workspace, request.SessionId, cancellationToken).ConfigureAwait(false); + if (session is null) + { + throw new InvalidOperationException("No session found to export."); + } + + var workItem = ReadWorkItem(session); + var events = await eventStore.ReadAllAsync(workspace, session.Id, cancellationToken).ConfigureAwait(false); + var markdown = RenderMarkdown(session, workItem, events); + var content = string.Equals(request.ExportFormat, "json", StringComparison.OrdinalIgnoreCase) + ? JsonSerializer.Serialize(new Dictionary + { + ["sessionId"] = session.Id, + ["title"] = workItem?.Title ?? session.Title, + ["markdown"] = markdown + }, ProtocolJsonContext.Default.DictionaryStringString) + : markdown; + var export = new WorkItemSummaryExport(session.Id, request.ExportFormat, content, workItem); + await PublishAsync( + workspace, + session.Id, + new WorkItemSummaryExportedEvent(CreateIdentifier("event"), session.Id, null, systemClock.UtcNow, request.Provider, request.ExportFormat), + cancellationToken).ConfigureAwait(false); + return export; + } + + private async Task<(ConversationSession Session, bool Created)> ResolveSessionAsync(string workspace, string? sessionId, WorkItem workItem, CancellationToken cancellationToken) + { + ConversationSession? session = null; + if (!string.IsNullOrWhiteSpace(sessionId)) + { + session = await sessionStore.GetByIdAsync(workspace, sessionId, cancellationToken).ConfigureAwait(false); + } + + session ??= await sessionStore.GetLatestAsync(workspace, cancellationToken).ConfigureAwait(false); + if (session is not null) + { + return (session, false); + } + + var created = new ConversationSession( + CreateIdentifier("session"), + workItem.Title, + SessionLifecycleState.Active, + PermissionMode.WorkspaceWrite, + OutputFormat.Text, + workspace, + workspace, + systemClock.UtcNow, + systemClock.UtcNow, + null, + null, + new Dictionary()); + await sessionStore.SaveAsync(workspace, created, cancellationToken).ConfigureAwait(false); + await PublishAsync(workspace, created.Id, new SessionCreatedEvent(CreateIdentifier("event"), created.Id, null, systemClock.UtcNow, created), cancellationToken).ConfigureAwait(false); + return (created, true); + } + + private static WorkItem? ReadWorkItem(ConversationSession session) + { + if (session.Metadata is null || !session.Metadata.TryGetValue(SharpClawWorkflowMetadataKeys.WorkItemJson, out var json)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.WorkItem); + } + catch (JsonException) + { + return null; + } + } + + private static string RenderMarkdown(ConversationSession session, WorkItem? workItem, IReadOnlyList events) + { + var sb = new StringBuilder(); + sb.AppendLine($"# Work Summary: {workItem?.Title ?? session.Title}"); + sb.AppendLine(); + if (workItem is not null) + { + sb.AppendLine($"- Provider: {workItem.Provider}"); + sb.AppendLine($"- Work item: {workItem.Id}"); + if (!string.IsNullOrWhiteSpace(workItem.Url)) + { + sb.AppendLine($"- URL: {workItem.Url}"); + } + + sb.AppendLine(); + } + + var completedTurns = events.OfType().ToArray(); + if (completedTurns.Length > 0) + { + sb.AppendLine("## Completed Turns"); + foreach (var turn in completedTurns) + { + sb.AppendLine($"- Turn {turn.Turn.SequenceNumber}: {turn.Summary ?? Truncate(turn.Turn.Output)}"); + } + } + else + { + sb.AppendLine("No completed turns were found for this session."); + } + + return sb.ToString(); + } + + private ValueTask PublishAsync(string workspace, string sessionId, RuntimeEvent runtimeEvent, CancellationToken cancellationToken) + => eventPublisher.PublishAsync(runtimeEvent, new RuntimeEventPublishOptions(workspace, sessionId, PersistToSessionStore: true), cancellationToken); + + private static string CreateIdentifier(string prefix) => $"{prefix}_{Guid.NewGuid():N}"; + + private static string Truncate(string? value, int max = 160) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "completed"; + } + + return value.Length <= max ? value : value[..max]; + } +} diff --git a/src/SharpClaw.Code.WorkItems/SharpClaw.Code.WorkItems.csproj b/src/SharpClaw.Code.WorkItems/SharpClaw.Code.WorkItems.csproj new file mode 100644 index 0000000..2a1dc19 --- /dev/null +++ b/src/SharpClaw.Code.WorkItems/SharpClaw.Code.WorkItems.csproj @@ -0,0 +1,18 @@ + + + + Work item integration services for SharpClaw Code. + + + + + + + + + + + + + + diff --git a/src/SharpClaw.Code.WorkItems/WorkItemsServiceCollectionExtensions.cs b/src/SharpClaw.Code.WorkItems/WorkItemsServiceCollectionExtensions.cs new file mode 100644 index 0000000..dbf8d18 --- /dev/null +++ b/src/SharpClaw.Code.WorkItems/WorkItemsServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.Infrastructure; +using SharpClaw.Code.WorkItems.Abstractions; +using SharpClaw.Code.WorkItems.Providers; +using SharpClaw.Code.WorkItems.Services; + +namespace SharpClaw.Code.WorkItems; + +/// +/// Registers work-item integration services. +/// +public static class WorkItemsServiceCollectionExtensions +{ + /// + /// Adds work-item providers and session-aware services. + /// + public static IServiceCollection AddSharpClawWorkItems(this IServiceCollection services) + { + services.AddSharpClawInfrastructure(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/tests/SharpClaw.Code.UnitTests/ExternalAgents/ExternalAgentAdapterTests.cs b/tests/SharpClaw.Code.UnitTests/ExternalAgents/ExternalAgentAdapterTests.cs new file mode 100644 index 0000000..6a8b1b5 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/ExternalAgents/ExternalAgentAdapterTests.cs @@ -0,0 +1,48 @@ +using FluentAssertions; +using SharpClaw.Code.ExternalAgents.Abstractions; +using SharpClaw.Code.ExternalAgents.Adapters; +using SharpClaw.Code.Infrastructure.Models; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.UnitTests.ExternalAgents; + +public sealed class ExternalAgentAdapterTests +{ + [Fact] + public async Task Codex_adapter_should_build_text_mode_command_and_map_output() + { + var runner = new RecordingProcessRunner(); + var adapter = new CodexCliAdapter(new FixedExecutableResolver("/bin/codex"), runner, new EnabledConfigProvider()); + + var result = await adapter.RunAsync( + new ExternalAgentRunRequest("codex", "/workspace", "review this", ExternalAgentMode.WorkspaceWrite), + CancellationToken.None); + + result.FailureKind.Should().Be(ExternalAgentFailureKind.None); + runner.LastRequest.Should().NotBeNull(); + runner.LastRequest!.Arguments.Should().Equal("exec", "review this"); + result.OutputText.Should().Contain("ok"); + } + + private sealed class FixedExecutableResolver(string path) : IExternalAgentExecutableResolver + { + public string? Resolve(string executableNameOrPath) => path; + } + + private sealed class EnabledConfigProvider : IExternalAgentConfigProvider + { + public Task GetConfigAsync(string workspaceRoot, CancellationToken cancellationToken) + => Task.FromResult(new ExternalAgentsConfig(Enabled: true)); + } + + private sealed class RecordingProcessRunner : SharpClaw.Code.Infrastructure.Abstractions.IProcessRunner + { + public ProcessRunRequest? LastRequest { get; private set; } + + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult(new ProcessRunResult(0, "ok", string.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow)); + } + } +} diff --git a/tests/SharpClaw.Code.UnitTests/SharpClaw.Code.UnitTests.csproj b/tests/SharpClaw.Code.UnitTests/SharpClaw.Code.UnitTests.csproj index 7b81886..617b3d7 100644 --- a/tests/SharpClaw.Code.UnitTests/SharpClaw.Code.UnitTests.csproj +++ b/tests/SharpClaw.Code.UnitTests/SharpClaw.Code.UnitTests.csproj @@ -23,6 +23,7 @@ + @@ -37,6 +38,7 @@ + diff --git a/tests/SharpClaw.Code.UnitTests/Skills/SkillPackRegistryTests.cs b/tests/SharpClaw.Code.UnitTests/Skills/SkillPackRegistryTests.cs new file mode 100644 index 0000000..77fb5f7 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Skills/SkillPackRegistryTests.cs @@ -0,0 +1,66 @@ +using System.Text.Json; +using FluentAssertions; +using SharpClaw.Code.Infrastructure.Services; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Skills.Services; + +namespace SharpClaw.Code.UnitTests.Skills; + +public sealed class SkillPackRegistryTests : IDisposable +{ + private readonly string workspace = Path.Combine(Path.GetTempPath(), $"sharpclaw-skillpacks-{Guid.NewGuid():N}"); + + [Fact] + public async Task ListAsync_should_include_built_in_skill_packs() + { + var registry = CreateRegistry(); + + var packs = await registry.ListAsync(workspace, CancellationToken.None); + + packs.Should().Contain(pack => pack.Manifest.Id == "pr-review" && pack.IsBuiltIn); + } + + [Fact] + public async Task InstallAsync_should_parse_manifest_and_build_prompt() + { + Directory.CreateDirectory(workspace); + var manifestPath = Path.Combine(workspace, "skillpack.json"); + var manifest = new SkillPackManifest( + "custom-review", + "Custom Review", + "1.0.0", + "Custom review pack", + "tests", + ["review"], + null, + null, + null, + null, + null, + null, + "Review {{arguments}} in {{workspace}}."); + await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(manifest, ProtocolJsonContext.Default.SkillPackManifest)); + var registry = CreateRegistry(); + + var installed = await registry.InstallAsync(workspace, new SkillPackInstallRequest(manifestPath), CancellationToken.None); + var prompt = await registry.BuildPromptAsync(workspace, new SkillPackRunRequest(installed.Manifest.Id, "abc"), CancellationToken.None); + + installed.Manifest.Id.Should().Be("custom-review"); + prompt.Should().Contain("abc").And.Contain(workspace); + } + + public void Dispose() + { + if (Directory.Exists(workspace)) + { + Directory.Delete(workspace, recursive: true); + } + } + + private static SkillPackRegistry CreateRegistry() + { + var pathService = new PathService(); + return new SkillPackRegistry(new LocalFileSystem(), pathService, new UserProfilePaths(pathService), new SystemClock()); + } +} diff --git a/tests/SharpClaw.Code.UnitTests/WorkItems/GitHubWorkItemProviderTests.cs b/tests/SharpClaw.Code.UnitTests/WorkItems/GitHubWorkItemProviderTests.cs new file mode 100644 index 0000000..50ac712 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/WorkItems/GitHubWorkItemProviderTests.cs @@ -0,0 +1,21 @@ +using FluentAssertions; +using SharpClaw.Code.WorkItems.Providers; + +namespace SharpClaw.Code.UnitTests.WorkItems; + +public sealed class GitHubWorkItemProviderTests +{ + [Theory] + [InlineData("https://github.com/clawdotnet/SharpClawCode/issues/123", "issues", 123)] + [InlineData("https://github.com/clawdotnet/SharpClawCode/pull/45", "pull", 45)] + public void TryParse_should_parse_issue_and_pr_urls(string url, string kind, int number) + { + var parsed = GitHubWorkItemProvider.TryParse(url, out var reference); + + parsed.Should().BeTrue(); + reference.Owner.Should().Be("clawdotnet"); + reference.Repository.Should().Be("SharpClawCode"); + reference.Kind.Should().Be(kind); + reference.Number.Should().Be(number); + } +}