From 96b270c703d2961cb70b5a201aa6295f6d49134e Mon Sep 17 00:00:00 2001 From: Pavel Borisov Date: Mon, 14 Sep 2026 22:01:57 +0200 Subject: [PATCH 1/5] Report a fixture deleted from outside against its owner (#781) A vanished TempDir surfaces as a DirectoryNotFoundException wherever the victim next touches its tree, arbitrarily far from whatever removed it; dispose is the one place that can still name the owner. The orchestrator tests tripped it at once: cleanup of a standalone worktree deletes the path it is handed, and they handed it the whole fixture rather than a directory under it. Co-Authored-By: Claude Opus 5 (1M context) --- .../AgentOrchestratorBracketedPasteTests.cs | 9 +++++-- .../AgentOrchestratorLocalAttachTests.cs | 19 ++++++++------ .../AgentOrchestratorStopDiagnosticsTests.cs | 13 +++++++--- .../Services/AgentOrchestratorVendorTests.cs | 15 ++++++----- .../Services/LocalPermissionBridgeTests.cs | 25 +++++++++++-------- .../Commands/SqliteNativeResolverTests.cs | 5 ++-- test/Capacitor.Tests.Helpers/TempDir.cs | 15 +++++++++++ 7 files changed, 69 insertions(+), 32 deletions(-) diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorBracketedPasteTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorBracketedPasteTests.cs index e4cce87d2..5a8759728 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorBracketedPasteTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorBracketedPasteTests.cs @@ -11,6 +11,11 @@ namespace Capacitor.Cli.Daemon.Tests.Unit.Services; public class AgentOrchestratorBracketedPasteTests { [TempDir] public required TempDir Worktree { get; init; } + /// A directory under the fixture, never the fixture itself: cleanup of a standalone + /// worktree deletes the path it is given, and a fixture that vanishes under its own test takes + /// the reason for every later failure with it. + string WorktreePath => Worktree.CreateDir("worktree"); + const string PasteStart = "\x1b[200~"; const string PasteEnd = "\x1b[201~"; @@ -24,8 +29,8 @@ public async Task HandleSendInput_wraps_the_message_in_a_bracketed_paste_and_sub await using var orch = AgentOrchestratorHarness.BuildOrchestrator(server, new SpyPtyProcessFactory(), new Dictionary()); var agent = new AgentInstance( - "agent-paste", null, "", null, Worktree.Path, "codex", - new PtyHostedAgentRuntime("codex", pty, approvalsDisabled: true), new WorktreeInfo(Worktree.Path, "", Worktree.Path, IsStandalone: true), new CancellationTokenSource()); + "agent-paste", null, "", null, WorktreePath, "codex", + new PtyHostedAgentRuntime("codex", pty, approvalsDisabled: true), new WorktreeInfo(WorktreePath, "", WorktreePath, IsStandalone: true), new CancellationTokenSource()); orch.RegisterAgentForTest(agent); await orch.HandleSendInputForTest(new SendInputCommand("agent-paste", message, null)); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorLocalAttachTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorLocalAttachTests.cs index a8d8e55a1..7e5c26dd2 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorLocalAttachTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorLocalAttachTests.cs @@ -122,13 +122,14 @@ public async Task Borrowed_cwd_cleanup_does_not_delete_user_dir_or_branch() { [Test] public async Task Owned_worktree_cleanup_still_removes_it() { using var tmp = new TempDir(); + string worktree = tmp.CreateDir("worktree"); var server = new CaptureServerConnection(); await using var orch = AgentOrchestratorHarness.BuildOrchestrator(server, new SpyPtyProcessFactory(), new Dictionary()); var agent = new AgentInstance( - "owned-1", null, "", null, tmp.Path, "claude", - new PtyHostedAgentRuntime("claude", new StubPtyProcess()), new WorktreeInfo(tmp.Path, "", tmp.Path, IsStandalone: true), new CancellationTokenSource() + "owned-1", null, "", null, worktree, "claude", + new PtyHostedAgentRuntime("claude", new StubPtyProcess()), new WorktreeInfo(worktree, "", worktree, IsStandalone: true), new CancellationTokenSource() ) { Work = WorkLocation.OwnedWorktree }; @@ -136,7 +137,9 @@ public async Task Owned_worktree_cleanup_still_removes_it() { orch.RegisterAgentForTest(agent); await orch.CleanupAgentForTest("owned-1"); - await Assert.That(Directory.Exists(tmp.Path)).IsFalse(); + await Assert.That(Directory.Exists(worktree)).IsFalse(); + // Scoped to the worktree: the cleanup owns what it was handed, not the tree above it. + await Assert.That(Directory.Exists(tmp.Path)).IsTrue(); } [Test] @@ -525,13 +528,14 @@ public async Task Private_agents_are_excluded_from_live_agent_ids() { [Arguments("cursor")] public async Task Attach_to_a_runtime_with_no_terminal_is_refused_by_name(string vendor) { using var worktree = new TempDir(); + string worktreePath = worktree.CreateDir("worktree"); var server = new CaptureServerConnection(); await using var orch = AgentOrchestratorHarness.BuildOrchestrator(server, new SpyPtyProcessFactory(), new Dictionary()); var runtime = new NoRawInputRuntime(vendor); var agent = new AgentInstance( - "hosted-1", null, "", null, worktree.Path, vendor, - runtime, new WorktreeInfo(worktree.Path, "", worktree.Path, IsStandalone: true), new CancellationTokenSource() + "hosted-1", null, "", null, worktreePath, vendor, + runtime, new WorktreeInfo(worktreePath, "", worktreePath, IsStandalone: true), new CancellationTokenSource() ); orch.RegisterAgentForTest(agent); @@ -563,6 +567,7 @@ public async Task Attach_to_a_runtime_with_no_terminal_is_refused_by_name(string [Test] public async Task Attach_to_a_terminal_runtime_that_rejects_raw_input_gets_an_error_frame_instead_of_crashing() { using var worktree = new TempDir(); + string worktreePath = worktree.CreateDir("worktree"); var server = new CaptureServerConnection(); await using var orch = AgentOrchestratorHarness.BuildOrchestrator(server, new SpyPtyProcessFactory(), new Dictionary()); @@ -571,8 +576,8 @@ public async Task Attach_to_a_terminal_runtime_that_rejects_raw_input_gets_an_er // disagree. var runtime = new NoRawInputRuntime("claude", emitsTerminalOutput: true); var agent = new AgentInstance( - "pty-1", null, "", null, worktree.Path, "claude", - runtime, new WorktreeInfo(worktree.Path, "", worktree.Path, IsStandalone: true), new CancellationTokenSource() + "pty-1", null, "", null, worktreePath, "claude", + runtime, new WorktreeInfo(worktreePath, "", worktreePath, IsStandalone: true), new CancellationTokenSource() ); orch.RegisterAgentForTest(agent); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorStopDiagnosticsTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorStopDiagnosticsTests.cs index 62f6967e2..50290e172 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorStopDiagnosticsTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorStopDiagnosticsTests.cs @@ -10,6 +10,11 @@ namespace Capacitor.Cli.Daemon.Tests.Unit.Services; public class AgentOrchestratorStopDiagnosticsTests { [TempDir] public required TempDir Worktree { get; init; } + /// A directory under the fixture, never the fixture itself: cleanup of a standalone + /// worktree deletes the path it is given, and a fixture that vanishes under its own test takes + /// the reason for every later failure with it. + string WorktreePath => Worktree.CreateDir("worktree"); + sealed class CapturingOrchestratorLogger : ILogger { public List Messages { get; } = []; public IDisposable? BeginScope(TState state) where TState : notnull => null; @@ -34,9 +39,9 @@ public async Task Graceful_exit_timeout_warning_names_the_agents_own_vendor(stri // from WaitForExitAsync immediately — i.e. exactly the "graceful window elapsed without the // CLI exiting" state, with no real 15s wait. orch.RegisterAgentForTest(new AgentInstance( - $"agent-{vendor}", null, "", null, Worktree.Path, vendor, + $"agent-{vendor}", null, "", null, WorktreePath, vendor, new FakeHostedAgentRuntime(vendor, emitsTerminalOutput: false), - new WorktreeInfo(Worktree.Path, "", Worktree.Path, IsStandalone: true), new CancellationTokenSource())); + new WorktreeInfo(WorktreePath, "", WorktreePath, IsStandalone: true), new CancellationTokenSource())); await orch.HandleStopAgent($"agent-{vendor}"); @@ -55,9 +60,9 @@ public async Task Graceful_exit_timeout_warning_still_names_claude_for_a_claude_ new Dictionary(), logger: log); orch.RegisterAgentForTest(new AgentInstance( - "agent-claude", null, "", null, Worktree.Path, "claude", + "agent-claude", null, "", null, WorktreePath, "claude", new FakeHostedAgentRuntime("claude", emitsTerminalOutput: false), - new WorktreeInfo(Worktree.Path, "", Worktree.Path, IsStandalone: true), new CancellationTokenSource())); + new WorktreeInfo(WorktreePath, "", WorktreePath, IsStandalone: true), new CancellationTokenSource())); await orch.HandleStopAgent("agent-claude"); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorVendorTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorVendorTests.cs index 2ccc5e289..4b257de3d 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorVendorTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorVendorTests.cs @@ -63,13 +63,14 @@ public async Task Git_spawn_failure_reports_the_working_directory_and_PATH_resol [Test] public async Task ReRegister_retries_a_transient_per_agent_failure_then_succeeds() { using var worktree = new TempDir(); + string worktreePath = worktree.CreateDir("worktree"); var server = new CaptureServerConnection { AgentRegisteredFailTimes = 1 }; await using var orch = AgentOrchestratorHarness.BuildOrchestrator(server, new SpyPtyProcessFactory(), new Dictionary()); orch.RegisterAgentForTest(new AgentInstance( - "agent-rereg", null, "", null, worktree.Path, "claude", - new PtyHostedAgentRuntime("claude", new StubPtyProcess()), new WorktreeInfo(worktree.Path, "", worktree.Path, IsStandalone: true), new CancellationTokenSource() + "agent-rereg", null, "", null, worktreePath, "claude", + new PtyHostedAgentRuntime("claude", new StubPtyProcess()), new WorktreeInfo(worktreePath, "", worktreePath, IsStandalone: true), new CancellationTokenSource() )); // The orchestrator wires ReRegisterAgentsHook in its ctor; invoking it runs the same @@ -85,13 +86,14 @@ public async Task ReRegister_retries_a_transient_per_agent_failure_then_succeeds [Test] public async Task ReRegister_reports_pty_transport_for_a_pty_codex_runtime() { using var worktree = new TempDir(); + string worktreePath = worktree.CreateDir("worktree"); var server = new CaptureServerConnection(); await using var orch = AgentOrchestratorHarness.BuildOrchestrator(server, new SpyPtyProcessFactory(), new Dictionary()); orch.RegisterAgentForTest(new AgentInstance( - "agent-codex-pty", null, "", null, worktree.Path, "codex", - new PtyHostedAgentRuntime("codex", new StubPtyProcess()), new WorktreeInfo(worktree.Path, "", worktree.Path, IsStandalone: true), new CancellationTokenSource() + "agent-codex-pty", null, "", null, worktreePath, "codex", + new PtyHostedAgentRuntime("codex", new StubPtyProcess()), new WorktreeInfo(worktreePath, "", worktreePath, IsStandalone: true), new CancellationTokenSource() )); await server.ReRegisterAgentsHook!(); @@ -544,6 +546,7 @@ public async Task Non_codex_launch_echoes_no_posture() { [Test] public async Task Reregistration_resends_the_same_applied_posture() { using var worktree = new TempDir(); + string worktreePath = worktree.CreateDir("worktree"); // A server restart wipes the in-memory echo; the reconnect path rebuilds it from the // AgentInstance, so the pair must survive rather than silently becoming null. var server = new CaptureServerConnection(); @@ -552,9 +555,9 @@ public async Task Reregistration_resends_the_same_applied_posture() { await using var orch = AgentOrchestratorHarness.BuildOrchestrator(server, ptyFactory, new Dictionary()); orch.RegisterAgentForTest(new AgentInstance( - "agent-rereg-posture", null, "", null, worktree.Path, "codex", + "agent-rereg-posture", null, "", null, worktreePath, "codex", new PtyHostedAgentRuntime("codex", new StubPtyProcess()), - new WorktreeInfo(worktree.Path, "", worktree.Path, IsStandalone: true), new CancellationTokenSource() + new WorktreeInfo(worktreePath, "", worktreePath, IsStandalone: true), new CancellationTokenSource() ) { SandboxPolicy = "danger-full-access", ApprovalPolicy = "never" }); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalPermissionBridgeTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalPermissionBridgeTests.cs index 4acc8f969..f9baf3860 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalPermissionBridgeTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalPermissionBridgeTests.cs @@ -341,33 +341,38 @@ public async Task ServerFailureFallsBackToDeny() { } } + /// A port below every platform's ephemeral range — Linux allocates from 32768, macOS + /// and Windows from 49152 — so nothing else on the machine is handed it while this test holds + /// the gap between releasing it and binding it again. + const int RebindablePort = 28137; + [Test, NotInParallel(nameof(LocalPermissionBridgeTests))] public async Task StopAsyncReleasesPort() { - var (bridge, _) = CreateBridge(); + var (bridge, _) = CreateBridgeOn(new FakeLoopbackPortSource(RebindablePort)); + TcpListener? probe = null; var disposed = false; try { await bridge.StartAsync(CancellationToken.None); - var port = new Uri(bridge.BaseUrl!).Port; + // The bridge retries onto an ephemeral port when its first choice is taken, and a probe + // rebinding a port the bridge never held would pass whatever StopAsync did. + await Assert.That(new Uri(bridge.BaseUrl!).Port).IsEqualTo(RebindablePort); + await bridge.StopAsync(CancellationToken.None); - // After stop, the port should accept a fresh bind. If StopAsync didn't release - // it, this would either throw or hang. - probe = new TcpListener(IPAddress.Loopback, port); + probe = new TcpListener(IPAddress.Loopback, RebindablePort); probe.Start(); - // Keep the replacement listener bound while disposing the bridge. This reproduces - // the suite-level race where StopAsync released the port, another fixture claimed it, - // and the old listener's later Close() threw EADDRINUSE. + // Disposed while the replacement listener holds the port: shutting down a bridge that + // no longer owns what it bound must not fault. await bridge.DisposeAsync(); disposed = true; } finally { probe?.Stop(); - // Ensure cleanup still runs if setup or the assertion above fails. Dispose is - // intentionally idempotent, so retrying after a partial shutdown is safe. + // Dispose is idempotent, so cleaning up after a partial shutdown is safe. if (!disposed) await bridge.DisposeAsync(); } } diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SqliteNativeResolverTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SqliteNativeResolverTests.cs index a1db93f51..afe8ebd59 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/SqliteNativeResolverTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/SqliteNativeResolverTests.cs @@ -57,9 +57,8 @@ public async Task downloads_from_mirror_verifies_caches_and_loads() { await Assert.That(IsLoadableSqlite(path)).IsTrue() .Because("the cached native must be a real, loadable SQLite engine"); - // Second call is cache-only — works even after the mirror disappears. - Directory.Delete(mirror.Path, true); - var again = SqliteNativeResolver.EnsureNativeLibrary(rid, mirror.Path, cache.Path, "0.0.0"); + // Second call is cache-only: a mirror path that does not exist cannot be the source. + var again = SqliteNativeResolver.EnsureNativeLibrary(rid, mirror.PathTo("absent"), cache.Path, "0.0.0"); await Assert.That(again).IsEqualTo(path); } diff --git a/test/Capacitor.Tests.Helpers/TempDir.cs b/test/Capacitor.Tests.Helpers/TempDir.cs index 0c8f19ec1..bc845d376 100644 --- a/test/Capacitor.Tests.Helpers/TempDir.cs +++ b/test/Capacitor.Tests.Helpers/TempDir.cs @@ -76,7 +76,22 @@ public string CreateFile(ReadOnlySpan segments, string content = "") => public string CreateFile(string relativePath, string[] lines) => Root.CreateFile(relativePath, lines); + bool _disposed; + + /// Deleting is best effort; finding it already gone is not. Nothing but this owner may + /// remove the directory, so its absence means something reached outside its own — and the + /// failures that causes surface far from the cause, in whatever the victim was doing when its + /// tree went away. public void Dispose() { + if (_disposed) return; + + _disposed = true; + + if (!Directory.Exists(Path)) + throw new InvalidOperationException( + $"{Path} was gone before its owner disposed it: something outside this fixture " + + $"deleted a directory that is not its own."); + try { Directory.Delete(Path, recursive: true); } catch { /* best effort */ } } From f8d93ad3f2ff844d26c464c39eef24c1d1585238 Mon Sep 17 00:00:00 2001 From: Pavel Borisov Date: Mon, 14 Sep 2026 23:35:33 +0200 Subject: [PATCH 2/5] Resolve the git provider router from DI instead of a static memo Remembering a host probe only pays off if one router outlives a call, and the watcher re-detects every 60s for as long as it runs, so the router is a singleton that call sites take rather than construct. A static memo was shared across concurrent tests that each cleared it, so one test's write could answer another's probe and its budget assertion saw no round trip. --- src/Capacitor.Cli/Commands/CommandServices.cs | 4 + src/Capacitor.Cli/Commands/CurateCommand.cs | 5 +- .../Harness/AntigravityHookCommand.cs | 10 +- .../Commands/Harness/ClaudeHookCommand.cs | 13 ++- .../Commands/Harness/CodexHookCommand.cs | 10 +- .../Commands/Harness/CopilotHookCommand.cs | 10 +- .../Commands/Harness/CursorHookCommand.cs | 8 +- .../Commands/Harness/GeminiHookCommand.cs | 10 +- .../Commands/Harness/KiroHookCommand.cs | 10 +- .../Commands/Harness/OpenCodeHookCommand.cs | 10 +- .../Commands/Harness/PiHookCommand.cs | 10 +- src/Capacitor.Cli/Commands/ImportCommand.cs | 13 ++- .../Commands/McpAnalyticsServer.cs | 5 +- src/Capacitor.Cli/Commands/McpFlowsServer.cs | 6 +- src/Capacitor.Cli/Commands/McpMemoryServer.cs | 5 +- src/Capacitor.Cli/Commands/McpReviewServer.cs | 5 +- .../Commands/McpSessionsServer.cs | 5 +- src/Capacitor.Cli/Commands/RecapCommand.cs | 7 +- src/Capacitor.Cli/Commands/SessionsCommand.cs | 5 +- src/Capacitor.Cli/Commands/SetupCommand.cs | 30 ++--- .../Commands/SetupImportRunner.cs | 8 +- src/Capacitor.Cli/Commands/SkillsCommand.cs | 6 +- .../Commands/TranscriptFileClassification.cs | 10 +- src/Capacitor.Cli/Commands/WatchCommand.cs | 10 +- src/Capacitor.Cli/CwdRepository.cs | 4 +- .../Harness/Claude/ClaudeImportSource.cs | 5 +- .../Harness/Codex/CodexImportSource.cs | 5 +- .../Harness/Copilot/CopilotImportSource.cs | 4 +- .../Harness/Cursor/CursorImportSource.cs | 4 +- .../Harness/Kiro/KiroImportSource.cs | 4 +- .../Harness/Pi/PiImportSource.cs | 4 +- .../PrDetection/GitProviderRouter.cs | 22 ++-- src/Capacitor.Cli/Program.cs | 6 +- src/Capacitor.Cli/RepoExclusion.cs | 6 +- src/Capacitor.Cli/RepositoryDetection.cs | 17 +-- .../SessionStartMemoryHookSupport.cs | 4 +- .../SessionStartMemoryScopeResolver.cs | 6 +- .../AntigravitySessionStartTests.cs | 5 +- ...vitySkippedChildOverrideRoutedLoopTests.cs | 3 +- .../ClaudeHookStdoutTests.cs | 3 +- ...SessionStartHandshakeOnPostFailureTests.cs | 3 +- .../CodexSessionStartVisibilityTests.cs | 5 +- .../CopilotImportSourceImportTests.cs | 3 +- .../CursorImportPrTests.cs | 3 +- .../CursorPrivatizeLifecycleFailureTests.cs | 17 +-- .../CursorSessionStartVisibilityTests.cs | 3 +- .../CursorSuppressedRepoImportTests.cs | 3 +- .../CursorTailingWatcherTests.cs | 7 +- ...SessionStartHandshakeOnPostFailureTests.cs | 3 +- .../GeminiStderrShadowedOnPostFailureTests.cs | 3 +- .../ImportEndReassertTests.cs | 4 +- .../KiroImportSourceImportTests.cs | 3 +- .../PiImportSourceImportTests.cs | 9 +- .../RoutedPrivatizeMembershipTests.cs | 5 +- .../RoutedReplayPrivatizeTests.cs | 5 +- .../SessionStartCoordinationNoticesTests.cs | 7 +- .../SessionStartMemoryRedirectTests.cs | 3 +- .../SessionStartVisibilityTests.cs | 11 +- .../SpoolOutageRecoveryTests.cs | 3 +- .../WatcherHubCredentialTests.cs | 3 +- .../WatcherParentExitPostTests.cs | 3 +- .../Harness/AntigravityHookCommandTests.cs | 13 ++- .../Harness/ClaudeHookCommandTests.cs | 49 ++++---- .../Harness/ClaudeHookInputWaitRelayTests.cs | 3 +- .../Commands/Harness/CodexHookCommandTests.cs | 63 +++++------ .../Harness/CodexHookInputWaitRelayTests.cs | 3 +- .../Harness/CursorHookCommandTests.cs | 23 ++-- .../Commands/ImportChainsTests.cs | 3 +- .../Commands/ImportClassifyTests.cs | 12 ++ .../Commands/ImportDiscoveryAgeTests.cs | 17 +-- .../ImportResolveReposSubSessionTests.cs | 3 +- .../Commands/ImportSkipTitleTests.cs | 5 +- .../ImportVendorSelectionOutputTests.cs | 3 +- .../Commands/ImportVisibilityTests.cs | 105 +++++++++--------- .../Commands/McpAnalyticsServerTests.cs | 3 +- .../McpFlowsServerReviewerVendorsTests.cs | 3 +- .../McpFlowsServerSettlementRetryTests.cs | 3 +- .../Commands/McpFlowsServerTests.cs | 3 +- .../McpFlowsServerVendorOverrideTests.cs | 3 +- .../ParticipantUnreachableRetryTests.cs | 3 +- .../ReplayChildContentCapabilityTests.cs | 13 ++- .../Commands/ReviewerVendorFallbackTests.cs | 3 +- .../Commands/SetupChosenServerTests.cs | 3 +- .../Commands/SetupCommandTests.cs | 3 +- .../Commands/SetupFacadeParityTests.cs | 3 +- .../Commands/SetupImportLaneTests.cs | 13 ++- .../Commands/ShutdownTranscriptSpoolTests.cs | 3 +- .../Commands/StatusWaitArgumentTests.cs | 3 +- .../Commands/ToolCallBudgetTests.cs | 3 +- .../Commands/UnusableUrlGuardTests.cs | 11 +- .../CwdRepositoryTests.cs | 10 +- .../AntigravitySessionStartMemoryTests.cs | 9 +- .../Claude/ClaudeHookExclusionGateTests.cs | 3 +- .../Harness/Claude/ClaudeImportSourceTests.cs | 11 +- .../Harness/Codex/CodexImportSourceTests.cs | 11 +- .../Harness/Codex/CodexImportTests.cs | 3 + .../Codex/CodexSubagentDiscoveryTests.cs | 3 +- .../Copilot/CopilotImportSourceTests.cs | 13 ++- .../Harness/Cursor/CursorGuardWiringTests.cs | 3 +- .../Harness/Cursor/CursorImportSourceTests.cs | 105 +++++++++--------- .../CursorLiveSubagentIntegrationTests.cs | 3 +- .../CursorOrphanedChildStandaloneTests.cs | 11 +- .../Cursor/CursorReconnectRewindTests.cs | 3 +- .../Cursor/CursorSubagentStaleStateTests.cs | 9 +- .../Cursor/CursorTopLevelStreamingTests.cs | 3 +- .../Harness/Cursor/CursorWatcherSpawnTests.cs | 11 +- .../Gemini/GeminiHookOutputContractTests.cs | 3 +- .../Gemini/GeminiSessionStartMemoryTests.cs | 3 +- .../Harness/Kiro/KiroImportSourceTests.cs | 5 +- .../Harness/Pi/PiImportSourceTests.cs | 17 +-- .../Harness/Pi/PiSessionStartMemoryTests.cs | 3 +- .../ImportProviderDetectionTests.cs | 4 +- .../PrDetection/GitProviderRouterTests.cs | 29 +++-- .../ProviderBudgetSplitTests.cs | 15 +-- .../RepositoryDetectionCacheTests.cs | 5 +- .../RepositoryDetectionPrSkipTests.cs | 9 +- .../RepositoryDetectionTrackedBranchTests.cs | 6 +- 117 files changed, 636 insertions(+), 475 deletions(-) diff --git a/src/Capacitor.Cli/Commands/CommandServices.cs b/src/Capacitor.Cli/Commands/CommandServices.cs index 5162fae4b..d705b3510 100644 --- a/src/Capacitor.Cli/Commands/CommandServices.cs +++ b/src/Capacitor.Cli/Commands/CommandServices.cs @@ -7,6 +7,7 @@ using Capacitor.Cli.Core.Setup; using Capacitor.Cli.Core.Telemetry; using Microsoft.Extensions.DependencyInjection; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands; @@ -33,6 +34,9 @@ public static IServiceCollection AddCapacitorCli( services.AddSingleton(_ => WatcherPaths.FromEnvironment(config)); services.AddSingleton(); + // Singleton deliberately: per-resolution routers would each start with an empty memo. + services.AddSingleton(); + // Factories because only a handful of commands take either. The registry is built over the // same probe instance, so a harness binary and a configured path search one PATH. services.AddSingleton(_ => BinaryProbe.FromEnvironment()); diff --git a/src/Capacitor.Cli/Commands/CurateCommand.cs b/src/Capacitor.Cli/Commands/CurateCommand.cs index 2440b84cb..f52acc69f 100644 --- a/src/Capacitor.Cli/Commands/CurateCommand.cs +++ b/src/Capacitor.Cli/Commands/CurateCommand.cs @@ -1,10 +1,11 @@ using Capacitor.Cli.Core; using Capacitor.Cli.Core.Curation; using Capacitor.Cli.Core.Http; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands; -class CurateCommand(ConfigRoot config, IRepositoriesApi repositories) { +class CurateCommand(ConfigRoot config, IRepositoriesApi repositories, GitProviderRouter router) { /// One page is all this command reads; hitting it exactly is what the warning below /// reports, so the request and the check must name the same number. const int PageLimit = 100; @@ -20,7 +21,7 @@ public async Task HandleApply(bool dryRun, bool yes) { } // 2. Identify the repo for the server key. - var repo = await RepositoryDetection.DetectRepositoryAsync(config, cwd); + var repo = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd); if (repo?.Owner is null || repo.RepoName is null) { await Console.Error.WriteLineAsync("Could not determine the repo's owner/name from its git remote."); return 1; diff --git a/src/Capacitor.Cli/Commands/Harness/AntigravityHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/AntigravityHookCommand.cs index 4ff8ec19f..9c2551393 100644 --- a/src/Capacitor.Cli/Commands/Harness/AntigravityHookCommand.cs +++ b/src/Capacitor.Cli/Commands/Harness/AntigravityHookCommand.cs @@ -5,6 +5,7 @@ using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core.Http; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands.Harness; @@ -37,7 +38,8 @@ namespace Capacitor.Cli.Commands.Harness; /// sealed class AntigravityHookCommand( ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home, - HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers) { + HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers, + GitProviderRouter router) { readonly AgentHookPoster _poster = new(config, profiles, http, watchers); string Url => profiles.Resolution.ServerUrl!; @@ -169,10 +171,10 @@ HookBudget budget forwarded["default_visibility"] = visibility; SessionStartInventory.Stamp(forwarded, config, harnesses); - var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(config, forwarded.ToJsonString()); + var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(router, config, forwarded.ToJsonString()); if (activeProfile?.ExcludedRepos is { Length: > 0 } excludedRepos - && await RepoExclusion.IsExcludedAsync(config, enriched, excludedRepos)) { + && await RepoExclusion.IsExcludedAsync(router, config, enriched, excludedRepos)) { DisabledSessions.Mark(sessionId, config); return 0; } @@ -303,7 +305,7 @@ internal static SessionMemoryLifecycle LifecycleFor(string sessionId) => try { var store = SessionStartMemoryLeaseStore.Create(config, clock.Time); - var provider = SessionStartMemoryHookSupport.CompositeProvider(config, http.ForMemoryAsync, clock.Time); + var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, http.ForMemoryAsync, clock.Time); return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync( LifecycleFor(sessionId), diff --git a/src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs index 0b848ebd5..8426edc6a 100644 --- a/src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs +++ b/src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs @@ -9,6 +9,7 @@ using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core.Http; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands.Harness; @@ -23,7 +24,7 @@ namespace Capacitor.Cli.Commands.Harness; public sealed class ClaudeHookCommand( ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home, HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers, - IProcessStarter starter) { + IProcessStarter starter, GitProviderRouter router) { string Url => profiles.Resolution.ServerUrl!; @@ -277,7 +278,7 @@ internal async Task ShouldSuppressCaptureAsync( internal async Task IsSessionExcludedAsync(Profile? profile, string body, HookBudget budget) { if (profile?.ExcludedRepos is { Length: > 0 } repos - && await RepoExclusion.IsExcludedAsync(config, body, repos, budget.Remaining)) { + && await RepoExclusion.IsExcludedAsync(router, config, body, repos, budget.Remaining)) { return true; } @@ -406,13 +407,13 @@ internal async Task HandleCore(HttpClient client, AuthStatus authStatus, Ho if (command == "session-start") { // Awaited INSIDE the session-start block after EnsureWatcherRunning so it never delays // transcript-capture start. - deferredRepoTask = RepositoryDetection.EnrichWithRepositoryInfo(config, body, budget.Remaining, detectPullRequest: false); + deferredRepoTask = RepositoryDetection.EnrichWithRepositoryInfo(router, config, body, budget.Remaining, detectPullRequest: false); } else if (command is "session-end" or "subagent-stop") { // Budgeted so a slow git probe can't push the bounded POST/spool path past the hook // deadline. The await below is also budget-bounded as a hard backstop. - deferredRepoTask = RepositoryDetection.EnrichWithRepositoryInfo(config, body, budget.Remaining, detectPullRequest: false); + deferredRepoTask = RepositoryDetection.EnrichWithRepositoryInfo(router, config, body, budget.Remaining, detectPullRequest: false); } else { - body = await RepositoryDetection.EnrichWithRepositoryInfo(config, body, detectPullRequest: false); + body = await RepositoryDetection.EnrichWithRepositoryInfo(router, config, body, detectPullRequest: false); } // Resolve the V2 profile once for repo/path exclusion and @@ -1112,7 +1113,7 @@ static void NormalizeGuidField(JsonNode node, string fieldName) { try { var store = SessionStartMemoryLeaseStore.Create(config, clock.Time); var provider = new SessionStartMemoryContextProvider( - new SessionStartMemoryScopeResolver(config, clock.Time), http.ForMemoryAsync, clock.Time); + new SessionStartMemoryScopeResolver(router, config, clock.Time), http.ForMemoryAsync, clock.Time); return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync( new SessionMemoryLifecycle(HarnessId.Claude, nativeSessionId, null, diff --git a/src/Capacitor.Cli/Commands/Harness/CodexHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/CodexHookCommand.cs index ff747e17c..06ea28e57 100644 --- a/src/Capacitor.Cli/Commands/Harness/CodexHookCommand.cs +++ b/src/Capacitor.Cli/Commands/Harness/CodexHookCommand.cs @@ -8,6 +8,7 @@ // ReSharper disable ShortLivedHttpClient using Capacitor.Cli.Core.Http; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands.Harness; @@ -39,7 +40,8 @@ namespace Capacitor.Cli.Commands.Harness; /// sealed class CodexHookCommand( ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home, - HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers) { + HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers, + GitProviderRouter router) { readonly AgentHookPoster _poster = new(config, profiles, http, watchers); string Url => profiles.Resolution.ServerUrl!; @@ -155,7 +157,7 @@ internal static async Task RunSessionStartHandshakeForTest(Action writeStdout, F // the injected client factory can throw synchronously. try { var store = SessionStartMemoryLeaseStore.Create(config, clock.Time); - var provider = SessionStartMemoryHookSupport.CompositeProvider(config, http.ForMemoryAsync, clock.Time); + var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, http.ForMemoryAsync, clock.Time); return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync( new SessionMemoryLifecycle(HarnessId.Codex, sessionId!, LifecycleInstanceId: null, @@ -343,7 +345,7 @@ async Task HandleSessionStart(JsonNode node, Profile? activeProfile, HookBu } SessionStartInventory.Stamp(node.AsObject(), config, harnesses); - var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(config, node.ToJsonString()); + var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(router, config, node.ToJsonString()); // Repo exclusion runs here (not above the event switch) so that the // repository block is already populated by enrichment — RepoExclusion @@ -353,7 +355,7 @@ async Task HandleSessionStart(JsonNode node, Profile? activeProfile, HookBu // take the existing disabled-session fast path at the top of Handle // without paying any git cost. if (activeProfile?.ExcludedRepos is { Length: > 0 } excludedRepos - && await RepoExclusion.IsExcludedAsync(config, enriched, excludedRepos)) { + && await RepoExclusion.IsExcludedAsync(router, config, enriched, excludedRepos)) { var excludedSessionId = TryGetString(node, "session_id"); if (excludedSessionId is not null) DisabledSessions.Mark(excludedSessionId, config); diff --git a/src/Capacitor.Cli/Commands/Harness/CopilotHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/CopilotHookCommand.cs index ee1d20cfb..e6d93babd 100644 --- a/src/Capacitor.Cli/Commands/Harness/CopilotHookCommand.cs +++ b/src/Capacitor.Cli/Commands/Harness/CopilotHookCommand.cs @@ -7,6 +7,7 @@ using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core.Http; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands.Harness; @@ -44,7 +45,8 @@ namespace Capacitor.Cli.Commands.Harness; /// sealed class CopilotHookCommand( ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home, - HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers) { + HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers, + GitProviderRouter router) { readonly AgentHookPoster _poster = new(config, profiles, http, watchers); string Url => profiles.Resolution.ServerUrl!; @@ -112,7 +114,7 @@ internal static void WriteSessionStartOutput(TextWriter writer, string? fragment try { var store = SessionStartMemoryLeaseStore.Create(config, clock.Time); - var provider = SessionStartMemoryHookSupport.CompositeProvider(config, http.ForMemoryAsync, clock.Time); + var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, http.ForMemoryAsync, clock.Time); return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync( new SessionMemoryLifecycle(HarnessId.Copilot, sessionId, LifecycleInstanceId: null, @@ -258,12 +260,12 @@ HookBudget budget } SessionStartInventory.Stamp(forwarded, config, harnesses); - var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(config, forwarded.ToJsonString()); + var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(router, config, forwarded.ToJsonString()); // Repo exclusion after enrichment (fast in-payload path) — mark the // session so per-turn agentStop events skip via DisabledSessions. if (activeProfile?.ExcludedRepos is { Length: > 0 } excludedRepos - && await RepoExclusion.IsExcludedAsync(config, enriched, excludedRepos)) { + && await RepoExclusion.IsExcludedAsync(router, config, enriched, excludedRepos)) { DisabledSessions.Mark(sessionId, config); return 0; } diff --git a/src/Capacitor.Cli/Commands/Harness/CursorHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/CursorHookCommand.cs index 9e194d62a..9af96bdd9 100644 --- a/src/Capacitor.Cli/Commands/Harness/CursorHookCommand.cs +++ b/src/Capacitor.Cli/Commands/Harness/CursorHookCommand.cs @@ -10,6 +10,7 @@ using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core.Http; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands.Harness; @@ -23,7 +24,8 @@ namespace Capacitor.Cli.Commands.Harness; /// public sealed class CursorHookCommand( ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home, - HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers) { + HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers, + GitProviderRouter router) { readonly CursorMarkers _markers = new(config); string Url => profiles.Resolution.ServerUrl!; @@ -378,7 +380,7 @@ HookBudget budget var remaining = budget.Remaining; if (remaining > TimeSpan.Zero) { node = JsonNode.Parse( - await RepositoryDetection.EnrichWithRepositoryInfoFromCwd(config, node.ToJsonString(), workspaceRoot, remaining) + await RepositoryDetection.EnrichWithRepositoryInfoFromCwd(router, config, node.ToJsonString(), workspaceRoot, remaining) ) ?? node; } } @@ -583,7 +585,7 @@ HookBudget budget var store = SessionStartMemoryLeaseStore.Create(config, clock.Time); // Both lanes send on the hook's own client, which stays this method's caller's to dispose. - var provider = SessionStartMemoryHookSupport.CompositeProvider(config, _ => Task.FromResult(client), clock.Time); + var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, _ => Task.FromResult(client), clock.Time); return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync( // ClassificationAuthoritative is hardcoded true, and this is VALID UNDER THE diff --git a/src/Capacitor.Cli/Commands/Harness/GeminiHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/GeminiHookCommand.cs index 408396207..1ed69541a 100644 --- a/src/Capacitor.Cli/Commands/Harness/GeminiHookCommand.cs +++ b/src/Capacitor.Cli/Commands/Harness/GeminiHookCommand.cs @@ -8,6 +8,7 @@ using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core.Http; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands.Harness; @@ -65,7 +66,8 @@ namespace Capacitor.Cli.Commands.Harness; /// sealed class GeminiHookCommand( ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home, - HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers) { + HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers, + GitProviderRouter router) { readonly AgentHookPoster _poster = new(config, profiles, http, watchers); string Url => profiles.Resolution.ServerUrl!; @@ -194,7 +196,7 @@ internal static SessionMemoryLifecycle LifecycleFor(string sessionId, string? so try { var store = SessionStartMemoryLeaseStore.Create(config, clock.Time); - var provider = SessionStartMemoryHookSupport.CompositeProvider(config, http.ForMemoryAsync, clock.Time); + var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, http.ForMemoryAsync, clock.Time); return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync( LifecycleFor(sessionId, source), @@ -320,10 +322,10 @@ HookBudget budget } SessionStartInventory.Stamp(forwarded, config, harnesses); - var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(config, forwarded.ToJsonString()); + var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(router, config, forwarded.ToJsonString()); if (activeProfile?.ExcludedRepos is { Length: > 0 } excludedRepos - && await RepoExclusion.IsExcludedAsync(config, enriched, excludedRepos)) { + && await RepoExclusion.IsExcludedAsync(router, config, enriched, excludedRepos)) { DisabledSessions.Mark(sessionId, config); return 0; } diff --git a/src/Capacitor.Cli/Commands/Harness/KiroHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/KiroHookCommand.cs index 7c85fe085..7d26468de 100644 --- a/src/Capacitor.Cli/Commands/Harness/KiroHookCommand.cs +++ b/src/Capacitor.Cli/Commands/Harness/KiroHookCommand.cs @@ -6,6 +6,7 @@ using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core.Http; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands.Harness; @@ -37,7 +38,8 @@ namespace Capacitor.Cli.Commands.Harness; /// sealed class KiroHookCommand( ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home, - HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers) { + HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers, + GitProviderRouter router) { readonly AgentHookPoster _poster = new(config, profiles, http, watchers); string Url => profiles.Resolution.ServerUrl!; @@ -104,7 +106,7 @@ internal static void WriteAgentSpawnOutput(TextWriter writer, string? fragment, try { var store = SessionStartMemoryLeaseStore.Create(config, clock.Time); - var provider = SessionStartMemoryHookSupport.CompositeProvider(config, http.ForMemoryAsync, clock.Time); + var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, http.ForMemoryAsync, clock.Time); return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync( new SessionMemoryLifecycle(HarnessId.Kiro, sessionId, LifecycleInstanceId: null, @@ -220,10 +222,10 @@ HookBudget budget } SessionStartInventory.Stamp(forwarded, config, harnesses); - var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(config, forwarded.ToJsonString()); + var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(router, config, forwarded.ToJsonString()); if (activeProfile?.ExcludedRepos is { Length: > 0 } excludedRepos - && await RepoExclusion.IsExcludedAsync(config, enriched, excludedRepos)) { + && await RepoExclusion.IsExcludedAsync(router, config, enriched, excludedRepos)) { DisabledSessions.Mark(sessionId, config); return 0; } diff --git a/src/Capacitor.Cli/Commands/Harness/OpenCodeHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/OpenCodeHookCommand.cs index 0ecf68888..e563ff4db 100644 --- a/src/Capacitor.Cli/Commands/Harness/OpenCodeHookCommand.cs +++ b/src/Capacitor.Cli/Commands/Harness/OpenCodeHookCommand.cs @@ -5,6 +5,7 @@ using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core.Http; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands.Harness; @@ -31,7 +32,8 @@ namespace Capacitor.Cli.Commands.Harness; /// sealed class OpenCodeHookCommand( ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home, - HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers) { + HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers, + GitProviderRouter router) { readonly AgentHookPoster _poster = new(config, profiles, http, watchers); string Url => profiles.Resolution.ServerUrl!; @@ -128,10 +130,10 @@ HookBudget budget } SessionStartInventory.Stamp(forwarded, config, harnesses); - var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(config, forwarded.ToJsonString()); + var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(router, config, forwarded.ToJsonString()); if (activeProfile?.ExcludedRepos is { Length: > 0 } excludedRepos - && await RepoExclusion.IsExcludedAsync(config, enriched, excludedRepos)) { + && await RepoExclusion.IsExcludedAsync(router, config, enriched, excludedRepos)) { DisabledSessions.Mark(sessionId, config); return 0; } @@ -255,7 +257,7 @@ internal static SessionMemoryLifecycle LifecycleFor(string sessionId) => try { var store = SessionStartMemoryLeaseStore.Create(config, clock.Time); - var provider = SessionStartMemoryHookSupport.CompositeProvider(config, http.ForMemoryAsync, clock.Time); + var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, http.ForMemoryAsync, clock.Time); return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync( LifecycleFor(sessionId), diff --git a/src/Capacitor.Cli/Commands/Harness/PiHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/PiHookCommand.cs index 865dc6b84..d02ed6ff0 100644 --- a/src/Capacitor.Cli/Commands/Harness/PiHookCommand.cs +++ b/src/Capacitor.Cli/Commands/Harness/PiHookCommand.cs @@ -7,6 +7,7 @@ using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core.Http; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands.Harness; @@ -36,7 +37,8 @@ namespace Capacitor.Cli.Commands.Harness; /// sealed class PiHookCommand( ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home, - HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers) { + HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers, + GitProviderRouter router) { readonly AgentHookPoster _poster = new(config, profiles, http, watchers); string Url => profiles.Resolution.ServerUrl!; @@ -141,10 +143,10 @@ HookBudget budget if (activeProfile?.DefaultVisibility is { } visibility) forwarded["default_visibility"] = visibility; SessionStartInventory.Stamp(forwarded, config, harnesses); - var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(config, forwarded.ToJsonString()); + var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(router, config, forwarded.ToJsonString()); if (activeProfile?.ExcludedRepos is { Length: > 0 } excludedRepos - && await RepoExclusion.IsExcludedAsync(config, enriched, excludedRepos)) { + && await RepoExclusion.IsExcludedAsync(router, config, enriched, excludedRepos)) { DisabledSessions.Mark(sessionId, config); return 0; } @@ -325,7 +327,7 @@ internal static SessionMemoryLifecycle LifecycleFor(string file, string? reason) try { var store = SessionStartMemoryLeaseStore.Create(config, clock.Time); - var provider = SessionStartMemoryHookSupport.CompositeProvider(config, http.ForMemoryAsync, clock.Time); + var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, http.ForMemoryAsync, clock.Time); return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync( LifecycleFor(file, reason), diff --git a/src/Capacitor.Cli/Commands/ImportCommand.cs b/src/Capacitor.Cli/Commands/ImportCommand.cs index 45d632630..ecaf80ee8 100644 --- a/src/Capacitor.Cli/Commands/ImportCommand.cs +++ b/src/Capacitor.Cli/Commands/ImportCommand.cs @@ -13,12 +13,13 @@ using Capacitor.Cli.Harness.Claude; using Capacitor.Cli.Harness.Cursor; using Spectre.Console; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands; class ImportCommand( ConfigRoot config, ProfileContext profiles, UserHome home, HarnessRegistry harnesses, - ICapacitorHttpClient http) { + ICapacitorHttpClient http, GitProviderRouter router) { /// /// Maximum parallel worker count for the Importing phase. Both the /// channel-based dispatcher in ImportChainsAsync and the TTY slot-row @@ -764,7 +765,7 @@ public async Task HandleImport( // --- Sources --- // A caller that names none means Claude only. - sources ??= [new ClaudeImportSource(config, harnesses.Of().Paths.Projects)]; + sources ??= [new ClaudeImportSource(config, harnesses.Of().Paths.Projects, router)]; // --- No-source exit policy --- var available = sources.Where(s => s.IsAvailable).ToList(); @@ -933,7 +934,7 @@ await Parallel.ForEachAsync( async (cwd, _) => { try { // Import only needs owner/repo here — skip the PR/MR provider round-trip. - var repo = await RepositoryDetection.DetectRepositoryAsync(config, cwd, detectPullRequest: false); + var repo = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd, detectPullRequest: false); repoByCwd[cwd] = repo is { Owner: { } o, RepoName: { } n } ? (o, n) : null; } catch { repoByCwd[cwd] = null; @@ -2686,7 +2687,7 @@ string sessionId async ValueTask DetectOne(string cwd) { // Import only needs owner/repo here — skip the PR/MR provider round-trip. - var repo = await RepositoryDetection.DetectRepositoryAsync(config, cwd, detectPullRequest: false); + var repo = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd, detectPullRequest: false); repoByCwd[cwd] = repo is { Owner: { } o, RepoName: { } n } ? (o, n) : null; } @@ -3109,7 +3110,7 @@ internal enum SessionImportOutcome { Loaded, Resumed, Errored } if (cwd is not null) { // The imported session-start payload carries no PR fields (only owner/repo/branch/user), // so skip the PR/MR provider round-trip. - var repo = await RepositoryDetection.DetectRepositoryAsync(config, cwd, detectPullRequest: false); + var repo = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd, detectPullRequest: false); if (repo is not null || codexRepo is not null) { var repoNode = new JsonObject(); @@ -3146,7 +3147,7 @@ internal enum SessionImportOutcome { Loaded, Resumed, Errored } session.Vendor.VendorId, session.FilePath, GitRepository.FindRoot, - root => RepositoryDetection.DetectRepositoryAsync(config, root, detectPullRequest: false)); + root => RepositoryDetection.DetectRepositoryAsync(router, config, root, detectPullRequest: false)); if (evidenceNode is not null) startHook["repository"] = evidenceNode; } diff --git a/src/Capacitor.Cli/Commands/McpAnalyticsServer.cs b/src/Capacitor.Cli/Commands/McpAnalyticsServer.cs index 0427928a7..cff1e6c6f 100644 --- a/src/Capacitor.Cli/Commands/McpAnalyticsServer.cs +++ b/src/Capacitor.Cli/Commands/McpAnalyticsServer.cs @@ -10,6 +10,7 @@ using Capacitor.Cli.Core.Config; using Capacitor.Cli.Core.Http; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands; @@ -19,7 +20,7 @@ namespace Capacitor.Cli.Commands; /// rejection reasons. Structure cloned from McpMemoryServer. /// sealed class McpAnalyticsServer(ConfigRoot config, ProfileContext profiles, TokenStore tokens, ICapacitorHttpClient http, - TelemetryStartup startup) { + TelemetryStartup startup, GitProviderRouter router) { internal const string NotLoggedInMessage = AuthRejectionNotice.NotLoggedIn; internal const string NotSupportedMessage = @@ -37,7 +38,7 @@ static string TimeoutHintFor(string toolName) => public async Task RunAsync() { var baseUrl = profiles.Resolution.ServerUrl!; - var repository = new CwdRepository(config, Directory.GetCurrentDirectory()); + var repository = new CwdRepository(config, Directory.GetCurrentDirectory(), router); var tools = BuildToolsList(); // Best-effort, and recorded even when the read throws: a stale token on disk must never diff --git a/src/Capacitor.Cli/Commands/McpFlowsServer.cs b/src/Capacitor.Cli/Commands/McpFlowsServer.cs index 2d8e92e43..650ab7b3d 100644 --- a/src/Capacitor.Cli/Commands/McpFlowsServer.cs +++ b/src/Capacitor.Cli/Commands/McpFlowsServer.cs @@ -11,11 +11,13 @@ using Capacitor.Cli.Core.Config; using Capacitor.Cli.Core.Http; using Capacitor.Cli.Core.Telemetry; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands; class McpFlowsServer( - ConfigRoot config, ProfileContext profiles, TokenStore store, ICapacitorHttpClient http, TelemetryStartup startup) { + ConfigRoot config, ProfileContext profiles, TokenStore store, ICapacitorHttpClient http, + TelemetryStartup startup, GitProviderRouter router) { public async Task RunAsync(string? driverArg = null) { var baseUrl = profiles.Resolution.ServerUrl!; @@ -32,7 +34,7 @@ public async Task RunAsync(string? driverArg = null) { var driverVendor = DriverVendor.Infer(driverArg); var tools = BuildToolsList(); - var repository = new CwdRepository(config, cwd); + var repository = new CwdRepository(config, cwd, router); // Best-effort, and recorded even when the read throws: a stale token on disk must never // block the server from starting, and an absent property is a different value in a funnel diff --git a/src/Capacitor.Cli/Commands/McpMemoryServer.cs b/src/Capacitor.Cli/Commands/McpMemoryServer.cs index 03c3f50c6..54eed88b3 100644 --- a/src/Capacitor.Cli/Commands/McpMemoryServer.cs +++ b/src/Capacitor.Cli/Commands/McpMemoryServer.cs @@ -10,17 +10,18 @@ using Capacitor.Cli.Core.Config; using Capacitor.Cli.Core.Http; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands; sealed class McpMemoryServer(ConfigRoot config, ProfileContext profiles, TokenStore tokens, ICapacitorHttpClient http, - TelemetryStartup startup) { + TelemetryStartup startup, GitProviderRouter router) { internal const string NotLoggedInMessage = AuthRejectionNotice.NotLoggedIn; public async Task RunAsync() { var baseUrl = profiles.Resolution.ServerUrl!; - var repository = new CwdRepository(config, Directory.GetCurrentDirectory()); + var repository = new CwdRepository(config, Directory.GetCurrentDirectory(), router); var machineId = await ResolveMachineIdAsync(); var tools = BuildToolsList(); diff --git a/src/Capacitor.Cli/Commands/McpReviewServer.cs b/src/Capacitor.Cli/Commands/McpReviewServer.cs index 4bbb64060..2e7d4451a 100644 --- a/src/Capacitor.Cli/Commands/McpReviewServer.cs +++ b/src/Capacitor.Cli/Commands/McpReviewServer.cs @@ -12,11 +12,12 @@ using Capacitor.Cli.Core.Config; using Capacitor.Cli.Core.Http; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands; sealed class McpReviewServer(ConfigRoot config, ProfileContext profiles, TokenStore tokens, ICapacitorHttpClient http, - TelemetryStartup startup) { + TelemetryStartup startup, GitProviderRouter router) { /// /// Run with an explicit session-default PR (used by kcap review <pr>). /// Tool calls may still override the default by passing a pr argument. @@ -150,7 +151,7 @@ async Task TimedDispatchToolCallAsync(JsonNode callId, JsonObject callRe async Task DetectPrFromGitAsync() { try { var cwd = Directory.GetCurrentDirectory(); - var repoInfo = await RepositoryDetection.DetectRepositoryAsync(config, cwd); + var repoInfo = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd); if (repoInfo?.Owner is not null && repoInfo.RepoName is not null && repoInfo.PrNumber is not null) { return new PrIdentity(repoInfo.Owner, repoInfo.RepoName, repoInfo.PrNumber.Value); diff --git a/src/Capacitor.Cli/Commands/McpSessionsServer.cs b/src/Capacitor.Cli/Commands/McpSessionsServer.cs index fab32d8ab..a97c92211 100644 --- a/src/Capacitor.Cli/Commands/McpSessionsServer.cs +++ b/src/Capacitor.Cli/Commands/McpSessionsServer.cs @@ -10,17 +10,18 @@ using Capacitor.Cli.Core.Config; using Capacitor.Cli.Core.Http; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands; sealed class McpSessionsServer(ConfigRoot config, ProfileContext profiles, TokenStore tokens, ICapacitorHttpClient http, - TelemetryStartup startup) { + TelemetryStartup startup, GitProviderRouter router) { internal const string NotLoggedInMessage = AuthRejectionNotice.NotLoggedIn; public async Task RunAsync() { var baseUrl = profiles.Resolution.ServerUrl!; - var repository = new CwdRepository(config, Directory.GetCurrentDirectory()); + var repository = new CwdRepository(config, Directory.GetCurrentDirectory(), router); var tools = BuildToolsList(); // Best-effort, and recorded even when the read throws: a stale token on disk must never diff --git a/src/Capacitor.Cli/Commands/RecapCommand.cs b/src/Capacitor.Cli/Commands/RecapCommand.cs index 7900608b9..3c16d1074 100644 --- a/src/Capacitor.Cli/Commands/RecapCommand.cs +++ b/src/Capacitor.Cli/Commands/RecapCommand.cs @@ -2,15 +2,18 @@ using System.Text.Json; using Capacitor.Cli.Core; using Capacitor.Cli.Core.Http; +using Capacitor.Cli.PrDetection; // ReSharper disable MethodHasAsyncOverload namespace Capacitor.Cli.Commands; -class RecapCommand(ConfigRoot config, ISessionsApi sessionsApi, IRepositoriesApi repositoriesApi) { +class RecapCommand( + ConfigRoot config, ISessionsApi sessionsApi, IRepositoriesApi repositoriesApi, + GitProviderRouter router) { public async Task HandleRepoRecap(int limit = 10) { var cwd = Directory.GetCurrentDirectory(); - var repo = await RepositoryDetection.DetectRepositoryAsync(config, cwd); + var repo = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd); if (repo?.Owner is null || repo.RepoName is null) { Console.Error.WriteLine("Not in a git repository with a remote origin."); diff --git a/src/Capacitor.Cli/Commands/SessionsCommand.cs b/src/Capacitor.Cli/Commands/SessionsCommand.cs index 717d9b560..e9d0c630f 100644 --- a/src/Capacitor.Cli/Commands/SessionsCommand.cs +++ b/src/Capacitor.Cli/Commands/SessionsCommand.cs @@ -4,10 +4,12 @@ using Capacitor.Cli.Core; using Capacitor.Cli.Core.Config; using Capacitor.Cli.Core.Http; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands; -class SessionsCommand(ConfigRoot config, ProfileContext profiles, ICapacitorHttpClient http) { +class SessionsCommand( + ConfigRoot config, ProfileContext profiles, ICapacitorHttpClient http, GitProviderRouter router) { public async Task HandleAsync(string[] args) { var options = SessionsArgs.Parse(args, out var error); @@ -23,6 +25,7 @@ public async Task HandleAsync(string[] args) { if (options.Repo is null) { var repo = await RepositoryDetection.DetectRepositoryAsync( + router, config, Directory.GetCurrentDirectory(), detectPullRequest: false); if (repo?.Owner is null || repo.RepoName is null) { diff --git a/src/Capacitor.Cli/Commands/SetupCommand.cs b/src/Capacitor.Cli/Commands/SetupCommand.cs index 518516e67..4a3372a64 100644 --- a/src/Capacitor.Cli/Commands/SetupCommand.cs +++ b/src/Capacitor.Cli/Commands/SetupCommand.cs @@ -33,6 +33,7 @@ using Capacitor.Cli.Core.Http; using Microsoft.Extensions.DependencyInjection; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands; @@ -238,6 +239,7 @@ sealed class SetupImportLane( UserHome home, ICapacitorHttpClient http, HarnessRegistry harnesses, + GitProviderRouter router, Func>? runner = null) : IFirstRunImportLane { /// One invocation's arguments, so a test can assert what each level asked for without /// running an import. @@ -253,9 +255,9 @@ internal sealed record Pass( ImportCommand.ImportDiscoveryResult? found = null; // Quiet, because the caller owns the terminal for the duration and the figures go to a screen. - var exit = await new ImportCommand(config, profiles, home, harnesses, http).HandleImport( + var exit = await new ImportCommand(config, profiles, home, harnesses, http, router).HandleImport( filterCwd: null, - sources: SetupCommand.BuildImportSources(config, harnesses, vendors), + sources: SetupCommand.BuildImportSources(config, harnesses, router, vendors), discoverOnly: true, discoverJson: true, windowsAsOf: asOf, @@ -306,9 +308,9 @@ internal static ReportFirstRunImportRequest Report(ImportCommand.ImportDiscovery async Task Run(Pass pass) { ImportCommand.ImportRunOutcome? outcome = null; - await new ImportCommand(config, profiles, home, harnesses, http).HandleImport( + await new ImportCommand(config, profiles, home, harnesses, http, router).HandleImport( filterCwd: null, - sources: SetupCommand.BuildImportSources(config, harnesses, pass.Vendors), + sources: SetupCommand.BuildImportSources(config, harnesses, router, pass.Vendors), since: pass.Since, scope: new ImportScope.Repo([.. pass.Repos.Select(c => (c.Owner, c.Name))]), skipConfirmation: true, @@ -419,7 +421,7 @@ public sealed class SetupCommand( UserHome home, HarnessRegistry harnesses, AgentsPaths agents, ICapacitorHttpClient http, TenantProvisioningClient provisioning, AuthProviderDiscovery discovery, CliTelemetry telemetry, AuthEndpoints endpoints, IOnboardingFacadeFactory facades, ISetupImportRunner imports, - ChosenServerHttp chosenHttp) { + ChosenServerHttp chosenHttp, GitProviderRouter router) { public async Task HandleAsync(string[] args) { var serverUrlArg = GetArg(args, "--server-url"); @@ -973,6 +975,7 @@ await SetupDaemonService.RunAsync( // detectPullRequest:false — Step 6 only needs (owner, name) to scope the repo import; // PR/MR detection would run extra provider probes/subprocesses for nothing here. var currentRepoDetected = await RepositoryDetection.DetectRepositoryAsync( + router, config, Environment.CurrentDirectory, detectPullRequest: false); (string Owner, string Name)? currentRepo = currentRepoDetected is { Owner: { } o, RepoName: { } n } ? (o, n) @@ -1249,18 +1252,19 @@ internal ServiceProvider HttpForChosenServer(string serverUrl, ProfileContext? c /// nothing. Filtering the sources rather than the counts afterwards is what makes a reported figure /// already scoped to what the user kept. internal static IReadOnlyList BuildImportSources( - ConfigRoot config, HarnessRegistry harnesses, IReadOnlyCollection? vendors = null) { + ConfigRoot config, HarnessRegistry harnesses, GitProviderRouter router, + IReadOnlyCollection? vendors = null) { var cursor = harnesses.Of().Paths; var opencode = harnesses.Of().Paths; IReadOnlyList all = [ - new ClaudeImportSource(config, harnesses.Of().Paths.Projects), - new CodexImportSource(config, harnesses.Of().Paths.Sessions), - new CursorImportSource(config, cursor.ProjectsDir, cursor.WorkspaceStorageDir), - new CopilotImportSource(config, harnesses.Of().Paths), + new ClaudeImportSource(config, harnesses.Of().Paths.Projects, router), + new CodexImportSource(config, harnesses.Of().Paths.Sessions, router), + new CursorImportSource(config, cursor.ProjectsDir, cursor.WorkspaceStorageDir, router), + new CopilotImportSource(config, harnesses.Of().Paths, router), new GeminiImportSource(harnesses.Of().Paths.TmpDir), - new KiroImportSource(config, harnesses.Of().Paths.SessionsDir), - new PiImportSource(config, harnesses.Of().Paths.SessionsDir), + new KiroImportSource(config, harnesses.Of().Paths.SessionsDir, router), + new PiImportSource(config, harnesses.Of().Paths.SessionsDir, router), new OpenCodeImportSource( Path.Combine(opencode.DataDir, "opencode.db"), opencode.ImportLedgerJson), @@ -1488,7 +1492,7 @@ async Task RunBrowserFlowStepAsync(string serverUrl, string config, harnesses, Environment.MachineName, await LoginShellFindsCliAsync()); - importing = new SetupImportLane(config, ImportContext(profiles, serverUrl), home, flowHttp, harnesses); + importing = new SetupImportLane(config, ImportContext(profiles, serverUrl), home, flowHttp, harnesses, router); using var progress = new SpectreFirstRunFlowProgress(); diff --git a/src/Capacitor.Cli/Commands/SetupImportRunner.cs b/src/Capacitor.Cli/Commands/SetupImportRunner.cs index da5755022..13a8c9562 100644 --- a/src/Capacitor.Cli/Commands/SetupImportRunner.cs +++ b/src/Capacitor.Cli/Commands/SetupImportRunner.cs @@ -2,23 +2,25 @@ using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core.Http; using Microsoft.Extensions.DependencyInjection; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands; /// The real import, aimed at the server this run chose rather than the one startup resolved. sealed class SetupImportRunner( - ConfigRoot config, UserHome home, HarnessRegistry harnesses, ChosenServerHttp http) : ISetupImportRunner { + ConfigRoot config, UserHome home, HarnessRegistry harnesses, ChosenServerHttp http, + GitProviderRouter router) : ISetupImportRunner { public async Task RunAsync(ImportInvocation inv) { await using var scoped = http.For(inv.Profiles.Resolution.ServerUrl ?? "", inv.Profiles); return await new ImportCommand( - config, inv.Profiles, home, harnesses, scoped.GetRequiredService()) + config, inv.Profiles, home, harnesses, scoped.GetRequiredService(), router) .HandleImport( filterCwd: null, filterSession: null, minLines: 15, generateSummaries: false, - sources: SetupCommand.BuildImportSources(config, harnesses), + sources: SetupCommand.BuildImportSources(config, harnesses, router), explicitVendorSelection: false, since: null, scope: new ImportScope.Repo(inv.Repo.Owner, inv.Repo.Name), diff --git a/src/Capacitor.Cli/Commands/SkillsCommand.cs b/src/Capacitor.Cli/Commands/SkillsCommand.cs index d4b863d90..a604200c6 100644 --- a/src/Capacitor.Cli/Commands/SkillsCommand.cs +++ b/src/Capacitor.Cli/Commands/SkillsCommand.cs @@ -6,6 +6,7 @@ using Capacitor.Cli.Core.Harness.Kiro; using Capacitor.Cli.Core.Http; using Capacitor.Cli.Core.Skills; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands; @@ -17,7 +18,8 @@ namespace Capacitor.Cli.Commands; /// are untouchable. Nothing is ever written into a repo. /// class SkillsCommand( - ConfigRoot config, HarnessRegistry harnesses, AgentsPaths agents, IRepositoriesApi repositories) { + ConfigRoot config, HarnessRegistry harnesses, AgentsPaths agents, IRepositoriesApi repositories, + GitProviderRouter router) { // The background refresh keys off each manifest's synced_at, so a burst of session starts // costs one network round-trip per interval per target, not one per session. static readonly TimeSpan AutoSyncInterval = TimeSpan.FromHours(6); @@ -40,7 +42,7 @@ public async Task HandleSync(bool dryRun, bool auto = false) { await Console.Error.WriteLineAsync("Not inside a git repository — run `kcap skills sync` from a repo."); return 1; } - var repo = await RepositoryDetection.DetectRepositoryAsync(config, cwd); + var repo = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd); if (repo?.Owner is null || repo.RepoName is null) { await Console.Error.WriteLineAsync("Could not determine the repo's owner/name from its git remote."); return 1; diff --git a/src/Capacitor.Cli/Commands/TranscriptFileClassification.cs b/src/Capacitor.Cli/Commands/TranscriptFileClassification.cs index b471fe0db..a266d18ab 100644 --- a/src/Capacitor.Cli/Commands/TranscriptFileClassification.cs +++ b/src/Capacitor.Cli/Commands/TranscriptFileClassification.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Capacitor.Cli.Core; using Capacitor.Cli.Core.Harness; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Commands; @@ -14,6 +15,7 @@ namespace Capacitor.Cli.Commands; /// internal static class TranscriptFileClassification { public static async Task> ClassifyAsync( + GitProviderRouter router, ConfigRoot config, UserHome home, HttpClient httpClient, @@ -30,7 +32,7 @@ internal static class TranscriptFileClassification { var tasks = new List>(transcripts.Count); foreach (var (sessionId, filePath, encodedCwd) in transcripts) { - tasks.Add(ClassifyOneAsync(config, home, httpClient, baseUrl, sessionId, filePath, encodedCwd, minLines, excludedRepos, excludedPaths, probeGate, vendor, onProbed, ct)); + tasks.Add(ClassifyOneAsync(router, config, home, httpClient, baseUrl, sessionId, filePath, encodedCwd, minLines, excludedRepos, excludedPaths, probeGate, vendor, onProbed, ct)); } var results = await Task.WhenAll(tasks); @@ -39,6 +41,7 @@ internal static class TranscriptFileClassification { } static async Task ClassifyOneAsync( + GitProviderRouter router, ConfigRoot config, UserHome home, HttpClient httpClient, @@ -55,13 +58,14 @@ internal static class TranscriptFileClassification { CancellationToken ct ) { try { - return await ClassifyOneCoreAsync(config, home, httpClient, baseUrl, sessionId, filePath, encodedCwd, minLines, excludedRepos, excludedPaths, probeGate, vendor, ct); + return await ClassifyOneCoreAsync(router, config, home, httpClient, baseUrl, sessionId, filePath, encodedCwd, minLines, excludedRepos, excludedPaths, probeGate, vendor, ct); } finally { onProbed?.Invoke(); } } static async Task ClassifyOneCoreAsync( + GitProviderRouter router, ConfigRoot config, UserHome home, HttpClient httpClient, @@ -201,7 +205,7 @@ CancellationToken ct if (cwd is not null) { if (excludedRepos is { Length: > 0 }) { // Classification only needs owner/repo for the exclusion key — skip PR detection. - var repo = await RepositoryDetection.DetectRepositoryAsync(config, cwd, detectPullRequest: false); + var repo = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd, detectPullRequest: false); if (repo?.Owner is not null && repo.RepoName is not null) { var key = $"{repo.Owner}/{repo.RepoName}"; diff --git a/src/Capacitor.Cli/Commands/WatchCommand.cs b/src/Capacitor.Cli/Commands/WatchCommand.cs index f50df8084..c077f52f0 100644 --- a/src/Capacitor.Cli/Commands/WatchCommand.cs +++ b/src/Capacitor.Cli/Commands/WatchCommand.cs @@ -20,6 +20,7 @@ using Capacitor.Cli.Harness.Cursor; using Capacitor.Cli.Harness.Gemini; using Capacitor.Cli.Harness.OpenCode; +using Capacitor.Cli.PrDetection; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.DependencyInjection; @@ -29,7 +30,8 @@ namespace Capacitor.Cli.Commands; partial class WatchCommand( ConfigRoot config, ProfileContext profiles, HarnessRegistry harnesses, - ICapacitorHttpClient http, ICredentialSource credentials, WatcherManager watchers) { + ICapacitorHttpClient http, ICredentialSource credentials, WatcherManager watchers, + GitProviderRouter router) { readonly CursorMarkers _markers = new(config); string Url => profiles.Resolution.ServerUrl!; @@ -501,7 +503,7 @@ void ArmParentMonitor(int ppid) { // Detect repository info upfront if cwd is provided (session watchers only, not agents) if (cwd is not null) { - state.Repository = await RepositoryDetection.DetectRepositoryAsync(config, cwd); + state.Repository = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd); state.LastRepoDetection = DateTimeOffset.UtcNow; } @@ -511,7 +513,7 @@ void ArmParentMonitor(int ppid) { // watcher is always spawned with cwd: null too and has never had its own repo detection. if (vendor == "claude" && agentId is null && (cwd is null || GitRepository.FindRoot(cwd) is null)) { state.EvidenceScanner = new RepoEvidenceScanner( - GitRepository.FindRoot, root => RepositoryDetection.DetectRepositoryAsync(config, root), + GitRepository.FindRoot, root => RepositoryDetection.DetectRepositoryAsync(router, config, root), p => p.Owner is not null && p.RepoName is not null); try { @@ -728,7 +730,7 @@ await BackfillCodexWatcherStateAsync( // Periodically refresh repository info (every 60s) if (cwd is not null && DateTimeOffset.UtcNow - state.LastRepoDetection > TimeSpan.FromSeconds(60)) { - var detected = await RepositoryDetection.DetectRepositoryAsync(config, cwd); + var detected = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd); // An evidence-derived repo may only be replaced by another real detection, // never cleared back to null by a launch-cwd probe that still finds nothing. diff --git a/src/Capacitor.Cli/CwdRepository.cs b/src/Capacitor.Cli/CwdRepository.cs index 57a4bbb26..d885b4148 100644 --- a/src/Capacitor.Cli/CwdRepository.cs +++ b/src/Capacitor.Cli/CwdRepository.cs @@ -9,7 +9,7 @@ namespace Capacitor.Cli; /// name only, and the provider round-trip would otherwise run in every agent session that spawns /// the server, tool call or not. Not thread-safe; a concurrent first use resolves twice. /// -sealed class CwdRepository(ConfigRoot config, string cwd, CommandRunner? run = null) { +sealed class CwdRepository(ConfigRoot config, string cwd, GitProviderRouter router, CommandRunner? run = null) { bool _resolved; RepositoryPayload? _repository; @@ -17,7 +17,7 @@ sealed class CwdRepository(ConfigRoot config, string cwd, CommandRunner? run = n public async ValueTask GetAsync() { if (_resolved) return _repository; - _repository = await RepositoryDetection.DetectRepositoryAsync(config, cwd, detectPullRequest: false, run: run); + _repository = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd, detectPullRequest: false, run: run); _resolved = true; return _repository; diff --git a/src/Capacitor.Cli/Harness/Claude/ClaudeImportSource.cs b/src/Capacitor.Cli/Harness/Claude/ClaudeImportSource.cs index 19c725ba2..a5ab825ab 100644 --- a/src/Capacitor.Cli/Harness/Claude/ClaudeImportSource.cs +++ b/src/Capacitor.Cli/Harness/Claude/ClaudeImportSource.cs @@ -1,6 +1,7 @@ using Capacitor.Cli.Commands; using Capacitor.Cli.Core; using Capacitor.Cli.Core.Harness; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Harness.Claude; @@ -12,7 +13,8 @@ namespace Capacitor.Cli.Harness.Claude; /// with vendor = "claude". Claude sessions are imported per chain, so /// is never the entry point — ImportChainsAsync is. /// -internal sealed class ClaudeImportSource(ConfigRoot config, string projectsDir) : IImportSource { +internal sealed class ClaudeImportSource( + ConfigRoot config, string projectsDir, GitProviderRouter router) : IImportSource { readonly string _projectsDir = projectsDir; public HarnessId Vendor => HarnessId.Claude; @@ -87,6 +89,7 @@ CancellationToken ct } return await TranscriptFileClassification.ClassifyAsync( + router, config, ctx.Home, ctx.HttpClient, diff --git a/src/Capacitor.Cli/Harness/Codex/CodexImportSource.cs b/src/Capacitor.Cli/Harness/Codex/CodexImportSource.cs index 26a675125..a45bfb900 100644 --- a/src/Capacitor.Cli/Harness/Codex/CodexImportSource.cs +++ b/src/Capacitor.Cli/Harness/Codex/CodexImportSource.cs @@ -3,6 +3,7 @@ using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core.Harness.Codex; using Capacitor.Cli.Harness.Claude; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Harness.Codex; @@ -15,7 +16,8 @@ namespace Capacitor.Cli.Harness.Codex; /// with vendor = "codex". Codex sessions are imported per chain, so /// is never the entry point — ImportChainsAsync is. /// -internal sealed class CodexImportSource(ConfigRoot config, string sessionsDir) : IImportSource { +internal sealed class CodexImportSource( + ConfigRoot config, string sessionsDir, GitProviderRouter router) : IImportSource { readonly string _sessionsDir = sessionsDir; public HarnessId Vendor => HarnessId.Codex; @@ -102,6 +104,7 @@ CancellationToken ct } return await TranscriptFileClassification.ClassifyAsync( + router, config, ctx.Home, ctx.HttpClient, diff --git a/src/Capacitor.Cli/Harness/Copilot/CopilotImportSource.cs b/src/Capacitor.Cli/Harness/Copilot/CopilotImportSource.cs index 24bb7d093..ac513172e 100644 --- a/src/Capacitor.Cli/Harness/Copilot/CopilotImportSource.cs +++ b/src/Capacitor.Cli/Harness/Copilot/CopilotImportSource.cs @@ -6,6 +6,7 @@ using Capacitor.Cli.Core; using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core.Harness.Copilot; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Harness.Copilot; @@ -33,10 +34,11 @@ internal sealed class CopilotImportSource : IImportSource { public CopilotImportSource( ConfigRoot config, CopilotPaths paths, + GitProviderRouter router, Func>? repoDetector = null ) { _paths = paths; - _repoDetector = repoDetector ?? (cwd => RepositoryDetection.DetectRepositoryAsync(config, cwd, detectPullRequest: false)); + _repoDetector = repoDetector ?? (cwd => RepositoryDetection.DetectRepositoryAsync(router, config, cwd, detectPullRequest: false)); } static StringComparison PathComparison => diff --git a/src/Capacitor.Cli/Harness/Cursor/CursorImportSource.cs b/src/Capacitor.Cli/Harness/Cursor/CursorImportSource.cs index 73a030f39..a2ced2bcc 100644 --- a/src/Capacitor.Cli/Harness/Cursor/CursorImportSource.cs +++ b/src/Capacitor.Cli/Harness/Cursor/CursorImportSource.cs @@ -6,6 +6,7 @@ using Capacitor.Cli.Core; using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core.Harness.Cursor; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Harness.Cursor; @@ -53,6 +54,7 @@ public CursorImportSource( ConfigRoot config, string projectsDir, string workspaceStorageDir, + GitProviderRouter router, Func>? repoDetector = null ) { _config = config; @@ -68,7 +70,7 @@ public CursorImportSource( // grouping under their repo — they just never carry pr_number/pr_title/pr_url/pr_head_ref. // The LIVE Cursor hook path (CursorHookCommand → EnrichWithRepositoryInfoFromCwd) is a // separate call site untouched by this default and keeps live PR detection. - _repoDetector = repoDetector ?? (cwd => RepositoryDetection.DetectRepositoryAsync(config, cwd, detectPullRequest: false)); + _repoDetector = repoDetector ?? (cwd => RepositoryDetection.DetectRepositoryAsync(router, config, cwd, detectPullRequest: false)); } /// diff --git a/src/Capacitor.Cli/Harness/Kiro/KiroImportSource.cs b/src/Capacitor.Cli/Harness/Kiro/KiroImportSource.cs index b88d791dd..4a55958ae 100644 --- a/src/Capacitor.Cli/Harness/Kiro/KiroImportSource.cs +++ b/src/Capacitor.Cli/Harness/Kiro/KiroImportSource.cs @@ -7,6 +7,7 @@ using Capacitor.Cli.Core; using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core.Harness.Kiro; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Harness.Kiro; @@ -26,10 +27,11 @@ internal sealed class KiroImportSource : IImportSource { public KiroImportSource( ConfigRoot config, string sessionsDir, + GitProviderRouter router, Func>? repoDetector = null ) { _sessionsDir = sessionsDir; - _repoDetector = repoDetector ?? (cwd => RepositoryDetection.DetectRepositoryAsync(config, cwd, detectPullRequest: false)); + _repoDetector = repoDetector ?? (cwd => RepositoryDetection.DetectRepositoryAsync(router, config, cwd, detectPullRequest: false)); } static StringComparison PathComparison => diff --git a/src/Capacitor.Cli/Harness/Pi/PiImportSource.cs b/src/Capacitor.Cli/Harness/Pi/PiImportSource.cs index 797cd9d68..b2c3372d3 100644 --- a/src/Capacitor.Cli/Harness/Pi/PiImportSource.cs +++ b/src/Capacitor.Cli/Harness/Pi/PiImportSource.cs @@ -7,6 +7,7 @@ using Capacitor.Cli.Core; using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core.Harness.Pi; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Harness.Pi; @@ -32,10 +33,11 @@ internal sealed class PiImportSource : IImportSource { public PiImportSource( ConfigRoot config, string sessionsDir, + GitProviderRouter router, Func>? repoDetector = null ) { _sessionsDir = sessionsDir; - _repoDetector = repoDetector ?? (cwd => RepositoryDetection.DetectRepositoryAsync(config, cwd, detectPullRequest: false)); + _repoDetector = repoDetector ?? (cwd => RepositoryDetection.DetectRepositoryAsync(router, config, cwd, detectPullRequest: false)); } static StringComparison PathComparison => diff --git a/src/Capacitor.Cli/PrDetection/GitProviderRouter.cs b/src/Capacitor.Cli/PrDetection/GitProviderRouter.cs index 03b7cbf57..8e86f2fb9 100644 --- a/src/Capacitor.Cli/PrDetection/GitProviderRouter.cs +++ b/src/Capacitor.Cli/PrDetection/GitProviderRouter.cs @@ -8,21 +8,27 @@ internal enum GitProviderKind { GitHub, GitLab, Unknown } /// /// Maps a remote host to a provider. SaaS hosts route directly; a custom host is /// probed once via `gh auth status --json hosts` (GitHub if listed, else best-effort -/// GitLab). The decision is memoized per host for the process lifetime so the -/// ImportCommand bulk loop can't multiply the probe. +/// GitLab), and the answer is remembered. +/// +/// Injected, never constructed at a call site: one per process is what makes remembering +/// worth anything. The watcher re-detects every 60s for as long as it runs, so a custom host would +/// otherwise pay for that probe on every refresh. +/// +/// Public only because two hook commands are, and a public constructor cannot take a less +/// accessible parameter; everything it does stays internal. /// -internal static class GitProviderRouter { - static readonly ConcurrentDictionary Memo = new(StringComparer.OrdinalIgnoreCase); +public sealed class GitProviderRouter { + readonly ConcurrentDictionary _memo = new(StringComparer.OrdinalIgnoreCase); - public static async Task ResolveAsync(string? host, string cwd, TimeSpan cap, CommandRunner run) { + internal async Task ResolveAsync(string? host, string cwd, TimeSpan cap, CommandRunner run) { if (string.IsNullOrEmpty(host)) return GitProviderKind.Unknown; if (host == "github.com") return GitProviderKind.GitHub; if (host == "gitlab.com") return GitProviderKind.GitLab; - if (Memo.TryGetValue(host, out var cached)) return cached; + if (_memo.TryGetValue(host, out var cached)) return cached; var kind = await ProbeAsync(host, cwd, cap, run); - Memo[host] = kind; + _memo[host] = kind; return kind; } @@ -40,6 +46,4 @@ static async Task ProbeAsync(string host, string cwd, TimeSpan // Not a known GitHub host → assume GitLab and let the detector no-op if unauthenticated. return GitProviderKind.GitLab; } - - internal static void ResetMemoForTests() => Memo.Clear(); } diff --git a/src/Capacitor.Cli/Program.cs b/src/Capacitor.Cli/Program.cs index 98c64a866..4d6f48d39 100644 --- a/src/Capacitor.Cli/Program.cs +++ b/src/Capacitor.Cli/Program.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.DependencyInjection; using ReviewCommand = Capacitor.Cli.Commands.ReviewCommand; using WatchCommand = Capacitor.Cli.Commands.WatchCommand; +using Capacitor.Cli.PrDetection; if (args.Length < 1) { await PrintUsage(); @@ -650,7 +651,8 @@ // Build sources var explicitVendorSelection = vsel.Vendors.Count > 0; var sources = SetupCommand.BuildImportSources( - config, sp.GetRequiredService(), explicitVendorSelection ? vsel.Vendors : null); + config, sp.GetRequiredService(), sp.GetRequiredService(), + explicitVendorSelection ? vsel.Vendors : null); // --- Scope resolution --- var profileConfig = profiles.Snapshot; @@ -659,7 +661,7 @@ var activeProfile = profiles.Name; var storedOrg = profileConfig.Profiles.GetValueOrDefault(activeProfile)?.ImportOrg; - var currentRepoDetected = await RepositoryDetection.DetectRepositoryAsync(config, Environment.CurrentDirectory); + var currentRepoDetected = await RepositoryDetection.DetectRepositoryAsync(sp.GetRequiredService(), config, Environment.CurrentDirectory); (string Owner, string Name)? currentRepo = currentRepoDetected is { Owner: { } o, RepoName: { } n } ? (o, n) : null; diff --git a/src/Capacitor.Cli/RepoExclusion.cs b/src/Capacitor.Cli/RepoExclusion.cs index e24dfabfe..7d8814856 100644 --- a/src/Capacitor.Cli/RepoExclusion.cs +++ b/src/Capacitor.Cli/RepoExclusion.cs @@ -1,5 +1,6 @@ using System.Text.Json.Nodes; using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli; @@ -9,7 +10,8 @@ static class RepoExclusion { /// Returns true if the repo is excluded (caller should skip processing). /// public static async Task IsExcludedAsync( - ConfigRoot config, string body, string[]? excludedRepos, TimeSpan? budget = null) { + GitProviderRouter router, ConfigRoot config, string body, string[]? excludedRepos, + TimeSpan? budget = null) { if (excludedRepos is null or { Length: 0 }) return false; try { @@ -31,7 +33,7 @@ public static async Task IsExcludedAsync( if (cwd is null) return false; // Exclusion matches on owner/repo only → skip the PR round-trip (~600ms to GitHub). - var repo = await RepositoryDetection.DetectRepositoryAsync(config, cwd, budget, detectPullRequest: false); + var repo = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd, budget, detectPullRequest: false); if (repo?.Owner is not null && repo.RepoName is not null) { return excludedRepos.Contains($"{repo.Owner}/{repo.RepoName}", StringComparer.OrdinalIgnoreCase); diff --git a/src/Capacitor.Cli/RepositoryDetection.cs b/src/Capacitor.Cli/RepositoryDetection.cs index 093d79f91..e59dbda4c 100644 --- a/src/Capacitor.Cli/RepositoryDetection.cs +++ b/src/Capacitor.Cli/RepositoryDetection.cs @@ -19,6 +19,7 @@ static class RepositoryDetection { internal static CommandRunner DefaultRunner => RunCommandAsync; public static async Task EnrichWithRepositoryInfo( + GitProviderRouter router, ConfigRoot config, string json, TimeSpan? budget = null, bool detectPullRequest = true, CommandRunner? run = null) { try { @@ -34,7 +35,7 @@ public static async Task EnrichWithRepositoryInfo( return json; } - var repo = await DetectRepositoryAsync(config, cwd, budget, detectPullRequest, run); + var repo = await DetectRepositoryAsync(router, config, cwd, budget, detectPullRequest, run); if (repo is null) { return json; @@ -66,12 +67,12 @@ public static async Task EnrichWithRepositoryInfo( /// Fail-open: forwards the original payload unchanged on any error or non-git dir. /// public static async Task EnrichWithRepositoryInfoFromCwd( - ConfigRoot config, string json, string cwd, TimeSpan? budget = null) { + GitProviderRouter router, ConfigRoot config, string json, string cwd, TimeSpan? budget = null) { try { if (string.IsNullOrEmpty(cwd)) return json; if (JsonNode.Parse(json) is not JsonObject obj) return json; - var repo = await DetectRepositoryAsync(config, cwd, budget); + var repo = await DetectRepositoryAsync(router, config, cwd, budget); if (repo is null) return json; obj["repository"] = BuildRepositoryNode(repo); @@ -120,10 +121,9 @@ static bool RepoPayloadEquals(RepositoryPayload a, RepositoryPayload b) => // detectPullRequest=false skips the live PR/MR provider detection (the `gh pr view` / `glab api` // round-trip) while still resolving base repo info (owner/repo/user/branch/host). Bulk import - // passes false: it never emits PR fields, so that per-cwd round-trip is pure wasted latency - // `run` is an injectable command runner (defaults to the real process spawner) so the - // git/provider spawns are unit-testable. + // passes false: it never emits PR fields, so that per-cwd round-trip is pure wasted latency. public static async Task DetectRepositoryAsync( + GitProviderRouter router, ConfigRoot config, string cwd, TimeSpan? budget = null, bool detectPullRequest = true, CommandRunner? run = null) { if (budget is { } b0 && b0 <= TimeSpan.Zero) return null; @@ -208,7 +208,7 @@ static bool RepoPayloadEquals(RepositoryPayload a, RepositoryPayload b) => // Import passes detectPullRequest:false: it discards PR fields, so the round-trip is // wasted latency. ResolveAndDetectPrAsync owns the split of providerCap across probes. if (detectPullRequest && providerCap > TimeSpan.Zero && host is not null) { - var pr = await ResolveAndDetectPrAsync(host, owner, repoName, branch, cwd, providerCap, run); + var pr = await ResolveAndDetectPrAsync(router, host, owner, repoName, branch, cwd, providerCap, run); if (pr is not null) { prNumber = pr.Number; @@ -243,6 +243,7 @@ static bool RepoPayloadEquals(RepositoryPayload a, RepositoryPayload b) => /// is a seam for tests (defaults to ). /// internal static async Task ResolveAndDetectPrAsync( + GitProviderRouter router, string host, string? owner, string? repoName, @@ -256,7 +257,7 @@ static bool RepoPayloadEquals(RepositoryPayload a, RepositoryPayload b) => var getTs = getTimestamp ?? Stopwatch.GetTimestamp; var start = getTs(); - var kind = await GitProviderRouter.ResolveAsync(host, cwd, providerCap, run); + var kind = await router.ResolveAsync(host, cwd, providerCap, run); var detectCap = Remaining(); if (detectCap <= TimeSpan.Zero) return null; diff --git a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryHookSupport.cs b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryHookSupport.cs index 7cce26bc1..72d585149 100644 --- a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryHookSupport.cs +++ b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryHookSupport.cs @@ -1,5 +1,6 @@ using Capacitor.Cli.Commands; using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.SessionStartMemory; @@ -27,11 +28,12 @@ internal static class SessionStartMemoryHookSupport { /// without memory context on any authenticated deployment. /// public static ISessionStartContextProvider CompositeProvider( + GitProviderRouter router, ConfigRoot config, Func> client, TimeProvider time, ISessionStartMemoryScopeResolver? scopeResolver = null) { - var resolver = scopeResolver ?? new SessionStartMemoryScopeResolver(config, time); + var resolver = scopeResolver ?? new SessionStartMemoryScopeResolver(router, config, time); var memory = new SessionStartMemoryContextProvider(resolver, client, time); var guidelines = new SessionStartGuidelinesLane(client); diff --git a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryScopeResolver.cs b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryScopeResolver.cs index aa6aa2947..0a67a03b7 100644 --- a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryScopeResolver.cs +++ b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryScopeResolver.cs @@ -1,8 +1,10 @@ using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.SessionStartMemory; -internal sealed class SessionStartMemoryScopeResolver(ConfigRoot config, TimeProvider time) : ISessionStartMemoryScopeResolver { +internal sealed class SessionStartMemoryScopeResolver( + GitProviderRouter router, ConfigRoot config, TimeProvider time) : ISessionStartMemoryScopeResolver { public async Task ResolveAsync(string? cwd, TimeSpan budget, CancellationToken ct) { var started = time.GetTimestamp(); TimeSpan Remaining() { @@ -14,7 +16,7 @@ TimeSpan Remaining() { string? machine = null; try { var path = string.IsNullOrWhiteSpace(cwd) ? Directory.GetCurrentDirectory() : cwd; - var repo = await RepositoryDetection.DetectRepositoryAsync(config, path, Remaining(), detectPullRequest: false); + var repo = await RepositoryDetection.DetectRepositoryAsync(router, config, path, Remaining(), detectPullRequest: false); if (repo?.Owner is not null && repo.RepoName is not null) repoHash = RepoHashHelper.ComputeRepoHash(repo.Owner, repo.RepoName); } catch { } diff --git a/test/Capacitor.Cli.Tests.Integration/AntigravitySessionStartTests.cs b/test/Capacitor.Cli.Tests.Integration/AntigravitySessionStartTests.cs index f5ca33f6c..d79158b5f 100644 --- a/test/Capacitor.Cli.Tests.Integration/AntigravitySessionStartTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/AntigravitySessionStartTests.cs @@ -7,6 +7,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -70,7 +71,7 @@ public async Task PreInvocation_posts_session_start_with_profile_visibility() { } """; - var exit = await new AntigravityHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(["hook", "--antigravity", "PreInvocation"], new StringReader(payload), + var exit = await new AntigravityHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["hook", "--antigravity", "PreInvocation"], new StringReader(payload), new StringWriter()); await Assert.That(exit).IsEqualTo(0); @@ -117,7 +118,7 @@ public async Task PreInvocation_for_excluded_path_is_skipped_without_posting() { } """; - var exit = await new AntigravityHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(["hook", "--antigravity", "PreInvocation"], new StringReader(payload), + var exit = await new AntigravityHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["hook", "--antigravity", "PreInvocation"], new StringReader(payload), new StringWriter()); await Assert.That(exit).IsEqualTo(0); diff --git a/test/Capacitor.Cli.Tests.Integration/AntigravitySkippedChildOverrideRoutedLoopTests.cs b/test/Capacitor.Cli.Tests.Integration/AntigravitySkippedChildOverrideRoutedLoopTests.cs index 6eeddbbec..da37d6267 100644 --- a/test/Capacitor.Cli.Tests.Integration/AntigravitySkippedChildOverrideRoutedLoopTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/AntigravitySkippedChildOverrideRoutedLoopTests.cs @@ -6,6 +6,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -165,7 +166,7 @@ public async Task already_loaded_root_with_new_child_content_counts_loaded_and_i var exitCode = 0; var stdout = await CaptureStdoutAsync(async () => { - exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 0, sources: [antigravity, gemini], diff --git a/test/Capacitor.Cli.Tests.Integration/ClaudeHookStdoutTests.cs b/test/Capacitor.Cli.Tests.Integration/ClaudeHookStdoutTests.cs index 67e22163a..9a209318a 100644 --- a/test/Capacitor.Cli.Tests.Integration/ClaudeHookStdoutTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/ClaudeHookStdoutTests.cs @@ -6,6 +6,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -60,7 +61,7 @@ static string SessionStartPayloadWithoutTranscriptPath() => // concurrently-running test writes to Console can contaminate it. async Task RunSessionStartAsync() { var stdout = new StringWriter(); - await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath()), stdout: stdout); + await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath()), stdout: stdout); return stdout.ToString(); } diff --git a/test/Capacitor.Cli.Tests.Integration/CodexSessionStartHandshakeOnPostFailureTests.cs b/test/Capacitor.Cli.Tests.Integration/CodexSessionStartHandshakeOnPostFailureTests.cs index 6e30722d6..d5bb56afd 100644 --- a/test/Capacitor.Cli.Tests.Integration/CodexSessionStartHandshakeOnPostFailureTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/CodexSessionStartHandshakeOnPostFailureTests.cs @@ -5,6 +5,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -57,7 +58,7 @@ public async Task A_rejected_lifecycle_post_still_satisfies_the_blocking_stdout_ using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); // The rejection is still reported — this is not "pretend it worked". await Assert.That(exit).IsEqualTo(1); diff --git a/test/Capacitor.Cli.Tests.Integration/CodexSessionStartVisibilityTests.cs b/test/Capacitor.Cli.Tests.Integration/CodexSessionStartVisibilityTests.cs index 81d768d90..c9dbee333 100644 --- a/test/Capacitor.Cli.Tests.Integration/CodexSessionStartVisibilityTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/CodexSessionStartVisibilityTests.cs @@ -6,6 +6,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -52,7 +53,7 @@ public async Task SessionStart_stamps_default_visibility_from_active_profile() { } """; - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start/codex").UsingPost()); @@ -97,7 +98,7 @@ public async Task SessionStart_for_excluded_repo_is_skipped_and_marks_session_di using var capture = ConsoleOutput.StartCapture(); try { - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); // No /hooks/session-start/codex POST. diff --git a/test/Capacitor.Cli.Tests.Integration/CopilotImportSourceImportTests.cs b/test/Capacitor.Cli.Tests.Integration/CopilotImportSourceImportTests.cs index 995a034f2..034582fdd 100644 --- a/test/Capacitor.Cli.Tests.Integration/CopilotImportSourceImportTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/CopilotImportSourceImportTests.cs @@ -5,6 +5,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Capacitor.Cli.Core.Harness.Copilot; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -62,7 +63,7 @@ public async Task ImportSession_AlreadyLoaded_replay_is_a_no_op_suppressed_by_th using var client = new HttpClient(); var source = new CopilotImportSource(Config.Root, CopilotLayout, - repoDetector: _ => Task.FromResult(null)); + repoDetector: _ => Task.FromResult(null), router: new GitProviderRouter()); var discovered = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None); await Assert.That(discovered.Count).IsEqualTo(1); diff --git a/test/Capacitor.Cli.Tests.Integration/CursorImportPrTests.cs b/test/Capacitor.Cli.Tests.Integration/CursorImportPrTests.cs index 0487034c5..70f72820c 100644 --- a/test/Capacitor.Cli.Tests.Integration/CursorImportPrTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/CursorImportPrTests.cs @@ -4,6 +4,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -78,7 +79,7 @@ public async Task Import_attaches_repo_but_records_no_pull_request() { detectCalls++; return Task.FromResult( new RepositoryPayload { Owner = "acme", RepoName = "widgets" }); - }); + }, router: new GitProviderRouter()); using var client = new HttpClient(); diff --git a/test/Capacitor.Cli.Tests.Integration/CursorPrivatizeLifecycleFailureTests.cs b/test/Capacitor.Cli.Tests.Integration/CursorPrivatizeLifecycleFailureTests.cs index b50e02ef1..3cdaefa72 100644 --- a/test/Capacitor.Cli.Tests.Integration/CursorPrivatizeLifecycleFailureTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/CursorPrivatizeLifecycleFailureTests.cs @@ -5,6 +5,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -99,9 +100,9 @@ public async Task private_run_privatizes_even_when_lifecycle_post_fails_after_co _server.Given(Request.Create().WithPath("/api/sessions/*/visibility").UsingPut()) .RespondWith(Response.Create().WithStatusCode(200)); - var source = new CursorImportSource(Config.Root, WriteOneCursorSession(), WorkspaceStorageDir); + var source = new CursorImportSource(Config.Root, WriteOneCursorSession(), WorkspaceStorageDir, router: new GitProviderRouter()); - var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 0, sources: [source], @@ -138,9 +139,9 @@ public async Task private_retry_run_privatizes_an_already_loaded_session_with_no _server.Given(Request.Create().WithPath("/api/sessions/*/visibility").UsingPut()) .RespondWith(Response.Create().WithStatusCode(200)); - var source = new CursorImportSource(Config.Root, WriteOneCursorSession(), WorkspaceStorageDir); + var source = new CursorImportSource(Config.Root, WriteOneCursorSession(), WorkspaceStorageDir, router: new GitProviderRouter()); - var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 0, sources: [source], @@ -174,9 +175,9 @@ public async Task non_private_run_never_calls_set_visibility() { _server.Given(Request.Create().WithPath("/api/sessions/*/visibility").UsingPut()) .RespondWith(Response.Create().WithStatusCode(200)); - var source = new CursorImportSource(Config.Root, WriteOneCursorSession(), WorkspaceStorageDir); + var source = new CursorImportSource(Config.Root, WriteOneCursorSession(), WorkspaceStorageDir, router: new GitProviderRouter()); - var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 0, sources: [source], @@ -265,9 +266,9 @@ public async Task private_run_privatizes_when_child_watermark_probe_fails_both_o _server.Given(Request.Create().WithPath("/api/sessions/*/visibility").UsingPut()) .RespondWith(Response.Create().WithStatusCode(200)); - var source = new CursorImportSource(Config.Root, WriteParentWithCorrelatedChild(), WorkspaceStorageDir); + var source = new CursorImportSource(Config.Root, WriteParentWithCorrelatedChild(), WorkspaceStorageDir, router: new GitProviderRouter()); - var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 0, sources: [source], diff --git a/test/Capacitor.Cli.Tests.Integration/CursorSessionStartVisibilityTests.cs b/test/Capacitor.Cli.Tests.Integration/CursorSessionStartVisibilityTests.cs index 375b4c7d3..11a53407f 100644 --- a/test/Capacitor.Cli.Tests.Integration/CursorSessionStartVisibilityTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/CursorSessionStartVisibilityTests.cs @@ -6,6 +6,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -59,7 +60,7 @@ async Task RunSessionStartAndCaptureBodyAsync(string defaultVisibility using var client = new HttpClient(); var spool = new HookSpool(tmp.CreateDir("spool").Path); - var exit = await new CursorHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).HandleCore(client, new StringReader(body), spool); + var exit = await new CursorHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).HandleCore(client, new StringReader(body), spool); await Assert.That(exit).IsEqualTo(0); var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start/cursor").UsingPost()); diff --git a/test/Capacitor.Cli.Tests.Integration/CursorSuppressedRepoImportTests.cs b/test/Capacitor.Cli.Tests.Integration/CursorSuppressedRepoImportTests.cs index c8403e7e7..e7e9e80e5 100644 --- a/test/Capacitor.Cli.Tests.Integration/CursorSuppressedRepoImportTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/CursorSuppressedRepoImportTests.cs @@ -4,6 +4,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -66,7 +67,7 @@ public async Task Suppression_simulating_reimport_still_posts_repository_and_rep WriteOneCursorSessionWithWorkspace(), WorkspaceStorageDir, repoDetector: _ => Task.FromResult( - new RepositoryPayload { Owner = "acme", RepoName = "widgets" })); + new RepositoryPayload { Owner = "acme", RepoName = "widgets" }), router: new GitProviderRouter()); using var client = new HttpClient(); diff --git a/test/Capacitor.Cli.Tests.Integration/CursorTailingWatcherTests.cs b/test/Capacitor.Cli.Tests.Integration/CursorTailingWatcherTests.cs index 11ca8aa9d..412b8b83a 100644 --- a/test/Capacitor.Cli.Tests.Integration/CursorTailingWatcherTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/CursorTailingWatcherTests.cs @@ -6,6 +6,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -44,7 +45,7 @@ namespace Capacitor.Cli.Tests.Integration; /// [NotInParallel] // KCAP_WATCHER_DIR is process-global, and other classes pin it too. public class CursorTailingWatcherTests { - WatchCommand Watch => field ??= new(Config.Root, Resolutions.None(Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient())); + WatchCommand Watch => field ??= new(Config.Root, Resolutions.None(Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), new GitProviderRouter()); CursorMarkers Markers => new(Config.Root); @@ -188,7 +189,7 @@ public async Task Reactivation_ViaSessionStartHook_SpawnsAFreshWatcher() { var spool = new HookSpool(tmp.PathTo("spool")); var body = $$"""{"hook_event_name":"sessionStart","session_id":"{{sessionId}}","transcript_path":"{{transcriptPath.Replace(@"\", @"\\")}}"}"""; - var exit = await new CursorHookCommand(Config.Root, Resolutions.At(server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore(client, new StringReader(body), spool); + var exit = await new CursorHookCommand(Config.Root, Resolutions.At(server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore(client, new StringReader(body), spool); await Assert.That(exit).IsEqualTo(0); await Assert.That(spawner.Keys).IsEquivalentTo([sessionId]); @@ -222,7 +223,7 @@ public async Task Reactivation_ViaNonSessionStartHook_SpawnsAFreshWatcher() { var spool = new HookSpool(tmp.PathTo("spool")); var body = $$"""{"hook_event_name":"postToolUse","session_id":"{{sessionId}}","transcript_path":"{{transcriptPath.Replace(@"\", @"\\")}}","tool_name":"Bash"}"""; - var exit = await new CursorHookCommand(Config.Root, Resolutions.At(server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore(client, new StringReader(body), spool); + var exit = await new CursorHookCommand(Config.Root, Resolutions.At(server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore(client, new StringReader(body), spool); await Assert.That(exit).IsEqualTo(0); await Assert.That(spawner.Keys).IsEquivalentTo([sessionId]); diff --git a/test/Capacitor.Cli.Tests.Integration/GeminiSessionStartHandshakeOnPostFailureTests.cs b/test/Capacitor.Cli.Tests.Integration/GeminiSessionStartHandshakeOnPostFailureTests.cs index 5fe0fae03..8ca961fec 100644 --- a/test/Capacitor.Cli.Tests.Integration/GeminiSessionStartHandshakeOnPostFailureTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/GeminiSessionStartHandshakeOnPostFailureTests.cs @@ -6,6 +6,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -99,7 +100,7 @@ public async Task A_rejected_lifecycle_post_still_delivers_the_memory_index_and_ // The real memory factory, resolving against this test's own config root: discovery finds no // /auth/config, falls back to a token store that holds nothing, and hands back an // unauthenticated client — which is exactly what the stub wants, without a seam. - var exit = await new GeminiHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())) + var exit = await new GeminiHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()) .Handle(new StringReader(payload)); return (exit, capture.GetCapturedOutput()); diff --git a/test/Capacitor.Cli.Tests.Integration/GeminiStderrShadowedOnPostFailureTests.cs b/test/Capacitor.Cli.Tests.Integration/GeminiStderrShadowedOnPostFailureTests.cs index 8dd1656ec..0347cf889 100644 --- a/test/Capacitor.Cli.Tests.Integration/GeminiStderrShadowedOnPostFailureTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/GeminiStderrShadowedOnPostFailureTests.cs @@ -6,6 +6,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -95,7 +96,7 @@ static string SelectedByGemini(string stdout, string stderr) => using var capture = ConsoleOutput.StartFullCapture(); - var exit = await new GeminiHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())) + var exit = await new GeminiHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()) .Handle(new StringReader(payload)); return (exit, capture.GetCapturedOutput(), capture.GetCapturedError()); diff --git a/test/Capacitor.Cli.Tests.Integration/ImportEndReassertTests.cs b/test/Capacitor.Cli.Tests.Integration/ImportEndReassertTests.cs index 920a2de37..2aa8ff523 100644 --- a/test/Capacitor.Cli.Tests.Integration/ImportEndReassertTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/ImportEndReassertTests.cs @@ -2,6 +2,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -24,7 +25,7 @@ public class ImportEndReassertTests : IDisposable { // These tests exercise chaining and repo resolution, not profile selection. ImportCommand Import() => - new(Config.Root, Resolutions.None(Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()); + new(Config.Root, Resolutions.None(Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()); readonly WireMockServer _server = WireMockServer.Start(); readonly TempDir _tmp = new(); readonly string _tempDir; @@ -76,6 +77,7 @@ static string WriteTranscript(string dir, string sessionId) { }; var classified = await TranscriptFileClassification.ClassifyAsync( + new GitProviderRouter(), Config.Root, Home, client, diff --git a/test/Capacitor.Cli.Tests.Integration/KiroImportSourceImportTests.cs b/test/Capacitor.Cli.Tests.Integration/KiroImportSourceImportTests.cs index 5e983d2e1..03f52ced6 100644 --- a/test/Capacitor.Cli.Tests.Integration/KiroImportSourceImportTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/KiroImportSourceImportTests.cs @@ -4,6 +4,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -56,7 +57,7 @@ public async Task ImportSession_AlreadyLoaded_replay_is_a_no_op_suppressed_by_th using var client = new HttpClient(); var source = new KiroImportSource(Config.Root, root, - repoDetector: _ => Task.FromResult(null)); + repoDetector: _ => Task.FromResult(null), router: new GitProviderRouter()); var discovered = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None); await Assert.That(discovered.Count).IsEqualTo(1); diff --git a/test/Capacitor.Cli.Tests.Integration/PiImportSourceImportTests.cs b/test/Capacitor.Cli.Tests.Integration/PiImportSourceImportTests.cs index bb1d9ede5..d6e401dfb 100644 --- a/test/Capacitor.Cli.Tests.Integration/PiImportSourceImportTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/PiImportSourceImportTests.cs @@ -5,6 +5,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -76,7 +77,7 @@ public async Task ImportSession_posts_lifecycle_and_transcript_with_pi_vendor() var source = new PiImportSource(Config.Root, sessionsDir, - repoDetector: _ => Task.FromResult(null)); + repoDetector: _ => Task.FromResult(null), router: new GitProviderRouter()); var discovered = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None); await Assert.That(discovered.Count).IsEqualTo(1); @@ -143,7 +144,7 @@ public async Task ImportSession_resumes_from_server_watermark_when_partial() { var source = new PiImportSource(Config.Root, sessionsDir, - repoDetector: _ => Task.FromResult(null)); + repoDetector: _ => Task.FromResult(null), router: new GitProviderRouter()); var discovered = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None); var classified = await source.ClassifyAsync( @@ -176,7 +177,7 @@ public async Task Classify_treats_trailing_branch_summary_as_import_relevant_whe var source = new PiImportSource(Config.Root, sessionsDir, - repoDetector: _ => Task.FromResult(null)); + repoDetector: _ => Task.FromResult(null), router: new GitProviderRouter()); var discovered = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None); var classified = await source.ClassifyAsync( @@ -208,7 +209,7 @@ public async Task ImportSession_AlreadyLoaded_replay_is_a_no_op_suppressed_by_th var source = new PiImportSource(Config.Root, sessionsDir, - repoDetector: _ => Task.FromResult(null)); + repoDetector: _ => Task.FromResult(null), router: new GitProviderRouter()); var discovered = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None); var classified = await source.ClassifyAsync( diff --git a/test/Capacitor.Cli.Tests.Integration/RoutedPrivatizeMembershipTests.cs b/test/Capacitor.Cli.Tests.Integration/RoutedPrivatizeMembershipTests.cs index 95453b19d..58284e4b9 100644 --- a/test/Capacitor.Cli.Tests.Integration/RoutedPrivatizeMembershipTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/RoutedPrivatizeMembershipTests.cs @@ -5,6 +5,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -101,7 +102,7 @@ public async Task a_routed_replay_is_not_imported_when_it_could_not_be_made_priv .RespondWith(Response.Create().WithStatusCode(500)); var stdout = await CaptureStdoutAsync(async () => { - await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 0, sources: [new ChildContentOutsidePrivateScopeSource()], @@ -122,7 +123,7 @@ public async Task a_skipped_replay_outside_the_child_content_scope_is_still_priv var exitCode = 0; var stdout = await CaptureStdoutAsync(async () => { - exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 0, sources: [new ChildContentOutsidePrivateScopeSource()], diff --git a/test/Capacitor.Cli.Tests.Integration/RoutedReplayPrivatizeTests.cs b/test/Capacitor.Cli.Tests.Integration/RoutedReplayPrivatizeTests.cs index dadac7194..323637c15 100644 --- a/test/Capacitor.Cli.Tests.Integration/RoutedReplayPrivatizeTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/RoutedReplayPrivatizeTests.cs @@ -5,6 +5,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -129,7 +130,7 @@ void StubAntigravityAlreadyLoadedWithNewChild(int sessionEndStatus = 200) { StubVisibilityPut(); } - Task RunAntigravityImport(bool forcePrivate) => new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + Task RunAntigravityImport(bool forcePrivate) => new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 0, sources: [new AntigravityImportSource(new(new(_agHome), ""))], @@ -243,7 +244,7 @@ public async Task private_run_privatizes_a_gemini_replay_whose_session_end_faile .RespondWith(Response.Create().WithStatusCode(500)); StubVisibilityPut(); - var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 0, sources: [new GeminiImportSource(_geminiHome)], diff --git a/test/Capacitor.Cli.Tests.Integration/SessionStartCoordinationNoticesTests.cs b/test/Capacitor.Cli.Tests.Integration/SessionStartCoordinationNoticesTests.cs index b7140b03b..c2c44ffb0 100644 --- a/test/Capacitor.Cli.Tests.Integration/SessionStartCoordinationNoticesTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/SessionStartCoordinationNoticesTests.cs @@ -6,6 +6,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -83,7 +84,7 @@ public async Task Advertises_the_capability_and_renders_returned_notices() { """); var stdout = new StringWriter(); - await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).Handle(new StringReader(Payload()), stdout: stdout); + await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).Handle(new StringReader(Payload()), stdout: stdout); // Capability advertised on the request. await Assert.That(PostedBody()["coordination_notices"]?.GetValue()).IsEqualTo("v1"); @@ -102,7 +103,7 @@ public async Task Opt_out_suppresses_capability_and_render() { GivenServerReturns("""{ "coordination_notices": [ { "text": "someone else is on this bug" } ] }"""); var stdout = new StringWriter(); - await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).Handle(new StringReader(Payload()), stdout: stdout); + await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).Handle(new StringReader(Payload()), stdout: stdout); await Assert.That(PostedBody()["coordination_notices"]).IsNull(); await Assert.That(stdout.ToString()).DoesNotContain("## Coordination notices"); @@ -116,7 +117,7 @@ public async Task Malformed_notices_field_does_not_fail_the_hook() { GivenServerReturns("""{ "coordination_notices": "v1" }"""); var stdout = new StringWriter(); - await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).Handle(new StringReader(Payload()), stdout: stdout); + await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).Handle(new StringReader(Payload()), stdout: stdout); // Capability still advertised; render silently emits nothing (fail-open, no crash). await Assert.That(PostedBody()["coordination_notices"]?.GetValue()).IsEqualTo("v1"); diff --git a/test/Capacitor.Cli.Tests.Integration/SessionStartMemoryRedirectTests.cs b/test/Capacitor.Cli.Tests.Integration/SessionStartMemoryRedirectTests.cs index 285d82301..f1fb656ef 100644 --- a/test/Capacitor.Cli.Tests.Integration/SessionStartMemoryRedirectTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/SessionStartMemoryRedirectTests.cs @@ -8,6 +8,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -91,7 +92,7 @@ public async Task The_memory_index_fetch_does_not_follow_a_redirect() { using var capture = ConsoleOutput.StartCapture(); - var exit = await new GeminiHookCommand(Config.Root, profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, sp.GetRequiredService(), TestWatchers.For(Config.Root, profiles, sp.GetRequiredService())) + var exit = await new GeminiHookCommand(Config.Root, profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, sp.GetRequiredService(), TestWatchers.For(Config.Root, profiles, sp.GetRequiredService()), router: new GitProviderRouter()) .Handle(new StringReader(payload)); return (exit, capture.GetCapturedOutput()); diff --git a/test/Capacitor.Cli.Tests.Integration/SessionStartVisibilityTests.cs b/test/Capacitor.Cli.Tests.Integration/SessionStartVisibilityTests.cs index 3750dc4de..6c75f4943 100644 --- a/test/Capacitor.Cli.Tests.Integration/SessionStartVisibilityTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/SessionStartVisibilityTests.cs @@ -6,6 +6,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -51,7 +52,7 @@ public async Task Stamps_private_visibility_from_active_profile_v2_config() { _server.Given(Request.Create().WithPath("/hooks/session-start").UsingPost()) .RespondWith(Response.Create().WithStatusCode(200).WithBody("{}")); - await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath())); + await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath())); var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start").UsingPost()); await Assert.That(requests.Count).IsEqualTo(1); @@ -76,7 +77,7 @@ public async Task Lowercases_mixedcase_visibility_from_v2_config() { _server.Given(Request.Create().WithPath("/hooks/session-start").UsingPost()) .RespondWith(Response.Create().WithStatusCode(200).WithBody("{}")); - await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath())); + await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath())); var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start").UsingPost()); await Assert.That(requests.Count).IsEqualTo(1); @@ -101,7 +102,7 @@ public async Task Falls_back_to_org_public_when_v2_config_visibility_is_invalid( _server.Given(Request.Create().WithPath("/hooks/session-start").UsingPost()) .RespondWith(Response.Create().WithStatusCode(200).WithBody("{}")); - await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath())); + await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath())); var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start").UsingPost()); await Assert.That(requests.Count).IsEqualTo(1); @@ -124,7 +125,7 @@ public async Task Stamps_harness_inventory_onto_session_start_body() { _server.Given(Request.Create().WithPath("/hooks/session-start").UsingPost()) .RespondWith(Response.Create().WithStatusCode(200).WithBody("{}")); - await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath())); + await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath())); var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start").UsingPost()); await Assert.That(requests.Count).IsEqualTo(1); @@ -164,7 +165,7 @@ public async Task Skips_session_start_when_repo_is_excluded_by_active_profile_v2 } """; - await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).Handle(new StringReader(payload)); + await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).Handle(new StringReader(payload)); var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start").UsingPost()); await Assert.That(requests.Count).IsEqualTo(0); diff --git a/test/Capacitor.Cli.Tests.Integration/SpoolOutageRecoveryTests.cs b/test/Capacitor.Cli.Tests.Integration/SpoolOutageRecoveryTests.cs index 4fb46ae2a..93279d21f 100644 --- a/test/Capacitor.Cli.Tests.Integration/SpoolOutageRecoveryTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/SpoolOutageRecoveryTests.cs @@ -5,6 +5,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -69,7 +70,7 @@ static string StopPayload(string sid = Sid) => // HandleCore takes a pre-built HttpClient so we bypass auth entirely. Task Invoke(HttpClient client, string payload) => - new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance) + new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()) .HandleCore(client, AuthStatus.Ok, MakeSpool(), new StringReader(payload)); IEnumerable SpoolFiles => diff --git a/test/Capacitor.Cli.Tests.Integration/WatcherHubCredentialTests.cs b/test/Capacitor.Cli.Tests.Integration/WatcherHubCredentialTests.cs index 44d2589d8..90cba9953 100644 --- a/test/Capacitor.Cli.Tests.Integration/WatcherHubCredentialTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/WatcherHubCredentialTests.cs @@ -2,6 +2,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -21,7 +22,7 @@ public class WatcherHubCredentialTests : IDisposable { public void Dispose() => _server.Stop(); WatchCommand Watch(string? bearer) => - new(Config.Root, Resolutions.At(_server.Url!, Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(bearer), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())); + new(Config.Root, Resolutions.At(_server.Url!, Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(bearer), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), new GitProviderRouter()); /// Refused, so the attempt ends at negotiate — which has already sent what we came for. void StubNegotiate() => diff --git a/test/Capacitor.Cli.Tests.Integration/WatcherParentExitPostTests.cs b/test/Capacitor.Cli.Tests.Integration/WatcherParentExitPostTests.cs index 892df501b..5d2010fda 100644 --- a/test/Capacitor.Cli.Tests.Integration/WatcherParentExitPostTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/WatcherParentExitPostTests.cs @@ -3,6 +3,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Integration; @@ -22,7 +23,7 @@ public class WatcherParentExitPostTests : IDisposable { // Instance, not static: the parent-exit POST resolves its client against a config dir, so it // must be this test's own root — which a static helper cannot see, TUnit injecting it after // construction. - WatchCommand Watch() => new(Config.Root, Resolutions.At(_server.Url!, Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())); + WatchCommand Watch() => new(Config.Root, Resolutions.At(_server.Url!, Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), new GitProviderRouter()); public void Dispose() => _server.Stop(); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/AntigravityHookCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/AntigravityHookCommandTests.cs index db0542198..6fb270026 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/AntigravityHookCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/AntigravityHookCommandTests.cs @@ -1,6 +1,7 @@ using Capacitor.Cli.Commands.Harness; using Capacitor.Cli.Commands; using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands.Harness; @@ -34,7 +35,7 @@ public async Task EventArg_is_null_when_missing_or_a_flag_follows() { [Test] public async Task Missing_event_exits_zero_without_touching_network() { // Control hooks must always exit 0 so Antigravity doesn't treat the hook as failed. - var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient())).Handle(["hook", "--antigravity"], new StringReader(""), new StringWriter()); + var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["hook", "--antigravity"], new StringReader(""), new StringWriter()); await Assert.That(rc).IsEqualTo(0); } @@ -42,7 +43,7 @@ public async Task Missing_event_exits_zero_without_touching_network() { public async Task PreInvocation_with_non_string_fields_fails_open() { // conversationId as a non-string shape must not throw (GetValue would); // it fails open to a no-op. - var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient())).Handle(["hook", "--antigravity", "PreInvocation"], + var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["hook", "--antigravity", "PreInvocation"], new StringReader("""{"conversationId":123,"transcriptPath":{"nested":true}}"""), new StringWriter()); await Assert.That(rc).IsEqualTo(0); @@ -55,14 +56,14 @@ public async Task PreInvocation_with_non_string_fields_fails_open() { [Arguments("PostToolUse")] public async Task Non_PreInvocation_events_are_no_ops(string ev) { // These must return 0 and never read stdin / hit the network. - var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient())).Handle(["hook", "--antigravity", ev], + var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["hook", "--antigravity", ev], new ThrowingReader(), new StringWriter()); await Assert.That(rc).IsEqualTo(0); } [Test] public async Task PreInvocation_with_malformed_payload_fails_open() { - var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient())).Handle(["hook", "--antigravity", "PreInvocation"], + var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["hook", "--antigravity", "PreInvocation"], new StringReader("{ not json"), new StringWriter()); await Assert.That(rc).IsEqualTo(0); } @@ -70,11 +71,11 @@ public async Task PreInvocation_with_malformed_payload_fails_open() { [Test] public async Task PreInvocation_without_conversation_or_transcript_is_a_no_op() { // No conversationId → nothing to key on. - await Assert.That(await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient())).Handle(["hook", "--antigravity", "PreInvocation"], + await Assert.That(await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["hook", "--antigravity", "PreInvocation"], new StringReader("""{"transcriptPath":"/t.jsonl"}"""), new StringWriter())).IsEqualTo(0); // conversationId but no transcriptPath → nothing to tail. - await Assert.That(await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient())).Handle(["hook", "--antigravity", "PreInvocation"], + await Assert.That(await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["hook", "--antigravity", "PreInvocation"], new StringReader("""{"conversationId":"abc"}"""), new StringWriter())).IsEqualTo(0); } diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs index a688652fd..554b9d7c0 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs @@ -14,6 +14,7 @@ using WireMock.Server; using Capacitor.Cli.Core.Http; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands.Harness; @@ -65,7 +66,7 @@ public async Task pre_tool_use_is_decided_before_any_client_is_created() { "version: 1\nrules:\n - match: { kind: shell, command: \"git push --force*\" }\n outcome: deny\n"); var stdout = new StringWriter(); - var exit = await new ClaudeHookCommand(Config.Root, fx.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, fx.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()) + var exit = await new ClaudeHookCommand(Config.Root, fx.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, fx.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()) .HandleWithDeps(fx.Spool, new StringReader( $$"""{"hook_event_name":"PreToolUse","session_id":"{{Sid}}","tool_name":"Bash","tool_input":{"command":"git push --force"},"cwd":"/tmp"}"""), () => throw new InvalidOperationException("the seam must decide before a client exists"), @@ -90,7 +91,7 @@ public async Task pre_tool_use_fails_open_when_the_branch_throws() { "version: 1\nrules:\n - match: { kind: shell, command: \"git push --force*\" }\n outcome: deny\n"); var stdout = new ClosedPipeWriter(); - var exit = await new ClaudeHookCommand(Config.Root, fx.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, fx.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()) + var exit = await new ClaudeHookCommand(Config.Root, fx.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, fx.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()) .HandleWithDeps(fx.Spool, new StringReader( $$"""{"hook_event_name":"PreToolUse","session_id":"{{Sid}}","tool_name":"Bash","tool_input":{"command":"git push --force"},"cwd":"/tmp"}"""), () => throw new InvalidOperationException("the seam must decide before a client exists"), @@ -113,7 +114,7 @@ public async Task session_start_surfaces_a_degraded_policy_snapshot() { using var fx = new Fixture(Config.Root); var stdout = new StringWriter { NewLine = "\n" }; - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( $$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""), stdout: stdout); @@ -134,7 +135,7 @@ public async Task session_start_merges_a_policy_degradation_into_the_401_notice( using var fx = new Fixture(Config.Root, HttpStatusCode.Unauthorized); var stdout = new StringWriter { NewLine = "\n" }; - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( $$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""), stdout: stdout); @@ -159,7 +160,7 @@ public async Task session_start_freezes_the_policy_snapshot_on_the_degraded_arm( var stdout = new StringWriter { NewLine = "\n" }; // Unusable URL: no client is ever built, so HandleWithDeps returns from the degraded arm. - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("not-a-url", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("not-a-url", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()) + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("not-a-url", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("not-a-url", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()) .HandleWithDeps(fx.Spool, new StringReader( $$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""), () => throw new InvalidOperationException("no client is buildable for an unusable URL"), @@ -181,7 +182,7 @@ public async Task stop_clears_the_turn_journal() { await Assert.That(File.Exists(Config.Root.Path("policy", "journal", $"{Sid}.json"))).IsTrue(); using var fx = new Fixture(Config.Root); - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( $$"""{"hook_event_name":"Stop","session_id":"{{Sid}}","cwd":"/tmp"}""")); @@ -230,7 +231,7 @@ public async Task memory_store_initialization_failure_does_not_suppress_session_ using var fx = new Fixture(Config.Root); MemoryStoreProbe.Poison(Config.Root); - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( $$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}""")); @@ -242,7 +243,7 @@ public async Task memory_store_initialization_failure_does_not_suppress_session_ public async Task disabled_memory_index_does_not_construct_the_lease_store() { using var fx = new Fixture(Config.Root); fx.MemoryIndexBody = """[{"memory_id":"m1","slug":"s","audience":"org","description":"d","kind":"preference"}]"""; // decoy — must never be fetched - var hook = new ClaudeHookCommand(Config.Root, Resolutions.Of(new Profile { DisableMemoryIndex = true }, serverUrl: "http://localhost"), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.Of(new Profile { DisableMemoryIndex = true }, serverUrl: "http://localhost"), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()); + var hook = new ClaudeHookCommand(Config.Root, Resolutions.Of(new Profile { DisableMemoryIndex = true }, serverUrl: "http://localhost"), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.Of(new Profile { DisableMemoryIndex = true }, serverUrl: "http://localhost"), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()); var exit = await hook.HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( @@ -263,7 +264,7 @@ public async Task update_check_off_suppresses_the_in_agent_nudge_even_when_serve using var fx = new Fixture(Config.Root) { RespondJson = """{"version": "999.0.0"}""" }; var stdout = new StringWriter(); - var hook = new ClaudeHookCommand(Config.Root, Resolutions.Of(new Profile { UpdateCheck = false }, serverUrl: fx.MemoryServerUrl), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.Of(new Profile { UpdateCheck = false }, serverUrl: fx.MemoryServerUrl), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()); + var hook = new ClaudeHookCommand(Config.Root, Resolutions.Of(new Profile { UpdateCheck = false }, serverUrl: fx.MemoryServerUrl), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.Of(new Profile { UpdateCheck = false }, serverUrl: fx.MemoryServerUrl), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()); var exit = await hook.HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( @@ -286,7 +287,7 @@ public async Task update_check_on_still_emits_the_in_agent_nudge_for_a_newer_ser using var fx = new Fixture(Config.Root) { RespondJson = """{"version": "999.0.0"}""" }; var stdout = new StringWriter(); - var hook = new ClaudeHookCommand(Config.Root, Resolutions.Of(new Profile { UpdateCheck = true }, serverUrl: fx.MemoryServerUrl), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.Of(new Profile { UpdateCheck = true }, serverUrl: fx.MemoryServerUrl), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()); + var hook = new ClaudeHookCommand(Config.Root, Resolutions.Of(new Profile { UpdateCheck = true }, serverUrl: fx.MemoryServerUrl), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.Of(new Profile { UpdateCheck = true }, serverUrl: fx.MemoryServerUrl), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()); var exit = await hook.HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( @@ -759,7 +760,7 @@ public async Task session_start_on_401_exits_zero_and_nudges_the_user_to_log_in( using var fx = new Fixture(Config.Root, HttpStatusCode.Unauthorized); var stdout = new StringWriter { NewLine = "\n" }; - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( $$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""), @@ -781,7 +782,7 @@ public async Task session_start_on_401_is_spooled_for_replay_after_login() { using var fx = new Fixture(Config.Root, HttpStatusCode.Unauthorized); var stdout = new StringWriter { NewLine = "\n" }; - await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore( + await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( $$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""), stdout: stdout); @@ -820,7 +821,7 @@ public async Task session_end_whose_spool_write_fails_reports_the_drop_not_a_spo var unwritable = new HookSpool(tmp.PathTo("blocker", "spool")); // a directory under a file using var capture = ConsoleOutput.StartErrorCapture("\n"); - await new ClaudeHookCommand(Config.Root, fx.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, fx.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore( + await new ClaudeHookCommand(Config.Root, fx.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, fx.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( fx.Client, AuthStatus.Ok, unwritable, new StringReader( $$"""{"hook_event_name":"SessionEnd","session_id":"{{Sid}}","transcript_path":"/none","cwd":"/tmp"}""")); @@ -838,13 +839,13 @@ public async Task spooled_entry_that_hits_a_401_on_drain_is_replayed_once_the_se rejecting.Spool.Append(Sid, "session-end", $$"""{"session_id":"{{Sid}}"}"""); var stdout = new StringWriter { NewLine = "\n" }; - await new ClaudeHookCommand(Config.Root, rejecting.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, rejecting.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore( + await new ClaudeHookCommand(Config.Root, rejecting.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, rejecting.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( rejecting.Client, AuthStatus.Ok, rejecting.Spool, new StringReader( $$"""{"hook_event_name":"Stop","session_id":"{{Sid}}","transcript_path":"/none","cwd":"/tmp"}"""), stdout: stdout); await Assert.That(rejecting.Spool.HasBacklog(Sid)).IsTrue(); - await new ClaudeHookCommand(Config.Root, accepting.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, accepting.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore( + await new ClaudeHookCommand(Config.Root, accepting.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, accepting.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( accepting.Client, AuthStatus.Ok, rejecting.Spool, new StringReader( $$"""{"hook_event_name":"Stop","session_id":"{{Sid}}","transcript_path":"/none","cwd":"/tmp"}"""), stdout: stdout); @@ -886,7 +887,7 @@ public async Task session_end_spooled_when_client_creation_exceeds_budget() { // 13.4s already elapsed → session-end remaining = 15 - 13.4 - 1.5 ≈ 0.1s cap. var sw = System.Diagnostics.Stopwatch.StartNew(); - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), Aged(TimeSpan.FromSeconds(13.4)), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleWithDeps( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), Aged(TimeSpan.FromSeconds(13.4)), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleWithDeps( fx.Spool, new StringReader($$"""{"hook_event_name":"SessionEnd","session_id":"{{Sid}}","transcript_path":"/none","cwd":"/tmp"}"""), slowFactory); @@ -1008,7 +1009,7 @@ public async Task subagent_stop_spooled_when_client_creation_exceeds_budget() { Task.Delay(TimeSpan.FromSeconds(30)).ContinueWith(_ => new AuthAttempt(new HttpClient(), AuthStatus.Ok), TaskScheduler.Default); // 3.4s already elapsed → subagent-stop remaining = 5 - 3.4 - 1.5 ≈ 0.1s cap. var sw = System.Diagnostics.Stopwatch.StartNew(); - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), Aged(TimeSpan.FromSeconds(3.4)), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleWithDeps( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), Aged(TimeSpan.FromSeconds(3.4)), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleWithDeps( fx.Spool, new StringReader($$"""{"hook_event_name":"SubagentStop","session_id":"{{Sid}}","agent_id":"{{AgentId}}","transcript_path":"/none","cwd":"/tmp"}"""), slowFactory); @@ -1081,7 +1082,7 @@ public async Task session_start_with_expired_auth_exits_zero_and_emits_the_expir using var fx = new Fixture(Config.Root); var stdout = new StringWriter { NewLine = "\n" }; - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( fx.Client, AuthStatus.Expired, fx.Spool, new StringReader( $$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""), @@ -1097,7 +1098,7 @@ public async Task session_start_with_wrong_server_auth_exits_zero_and_emits_the_ using var fx = new Fixture(Config.Root); var stdout = new StringWriter { NewLine = "\n" }; - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( fx.Client, AuthStatus.WrongServer, fx.Spool, new StringReader( $$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""), @@ -1113,7 +1114,7 @@ public async Task stop_with_expired_auth_exits_zero_without_a_notice() { using var fx = new Fixture(Config.Root); var stdout = new StringWriter { NewLine = "\n" }; - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( fx.Client, AuthStatus.Expired, fx.Spool, new StringReader( $$"""{"hook_event_name":"Stop","session_id":"{{Sid}}","cwd":"/tmp"}"""), stdout: stdout); @@ -1134,7 +1135,7 @@ public async Task stop_on_401_exits_zero_and_nudges_the_user_to_log_in() { using var fx = new Fixture(Config.Root, HttpStatusCode.Unauthorized); var stdout = new StringWriter { NewLine = "\n" }; - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( $$"""{"hook_event_name":"Stop","session_id":"{{Sid}}","cwd":"/tmp"}"""), stdout: stdout); @@ -1150,7 +1151,7 @@ public async Task notification_on_401_exits_zero_without_a_notice() { using var fx = new Fixture(Config.Root, HttpStatusCode.Unauthorized); var stdout = new StringWriter { NewLine = "\n" }; - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( $$"""{"hook_event_name":"Notification","session_id":"{{Sid}}","cwd":"/tmp"}"""), stdout: stdout); @@ -1166,7 +1167,7 @@ public async Task stop_on_500_still_exits_non_zero_without_a_notice() { using var fx = new Fixture(Config.Root, HttpStatusCode.InternalServerError); var stdout = new StringWriter { NewLine = "\n" }; - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( $$"""{"hook_event_name":"Stop","session_id":"{{Sid}}","cwd":"/tmp"}"""), stdout: stdout); @@ -1260,7 +1261,7 @@ public Fixture(ConfigRoot config, HttpStatusCode postStatus = HttpStatusCode.OK, public Task HandleAsync(string stdin, TimeSpan elapsed = default) { StubMemoryServer(); - return new ClaudeHookCommand(Config, Profiles, Aged(elapsed), _home, TestHarnesses.Under(_home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config, Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore( + return new ClaudeHookCommand(Config, Profiles, Aged(elapsed), _home, TestHarnesses.Under(_home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config, Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( Client, AuthStatus.Ok, Spool, new StringReader(stdin)); } diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookInputWaitRelayTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookInputWaitRelayTests.cs index a2b83cdad..66d766edb 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookInputWaitRelayTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookInputWaitRelayTests.cs @@ -8,6 +8,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands.Harness; @@ -39,7 +40,7 @@ static HookClock Aged(TimeSpan elapsed) { async Task RunAsync(HostedAgent hosted, string eventName, HookClock? clock = null, string extraFields = "") { using var client = new HttpClient(new OkHandler()); var payload = $$$"""{"hook_event_name":"{{{eventName}}}","session_id":"{{{Sid}}}","cwd":"/tmp","tool_name":"Bash","tool_input":{"command":"ls"}{{{extraFields}}}}"""; - return await new ClaudeHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), clock ?? new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance) + return await new ClaudeHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), clock ?? new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()) .HandleWithDeps(new HookSpool(Config.Root), new StringReader(payload), () => Task.FromResult(new AuthAttempt(client, AuthStatus.Ok, null, null)), new StringWriter()); } diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookCommandTests.cs index d7b38439c..dadf977d0 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookCommandTests.cs @@ -5,6 +5,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands.Harness; @@ -46,7 +47,7 @@ public async Task SessionStart_posts_to_session_start_codex_with_normalized_sess } """; - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); @@ -93,7 +94,7 @@ public async Task Stop_posts_to_hooks_stop_and_emits_continue_json() { using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); // Stop now POSTs /hooks/stop exactly once, carrying the full payload. @@ -137,7 +138,7 @@ public async Task Stop_still_emits_continue_json_when_server_is_slow() { using var capture = ConsoleOutput.StartCapture(); var sw = System.Diagnostics.Stopwatch.StartNew(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); sw.Stop(); await Assert.That(exit).IsEqualTo(0); @@ -170,7 +171,7 @@ public async Task Stop_still_emits_continue_json_when_auth_discovery_is_slow() { using var capture = ConsoleOutput.StartCapture(); var sw = System.Diagnostics.Stopwatch.StartNew(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); sw.Stop(); await Assert.That(exit).IsEqualTo(0); @@ -198,7 +199,7 @@ public async Task Stop_with_malformed_base_url_still_emits_continue_and_returns_ // Scheme-less URL → IsAcceptableUrl is false → PostBestEffortAsync returns // before any client is built, spending no budget or lease. - var exit = await new CodexHookCommand(Config.Root, Resolutions.At("localhost:5108", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("localhost:5108", Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At("localhost:5108", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("localhost:5108", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); var doc = JsonDocument.Parse(capture.GetCapturedOutput()); @@ -234,7 +235,7 @@ public async Task PermissionRequest_records_event_and_yields_decision_to_codex() using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); @@ -279,7 +280,7 @@ public async Task PermissionRequest_returns_quickly_when_server_is_slow() { using var capture = ConsoleOutput.StartCapture(); var sw = System.Diagnostics.Stopwatch.StartNew(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); sw.Stop(); await Assert.That(exit).IsEqualTo(0); @@ -320,7 +321,7 @@ public async Task PermissionRequest_returns_quickly_when_auth_discovery_is_slow( using var capture = ConsoleOutput.StartCapture(); var sw = System.Diagnostics.Stopwatch.StartNew(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); sw.Stop(); await Assert.That(exit).IsEqualTo(0); @@ -341,7 +342,7 @@ public async Task UserPromptSubmit_PreToolUse_PostToolUse_are_swallowed() { } """; - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); } @@ -353,7 +354,7 @@ public async Task UserPromptSubmit_PreToolUse_PostToolUse_are_swallowed() { public async Task Unknown_event_returns_zero_and_no_request() { var payload = """{"hook_event_name": "BogusEvent", "session_id": "abc"}"""; - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); await Assert.That(_server.LogEntries.Count).IsEqualTo(0); @@ -363,7 +364,7 @@ public async Task Unknown_event_returns_zero_and_no_request() { public async Task Missing_hook_event_name_returns_zero_silently() { var payload = """{"session_id": "abc"}"""; - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); await Assert.That(_server.LogEntries.Count).IsEqualTo(0); @@ -373,7 +374,7 @@ public async Task Missing_hook_event_name_returns_zero_silently() { public async Task Malformed_json_returns_zero_silently() { var payload = "{not json"; - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); } @@ -383,7 +384,7 @@ public async Task Malformed_json_returns_zero_silently() { public async Task Hook_event_name_as_number_returns_zero_without_crash() { var payload = """{"hook_event_name": 99, "session_id": "abc", "transcript_path": "/tmp/r.jsonl"}"""; - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); await Assert.That(_server.LogEntries.Count).IsEqualTo(0); @@ -402,7 +403,7 @@ public async Task Stop_with_numeric_session_id_returns_zero_without_crash() { _server.Given(Request.Create().WithPath("/hooks/session-end/codex").UsingPost()) .RespondWith(Response.Create().WithStatusCode(200).WithBody("{}")); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); @@ -447,7 +448,7 @@ public async Task Handle_skips_dispatch_when_session_is_disabled() { using var capture = ConsoleOutput.StartCapture(); try { - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); @@ -480,7 +481,7 @@ public async Task Stop_skips_the_watcher_for_an_envelope_sourced_hosted_session( Environment.SetEnvironmentVariable("KCAP_HOSTED_APPSERVER", "1"); var payload = """{"hook_event_name":"Stop","session_id":"g1-stop-suppressed","transcript_path":"/tmp/r.jsonl","cwd":"/tmp"}"""; - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner)).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); // The watcher never spawns for an envelope-sourced session... @@ -512,7 +513,7 @@ public async Task Stop_spawns_the_watcher_without_the_hosted_appserver_marker() Environment.SetEnvironmentVariable("KCAP_HOSTED_APPSERVER", null); var payload = """{"hook_event_name":"Stop","session_id":"g1-stop-control","transcript_path":"/tmp/r.jsonl","cwd":"/tmp"}"""; - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner)).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); await Assert.That(spawner.Spawned.Count).IsEqualTo(1); @@ -534,7 +535,7 @@ public async Task SessionStart_skips_the_watcher_for_an_envelope_sourced_hosted_ Environment.SetEnvironmentVariable("KCAP_HOSTED_APPSERVER", "1"); var payload = """{"hook_event_name":"SessionStart","session_id":"g1-start-suppressed","transcript_path":"/tmp/r.jsonl","cwd":"/tmp"}"""; - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner)).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); // No watcher for an envelope-sourced session; the session-start POST is untouched. @@ -570,7 +571,7 @@ public async Task PermissionRequest_with_daemon_url_set_posts_to_bridge_and_forw using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") + var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") ); await Assert.That(exit).IsEqualTo(0); @@ -592,7 +593,7 @@ public async Task PermissionRequest_in_an_envelope_sourced_hosted_session_yields using var marker = EnvScope.Exclusive("KCAP_HOSTED_APPSERVER", "1"); using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient())) + var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()) .Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1","tool_name":"shell","tool_input":{"command":"ls"}}""")); await Assert.That(exit).IsEqualTo(0); @@ -612,7 +613,7 @@ public async Task PermissionRequest_with_daemon_url_emits_deny_and_exits_nonzero using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") + var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") ); await Assert.That(exit).IsEqualTo(1); @@ -626,7 +627,7 @@ public async Task PermissionRequest_with_daemon_url_emits_deny_on_connection_ref using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") + var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") ); await Assert.That(exit).IsEqualTo(1); @@ -640,7 +641,7 @@ public async Task PermissionRequest_with_non_loopback_daemon_url_emits_deny_with using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") + var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") ); await Assert.That(exit).IsEqualTo(1); @@ -662,7 +663,7 @@ public async Task PermissionRequest_with_a_blank_daemon_url_yields_to_codex_rath var hosted = new HostedAgent(null, IsRendered: false, DaemonBridge.Parse("")); using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())) + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()) .Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1","tool_name":"shell","tool_input":{"command":"ls"}}""")); await Assert.That(exit).IsEqualTo(0); @@ -676,7 +677,7 @@ public async Task PermissionRequest_with_https_daemon_url_emits_deny_without_pos using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") + var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") ); await Assert.That(exit).IsEqualTo(1); @@ -704,7 +705,7 @@ public async Task PermissionRequest_with_daemon_url_does_not_double_post_to_serv using var capture = ConsoleOutput.StartCapture(); - await new CodexHookCommand(Config.Root, Resolutions.At($"http://127.0.0.1:{server.Ports[0]}", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At($"http://127.0.0.1:{server.Ports[0]}", Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") + await new CodexHookCommand(Config.Root, Resolutions.At($"http://127.0.0.1:{server.Ports[0]}", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At($"http://127.0.0.1:{server.Ports[0]}", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") ); await Assert.That(server.LogEntries.Count).IsEqualTo(0); // server NOT touched @@ -725,7 +726,7 @@ public async Task KcapSkip_SessionStart_emits_continue_json_and_skips_server() { using var capture = ConsoleOutput.StartCapture(); try { - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader("""{"hook_event_name":"SessionStart","session_id":"abc","cwd":"/tmp","transcript_path":"/tmp/r.jsonl"}""")); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader("""{"hook_event_name":"SessionStart","session_id":"abc","cwd":"/tmp","transcript_path":"/tmp/r.jsonl"}""")); await Assert.That(exit).IsEqualTo(0); @@ -746,7 +747,7 @@ public async Task KcapSkip_Stop_emits_continue_json_and_skips_server() { using var capture = ConsoleOutput.StartCapture(); try { - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader("""{"hook_event_name":"Stop","session_id":"abc","cwd":"/tmp","transcript_path":"/tmp/r.jsonl"}""")); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader("""{"hook_event_name":"Stop","session_id":"abc","cwd":"/tmp","transcript_path":"/tmp/r.jsonl"}""")); await Assert.That(exit).IsEqualTo(0); @@ -770,7 +771,7 @@ public async Task KcapSkip_PermissionRequest_emits_empty_object_and_skips_server using var capture = ConsoleOutput.StartCapture(); try { - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"abc","tool_name":"shell"}""")); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"abc","tool_name":"shell"}""")); await Assert.That(exit).IsEqualTo(0); @@ -793,7 +794,7 @@ public async Task KcapSkip_PreToolUse_is_silent_and_skips_server() { using var capture = ConsoleOutput.StartCapture(); try { - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader("""{"hook_event_name":"PreToolUse","session_id":"abc","tool_name":"shell"}""")); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader("""{"hook_event_name":"PreToolUse","session_id":"abc","tool_name":"shell"}""")); await Assert.That(exit).IsEqualTo(0); await Assert.That(capture.GetCapturedOutput()).IsEqualTo(string.Empty); @@ -842,7 +843,7 @@ public async Task The_permission_request_bridge_draws_the_loopback_lane() { using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, http, TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), http)).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, http, TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), http), router: new GitProviderRouter()).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); await Assert.That(http.Lanes).IsEquivalentTo(new[] { "Loopback" }); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookInputWaitRelayTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookInputWaitRelayTests.cs index 166825ae3..8d44a113e 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookInputWaitRelayTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookInputWaitRelayTests.cs @@ -5,6 +5,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands.Harness; @@ -27,7 +28,7 @@ public async Task Hosted_turn_boundaries_relay_to_the_daemon_bridge(string event using var capture = ConsoleOutput.StartCapture(); var hosted = new HostedAgent("agent-1", IsRendered: false, new DaemonBridge.Loopback($"http://127.0.0.1:{bridge.Ports[0]}/tok")); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient())) + var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()) .Handle(new StringReader($$"""{"hook_event_name":"{{eventName}}","session_id":"019e0322-05fc-7570-be65-75719c3ea861","cwd":"/tmp"}""")); await Assert.That(exit).IsEqualTo(0); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CursorHookCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CursorHookCommandTests.cs index fc8880273..152d09167 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CursorHookCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CursorHookCommandTests.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.Time.Testing; using Capacitor.Cli.Core.Http; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands.Harness; @@ -126,7 +127,7 @@ public async Task spooled_entry_that_hits_a_401_on_drain_is_replayed_once_the_se await rejecting.HandleAsync($$"""{"hook_event_name":"postToolUse","session_id":"{{Sid}}","tool_name":"Glob"}"""); await Assert.That(rejecting.Spool.HasBacklog(Sid)).IsTrue(); - await new CursorHookCommand(Config.Root, accepting.Profiles, new HookClock(accepting.Clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, accepting.Profiles, new FixedCapacitorHttpClient())).HandleCore( + await new CursorHookCommand(Config.Root, accepting.Profiles, new HookClock(accepting.Clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, accepting.Profiles, new FixedCapacitorHttpClient()), router: new GitProviderRouter()).HandleCore( accepting.Client, stdin: new StringReader($$"""{"hook_event_name":"postToolUse","session_id":"{{Sid}}","tool_name":"Glob"}"""), spool: rejecting.Spool); @@ -178,7 +179,7 @@ public async Task telemetry_hook_does_not_recovery_spawn_while_an_earlier_canoni }); using var client = new HttpClient(handler); - var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore( + var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore( client, new StringReader($$"""{"hook_event_name":"postToolUse","session_id":"{{sid}}","tool_name":"Bash","transcript_path":"/tmp/{{sid}}.jsonl"}"""), spool); @@ -347,7 +348,7 @@ public async Task a_work_budget_spent_before_dispatch_still_spools_the_event() { var clock = new HookClock(spent); // anchors on construction — advance AFTER it spent.Advance(CursorHookCommand.Ceiling - HookBudget.Safety); - var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient())) + var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()) .HandleWithDeps( new StringReader("""{"hook_event_name":"sessionStart","session_id":"abc"}"""), _ => Task.FromResult(new AuthAttempt(fx.Client, AuthStatus.Ok)), @@ -369,7 +370,7 @@ public async Task expired_budget_returns_zero_not_throws() { var clock = new HookClock(spent); spent.Advance(CursorHookCommand.Ceiling); - var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient())).HandleCore( + var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).HandleCore( fx.Client, new StringReader("""{"hook_event_name":"sessionStart","session_id":"abc"}"""), fx.Spool @@ -530,7 +531,7 @@ public async Task HardCap_before_resolve_emits_nothing() { // noticing. clientFactory/spoolFactory stand in for real auth/spool setup so the // test stays hermetic while still exercising the REAL entry point's cap+emit logic. var clock = new FakeTimeProvider(); - var call = new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient())) + var call = new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()) .HandleWithDeps(new NeverCompletingReader(), _ => Task.FromResult(new AuthAttempt(fx.Client, AuthStatus.Ok)), () => fx.Spool); @@ -557,7 +558,7 @@ public async Task HandleCore_deadline_win_cancels_the_abandoned_inners_token() { using var fx = new Fixture(Config.Root); var reader = new CancelObservingReader(); - var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient())).HandleCore(fx.Client, reader, fx.Spool); + var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).HandleCore(fx.Client, reader, fx.Spool); await Assert.That(exit).IsEqualTo(0); // The read never resolved (no hook_event_name was ever parsed), so there is @@ -581,7 +582,7 @@ public async Task HardCap_after_resolve_sessionStart_emits_empty_once() { fx.HoldOnPost = TimeSpan.FromMilliseconds(300); var sw = System.Diagnostics.Stopwatch.StartNew(); - var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient())).HandleWithDeps( + var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).HandleWithDeps( new StringReader("""{"hook_event_name":"sessionStart","session_id":"abc"}"""), _ => Task.FromResult(new AuthAttempt(fx.Client, AuthStatus.Ok)), () => fx.Spool); @@ -608,7 +609,7 @@ public async Task HardCap_during_client_setup_emits_nothing_and_no_late_write() var sw = System.Diagnostics.Stopwatch.StartNew(); var clock = new FakeTimeProvider(); - var call = new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient())) + var call = new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()) .HandleWithDeps( new StringReader("""{"hook_event_name":"sessionStart","session_id":"abc"}"""), _ => neverAuths.Task, @@ -862,7 +863,7 @@ public async Task CancelledFetch_leaves_lease_uncommitted() { // The real scope resolver runs: its git spawn is bounded by a Stopwatch, so its wall-clock // cost cannot eat a budget that only moves when this test says so. var elapsed = System.Diagnostics.Stopwatch.StartNew(); - var call = new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient())).HandleCore( + var call = new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).HandleCore( hangingClient, new StringReader(payload), fx.Spool); // Wait (bounded, real-time) for the request to ENTER the handler, then fire the budget clock. @@ -888,7 +889,7 @@ public async Task CancelledFetch_leaves_lease_uncommitted() { clock.Advance(TimeSpan.FromSeconds(31)); fx.MemoryIndexBody = "[]"; - var exit2 = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient())).HandleCore( + var exit2 = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).HandleCore( fx.Client, new StringReader(payload), fx.Spool); await Assert.That(exit2).IsEqualTo(0); // The index GET fires again on fx.Client — proving the first, cancelled attempt's @@ -1060,7 +1061,7 @@ public Fixture(ConfigRoot config, HttpStatusCode postStatus = HttpStatusCode.OK, public HostedAgent Hosted { get; init; } = HostedAgent.Terminal; public Task HandleAsync(string stdin) => - new CursorHookCommand(Config, Profiles, new HookClock(Clock), _home, TestHarnesses.Under(_home), Hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config, Profiles, new FixedCapacitorHttpClient())).HandleCore( + new CursorHookCommand(Config, Profiles, new HookClock(Clock), _home, TestHarnesses.Under(_home), Hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config, Profiles, new FixedCapacitorHttpClient()), router: new GitProviderRouter()).HandleCore( Client, stdin: new StringReader(stdin), spool: Spool); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ImportChainsTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ImportChainsTests.cs index 6f965df52..334e1755b 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ImportChainsTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ImportChainsTests.cs @@ -6,6 +6,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -16,7 +17,7 @@ public class ImportChainsTests : IDisposable { // These tests exercise chaining and repo resolution, not profile selection. ImportCommand Import() => - new(Config.Root, Resolutions.None(Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()); + new(Config.Root, Resolutions.None(Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()); readonly WireMockServer _server = WireMockServer.Start(); // TUnit creates a new class instance per test, so _tempDir is always unique. readonly TempDir _tmp = new(); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ImportClassifyTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ImportClassifyTests.cs index 6f859d5a0..3c52972dd 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ImportClassifyTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ImportClassifyTests.cs @@ -2,6 +2,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -49,6 +50,7 @@ public async Task ClassifyAsync_maps_404_to_New() { using var client = new HttpClient(); var result = await TranscriptFileClassification.ClassifyAsync( + new GitProviderRouter(), Config.Root, Home, client, @@ -80,6 +82,7 @@ public async Task ClassifyAsync_maps_204_to_AlreadyLoaded() { using var client = new HttpClient(); var result = await TranscriptFileClassification.ClassifyAsync( + new GitProviderRouter(), Config.Root, Home, client, @@ -113,6 +116,7 @@ public async Task ClassifyAsync_maps_200_with_last_line_to_Partial() { using var client = new HttpClient(); var result = await TranscriptFileClassification.ClassifyAsync( + new GitProviderRouter(), Config.Root, Home, client, @@ -143,6 +147,7 @@ public async Task ClassifyAsync_maps_short_transcript_to_TooShort() { using var client = new HttpClient(); var result = await TranscriptFileClassification.ClassifyAsync( + new GitProviderRouter(), Config.Root, Home, client, @@ -171,6 +176,7 @@ public async Task ClassifyAsync_maps_server_error_to_ProbeError() { using var client = new HttpClient(); var result = await TranscriptFileClassification.ClassifyAsync( + new GitProviderRouter(), Config.Root, Home, client, @@ -202,6 +208,7 @@ public async Task ClassifyAsync_identifies_kcap_subsession() { using var client = new HttpClient(); var result = await TranscriptFileClassification.ClassifyAsync( + new GitProviderRouter(), Config.Root, Home, client, @@ -241,6 +248,7 @@ await File.WriteAllLinesAsync( using var client = new HttpClient(); var result = await TranscriptFileClassification.ClassifyAsync( + new GitProviderRouter(), Config.Root, Home, client, @@ -270,6 +278,7 @@ public async Task ClassifyAsync_invokes_onProbed_callback_once_per_transcript() using var client = new HttpClient(); var result = await TranscriptFileClassification.ClassifyAsync( + new GitProviderRouter(), Config.Root, Home, client, @@ -306,6 +315,7 @@ public async Task ClassifyAsync_reclassifies_Partial_to_AlreadyLoaded_when_no_ne using var client = new HttpClient(); var result = await TranscriptFileClassification.ClassifyAsync( + new GitProviderRouter(), Config.Root, Home, client, _server.Url!, transcripts, @@ -337,6 +347,7 @@ public async Task ClassifyAsync_keeps_Partial_when_local_transcript_has_new_line using var client = new HttpClient(); var result = await TranscriptFileClassification.ClassifyAsync( + new GitProviderRouter(), Config.Root, Home, client, _server.Url!, transcripts, @@ -380,6 +391,7 @@ await File.WriteAllLinesAsync( using var client = new HttpClient(); var result = await TranscriptFileClassification.ClassifyAsync( + new GitProviderRouter(), Config.Root, Home, client, _server.Url!, transcripts, diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ImportDiscoveryAgeTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ImportDiscoveryAgeTests.cs index 60bb18123..f5cf7c816 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ImportDiscoveryAgeTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ImportDiscoveryAgeTests.cs @@ -18,6 +18,7 @@ using Capacitor.Cli.Harness.Kiro; using Capacitor.Cli.Harness.OpenCode; using Capacitor.Cli.Harness.Pi; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -52,7 +53,7 @@ public async Task Codex_is_dated_by_the_day_directory_the_rollout_sits_in() { var day = tmp.CreateDir("sessions", "2026", "01", "05"); var roll = day.CreateFile("rollout-abc.jsonl", "{}"); - var age = new CodexImportSource(Config.Root, CodexHarness.FromEnvironment(Home).Paths.Sessions).DiscoveryAge(Session(HarnessId.Codex, null, roll)); + var age = new CodexImportSource(Config.Root, CodexHarness.FromEnvironment(Home).Paths.Sessions, router: new GitProviderRouter()).DiscoveryAge(Session(HarnessId.Codex, null, roll)); // Not the file's mtime, which is now: --since prunes Codex on the directory alone. await Assert.That(age!.Value.UtcDateTime.Date).IsEqualTo(new DateTime(2026, 1, 5)); @@ -66,7 +67,7 @@ public async Task Claude_is_dated_by_the_transcripts_first_timestamp_not_its_mti /*lang=json*/ "{\"type\":\"user\",\"timestamp\":\"2026-08-01T10:00:00Z\",\"message\":{\"content\":\"later\"}}", ]); - var age = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects).DiscoveryAge(Session(HarnessId.Claude, null, path)); + var age = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects, router: new GitProviderRouter()).DiscoveryAge(Session(HarnessId.Claude, null, path)); // A session started in January and appended to today belongs to January, which is the window // --since places it in. Taking mtime would count it inside a 30-day window it is not in. @@ -86,7 +87,7 @@ public async Task Claude_keeps_scanning_past_a_malformed_line_and_beyond_the_fir var path = tmp.CreateFile("session.jsonl", [.. lines]); - var age = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects).DiscoveryAge(Session(HarnessId.Claude, null, path)); + var age = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects, router: new GitProviderRouter()).DiscoveryAge(Session(HarnessId.Claude, null, path)); await Assert.That(age!.Value.UtcDateTime.Date).IsEqualTo(new DateTime(2026, 1, 5)); } @@ -96,7 +97,7 @@ public async Task Claude_falls_back_to_the_last_write_when_no_timestamp_can_be_r using var tmp = new TempDir(); var path = tmp.CreateFile("garbage.jsonl", "not json at all"); - var age = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects).DiscoveryAge(Session(HarnessId.Claude, null, path)); + var age = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects, router: new GitProviderRouter()).DiscoveryAge(Session(HarnessId.Claude, null, path)); // Same fallback the --since filter takes when the metadata carries no timestamp. await Assert.That(age).IsNotNull(); @@ -128,9 +129,9 @@ public async Task Sources_that_resolve_a_first_timestamp_are_dated_by_it(string /// Every source but Claude and Codex, which resolve no timestamp during discovery. IImportSource SourceFor(string vendor) => vendor switch { "gemini" => new GeminiImportSource(GeminiHarness.FromEnvironment(Home).Paths.TmpDir), - "kiro" => new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir), - "pi" => new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir), - "copilot" => new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths), + "kiro" => new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()), + "pi" => new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()), + "copilot" => new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths, router: new GitProviderRouter()), "antigravity" => new AntigravityImportSource(AntigravityHarness.Over(GeminiHarness.FromEnvironment(Home)).Paths), "opencode" => new OpenCodeImportSource( Path.Combine(OpenCodeHarness.FromEnvironment(Home).Paths.DataDir, "opencode.db"), @@ -142,7 +143,7 @@ public async Task Sources_that_resolve_a_first_timestamp_are_dated_by_it(string CursorImportSource NewCursorSource() { var paths = CursorHarness.FromEnvironment(Home).Paths; - return new(Config.Root, paths.ProjectsDir, paths.WorkspaceStorageDir); + return new(Config.Root, paths.ProjectsDir, paths.WorkspaceStorageDir, router: new GitProviderRouter()); } [Test] diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ImportResolveReposSubSessionTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ImportResolveReposSubSessionTests.cs index 399983368..b4b9f6a2d 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ImportResolveReposSubSessionTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ImportResolveReposSubSessionTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Capacitor.Cli.Commands; using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -48,7 +49,7 @@ await File.WriteAllLinesAsync( var sessionCwds = new Dictionary(StringComparer.Ordinal); - await new ImportCommand(Config.Root, Resolutions.None(Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()) + await new ImportCommand(Config.Root, Resolutions.None(Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()) .ResolveTranscriptReposAsync( transcripts, codex: false, diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ImportSkipTitleTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ImportSkipTitleTests.cs index 6c384d582..de77af6df 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ImportSkipTitleTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ImportSkipTitleTests.cs @@ -4,6 +4,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -72,11 +73,11 @@ Task RunImport(string name, bool skipTitle) { [.. Enumerable.Range(0, 20).Select(i => $$$"""{"type":"user","timestamp":"2026-03-15T10:00:00Z","cwd":"/tmp/skip-title-proj","message":{"content":"add a retry to the import loop {{{i}}}"}}""")]); - return new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home, BinaryProbe.FromEnvironment()), new FixedCapacitorHttpClient()) + return new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home, BinaryProbe.FromEnvironment()), new FixedCapacitorHttpClient(), router: new GitProviderRouter()) .HandleImport( filterCwd: null, minLines: 1, - sources: [new ClaudeImportSource(Config.Root, projectsDir.Path)], + sources: [new ClaudeImportSource(Config.Root, projectsDir.Path, router: new GitProviderRouter())], scope: new ImportScope.All(), skipConfirmation: true, skipTitle: skipTitle); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ImportVendorSelectionOutputTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ImportVendorSelectionOutputTests.cs index 9a33a7236..1147bbf4a 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ImportVendorSelectionOutputTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ImportVendorSelectionOutputTests.cs @@ -1,6 +1,7 @@ using Capacitor.Cli.Commands; using Capacitor.Cli.Core.Harness; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -38,7 +39,7 @@ public Task ImportSessionAsync( } Task Run(params IImportSource[] sources) => - new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()) + new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()) .HandleImport( filterCwd: null, sources: sources, diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ImportVisibilityTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ImportVisibilityTests.cs index ab43da935..51eb91847 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ImportVisibilityTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ImportVisibilityTests.cs @@ -20,6 +20,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -49,7 +50,7 @@ public class ImportVisibilityTests : IDisposable { // These tests exercise chaining and repo resolution, not profile selection. ImportCommand Import() => - new(Config.Root, Resolutions.None(Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()); + new(Config.Root, Resolutions.None(Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()); readonly WireMockServer _server = WireMockServer.Start(); readonly TempDir _tmp = new(); readonly string _tempDir; @@ -199,10 +200,10 @@ public async Task HandleImport_chain_new_session_stamps_default_visibility_when_ var projectsDir = Path.Combine(_tempDir, "claude-projects-pos"); WriteClaudeSession(projectsDir, "vis-chain-handle-pos"); - var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 1, - sources: [new ClaudeImportSource(Config.Root, projectsDir)], + sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())], scope: new ImportScope.All(), skipConfirmation: true, forcePrivate: false, @@ -233,10 +234,10 @@ public async Task HandleImport_chain_forcePrivate_stamps_private_even_when_trans var projectsDir = Path.Combine(_tempDir, "claude-projects-neg"); WriteClaudeSession(projectsDir, "vis-chain-handle-neg"); - var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 1, - sources: [new ClaudeImportSource(Config.Root, projectsDir)], + sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())], scope: new ImportScope.All(), skipConfirmation: true, forcePrivate: true, @@ -269,10 +270,10 @@ public async Task HandleImport_chain_forcePrivate_privatizes_a_resume_whose_sess var projectsDir = Path.Combine(_tempDir, "claude-projects-resume"); WriteClaudeSession(projectsDir, "vis-chain-resume-fail"); - var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 1, - sources: [new ClaudeImportSource(Config.Root, projectsDir)], + sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())], scope: new ImportScope.All(), skipConfirmation: true, forcePrivate: true, @@ -305,10 +306,10 @@ public async Task HandleImport_forcePrivate_leaves_alone_a_session_the_run_never var projectsDir = Path.Combine(_tempDir, "claude-projects-short-private"); WriteClaudeSession(projectsDir, "vis-private-too-short", lines: 3); - await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 500, - sources: [new ClaudeImportSource(Config.Root, projectsDir)], + sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())], scope: new ImportScope.All(), skipConfirmation: true, forcePrivate: true @@ -331,10 +332,10 @@ public async Task HandleImport_forcePrivate_privatizes_an_existing_session_befor var projectsDir = Path.Combine(_tempDir, "claude-projects-window"); WriteClaudeSession(projectsDir, "vis-window"); - await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 1, - sources: [new ClaudeImportSource(Config.Root, projectsDir)], + sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())], scope: new ImportScope.All(), skipConfirmation: true, forcePrivate: true @@ -368,10 +369,10 @@ public async Task HandleImport_forcePrivate_does_not_pre_privatize_a_session_tha var projectsDir = Path.Combine(_tempDir, "claude-projects-newonly"); WriteClaudeSession(projectsDir, "vis-new-only"); - await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 1, - sources: [new ClaudeImportSource(Config.Root, projectsDir)], + sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())], scope: new ImportScope.All(), skipConfirmation: true, forcePrivate: true @@ -400,10 +401,10 @@ public async Task HandleImport_shareWithOrg_writes_an_explicit_org_visibility() var projectsDir = Path.Combine(_tempDir, "claude-projects-shared"); WriteClaudeSession(projectsDir, "vis-chain-shared"); - var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 1, - sources: [new ClaudeImportSource(Config.Root, projectsDir)], + sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())], scope: new ImportScope.All(), skipConfirmation: true, forcePrivate: false, @@ -432,10 +433,10 @@ public async Task HandleImport_writes_no_explicit_visibility_when_neither_stop_w var projectsDir = Path.Combine(_tempDir, "claude-projects-plain"); WriteClaudeSession(projectsDir, "vis-chain-plain"); - await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 1, - sources: [new ClaudeImportSource(Config.Root, projectsDir)], + sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())], scope: new ImportScope.All(), skipConfirmation: true ); @@ -457,10 +458,10 @@ public async Task HandleImport_shareWithOrg_reaches_a_session_this_run_only_revi var projectsDir = Path.Combine(_tempDir, "claude-projects-already"); WriteClaudeSession(projectsDir, "vis-already-shared"); - var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 1, - sources: [new ClaudeImportSource(Config.Root, projectsDir)], + sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())], scope: new ImportScope.All(), skipConfirmation: true, shareWithOrg: true @@ -491,10 +492,10 @@ public async Task HandleImport_shareWithOrg_leaves_alone_a_session_the_run_never var projectsDir = Path.Combine(_tempDir, "claude-projects-short"); WriteClaudeSession(projectsDir, "vis-too-short", lines: 3); - await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 500, - sources: [new ClaudeImportSource(Config.Root, projectsDir)], + sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())], scope: new ImportScope.All(), skipConfirmation: true, shareWithOrg: true @@ -518,10 +519,10 @@ public async Task HandleImport_reports_a_lost_visibility_write_through_the_outco ImportCommand.ImportRunOutcome? outcome = null; - var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 1, - sources: [new ClaudeImportSource(Config.Root, projectsDir)], + sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())], scope: new ImportScope.All(), skipConfirmation: true, shareWithOrg: true, @@ -548,10 +549,10 @@ public async Task HandleImport_reports_a_measured_zero_when_nothing_matches_the_ ImportCommand.ImportRunOutcome? outcome = null; - var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 1, - sources: [new ClaudeImportSource(Config.Root, projectsDir)], + sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())], scope: new ImportScope.Repo([("kurrent-io", "nothing-here-by-that-name")]), skipConfirmation: true, onFinished: o => outcome = o @@ -578,10 +579,10 @@ public async Task HandleImport_reports_a_clean_run_as_nothing_failed() { ImportCommand.ImportRunOutcome? outcome = null; - await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 1, - sources: [new ClaudeImportSource(Config.Root, projectsDir)], + sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())], scope: new ImportScope.All(), skipConfirmation: true, shareWithOrg: true, @@ -620,10 +621,10 @@ public async Task HandleImport_forcePrivate_does_not_upload_into_a_session_it_co ImportCommand.ImportRunOutcome? outcome = null; - var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 1, - sources: [new ClaudeImportSource(Config.Root, projectsDir)], + sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())], scope: new ImportScope.All(), skipConfirmation: true, forcePrivate: true, @@ -657,10 +658,10 @@ public async Task HandleImport_forcePrivate_reports_a_session_it_had_to_skip() { using var errors = ConsoleOutput.StartErrorCapture(); - await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 1, - sources: [new ClaudeImportSource(Config.Root, projectsDir)], + sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())], scope: new ImportScope.All(), skipConfirmation: true, forcePrivate: true @@ -703,7 +704,7 @@ public async Task Copilot_new_session_stamps_default_visibility() { using var client = new HttpClient(); var ctx = new ImportContext(client, _server.Url!, ForcePrivate: false, DefaultVisibility: "org_public"); - await new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths).ImportSessionAsync(c, ctx, CancellationToken.None); + await new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths, router: new GitProviderRouter()).ImportSessionAsync(c, ctx, CancellationToken.None); var body = SessionStartBody("copilot"); await Assert.That(body["default_visibility"]?.GetValue()).IsEqualTo("org_public"); @@ -718,13 +719,13 @@ public async Task Copilot_partial_and_already_loaded_sessions_omit_default_visib var partialPath = WriteTranscript("copilot-partial.jsonl"); var partial = RoutedClassification("copilot-partial-1", ImportCommand.ClassificationStatus.Partial, new() { ["TranscriptPath"] = partialPath }, resumeFromLine: 2); - await new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths).ImportSessionAsync(partial, ctx, CancellationToken.None); + await new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths, router: new GitProviderRouter()).ImportSessionAsync(partial, ctx, CancellationToken.None); await Assert.That(SessionStartBody("copilot").ContainsKey("default_visibility")).IsFalse(); var alreadyPath = WriteTranscript("copilot-already.jsonl"); var already = RoutedClassification("copilot-already-1", ImportCommand.ClassificationStatus.AlreadyLoaded, new() { ["TranscriptPath"] = alreadyPath }, totalLines: 5); - await new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths).ImportSessionAsync(already, ctx, CancellationToken.None); + await new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths, router: new GitProviderRouter()).ImportSessionAsync(already, ctx, CancellationToken.None); var alreadyBody = JsonNode.Parse( _server.LogEntries.Where(e => e.RequestMessage.Path == "/hooks/session-start/copilot") @@ -742,7 +743,7 @@ public async Task Copilot_forcePrivate_stamps_private_over_the_step3_default() { using var client = new HttpClient(); var ctx = new ImportContext(client, _server.Url!, ForcePrivate: true, DefaultVisibility: "org_public"); - await new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths).ImportSessionAsync(c, ctx, CancellationToken.None); + await new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths, router: new GitProviderRouter()).ImportSessionAsync(c, ctx, CancellationToken.None); await Assert.That(SessionStartBody("copilot")["default_visibility"]?.GetValue()) .IsEqualTo("private"); @@ -805,7 +806,7 @@ public async Task Kiro_new_session_stamps_default_visibility() { using var client = new HttpClient(); var ctx = new ImportContext(client, _server.Url!, ForcePrivate: false, DefaultVisibility: "org_public"); - await new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir).ImportSessionAsync(c, ctx, CancellationToken.None); + await new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()).ImportSessionAsync(c, ctx, CancellationToken.None); var body = SessionStartBody("kiro"); await Assert.That(body["default_visibility"]?.GetValue()).IsEqualTo("org_public"); @@ -820,7 +821,7 @@ public async Task Kiro_partial_session_omits_default_visibility() { using var client = new HttpClient(); var ctx = new ImportContext(client, _server.Url!, ForcePrivate: false, DefaultVisibility: "org_public"); - await new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir).ImportSessionAsync(c, ctx, CancellationToken.None); + await new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()).ImportSessionAsync(c, ctx, CancellationToken.None); await Assert.That(SessionStartBody("kiro").ContainsKey("default_visibility")).IsFalse(); } @@ -834,7 +835,7 @@ public async Task Kiro_forcePrivate_stamps_private_over_the_step3_default() { using var client = new HttpClient(); var ctx = new ImportContext(client, _server.Url!, ForcePrivate: true, DefaultVisibility: "org_public"); - await new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir).ImportSessionAsync(c, ctx, CancellationToken.None); + await new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()).ImportSessionAsync(c, ctx, CancellationToken.None); await Assert.That(SessionStartBody("kiro")["default_visibility"]?.GetValue()) .IsEqualTo("private"); @@ -851,7 +852,7 @@ public async Task Pi_new_session_stamps_default_visibility() { using var client = new HttpClient(); var ctx = new ImportContext(client, _server.Url!, ForcePrivate: false, DefaultVisibility: "org_public"); - await new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir).ImportSessionAsync(c, ctx, CancellationToken.None); + await new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()).ImportSessionAsync(c, ctx, CancellationToken.None); var body = SessionStartBody("pi"); await Assert.That(body["default_visibility"]?.GetValue()).IsEqualTo("org_public"); @@ -866,7 +867,7 @@ public async Task Pi_partial_session_omits_default_visibility() { using var client = new HttpClient(); var ctx = new ImportContext(client, _server.Url!, ForcePrivate: false, DefaultVisibility: "org_public"); - await new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir).ImportSessionAsync(c, ctx, CancellationToken.None); + await new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()).ImportSessionAsync(c, ctx, CancellationToken.None); await Assert.That(SessionStartBody("pi").ContainsKey("default_visibility")).IsFalse(); } @@ -880,7 +881,7 @@ public async Task Pi_forcePrivate_keeps_existing_private_stamp_and_never_the_org using var client = new HttpClient(); var ctx = new ImportContext(client, _server.Url!, ForcePrivate: true, DefaultVisibility: "org_public"); - await new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir).ImportSessionAsync(c, ctx, CancellationToken.None); + await new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()).ImportSessionAsync(c, ctx, CancellationToken.None); // Pi's existing forcePrivate behavior (stamping the literal "private") is untouched — // the new guard must never override it with the org-level default. @@ -1072,7 +1073,7 @@ public async Task Cursor_new_session_stamps_default_visibility() { await new CursorImportSource(Config.Root, Path.Combine(_tempDir, "unused-cursor-projects"), Path.Combine(_tempDir, "unused-cursor-workspace-storage") - ) + , router: new GitProviderRouter()) .ImportSessionAsync(c, ctx, CancellationToken.None); var body = SessionStartBody("cursor"); @@ -1092,7 +1093,7 @@ public async Task Cursor_partial_session_omits_default_visibility() { await new CursorImportSource(Config.Root, Path.Combine(_tempDir, "unused-cursor-projects-2"), Path.Combine(_tempDir, "unused-cursor-workspace-storage-2") - ) + , router: new GitProviderRouter()) .ImportSessionAsync(c, ctx, CancellationToken.None); await Assert.That(SessionStartBody("cursor").ContainsKey("default_visibility")).IsFalse(); @@ -1110,7 +1111,7 @@ public async Task Cursor_forcePrivate_stamps_private_over_the_step3_default() { await new CursorImportSource(Config.Root, Path.Combine(_tempDir, "unused-cursor-projects-3"), Path.Combine(_tempDir, "unused-cursor-workspace-storage-3") - ) + , router: new GitProviderRouter()) .ImportSessionAsync(c, ctx, CancellationToken.None); await Assert.That(SessionStartBody("cursor")["default_visibility"]?.GetValue()) @@ -1135,9 +1136,9 @@ public async Task Cursor_full_round_trip_through_HandleImport_stamps_default_vis .RespondWith(Response.Create().WithStatusCode(404)); StubAllHookEndpoints(); - var source = new CursorImportSource(Config.Root, projectsDir, Path.Combine(_tempDir, "cursor-workspace-storage-rt")); + var source = new CursorImportSource(Config.Root, projectsDir, Path.Combine(_tempDir, "cursor-workspace-storage-rt"), router: new GitProviderRouter()); - var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 0, sources: [source], @@ -1176,16 +1177,16 @@ sealed record RoutedSourceCase( Func> MakeSourceMeta); RoutedSourceCase CopilotCase() => - new(HarnessId.Copilot, () => new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths), p => new() { ["TranscriptPath"] = p }); + new(HarnessId.Copilot, () => new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths, router: new GitProviderRouter()), p => new() { ["TranscriptPath"] = p }); RoutedSourceCase GeminiCase() => new(HarnessId.Gemini, () => new GeminiImportSource(GeminiHarness.FromEnvironment(Home).Paths.TmpDir), p => new() { ["TranscriptPath"] = p }); RoutedSourceCase KiroCase() => - new(HarnessId.Kiro, () => new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir), p => new() { ["TranscriptPath"] = p }); + new(HarnessId.Kiro, () => new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()), p => new() { ["TranscriptPath"] = p }); RoutedSourceCase PiCase() => - new(HarnessId.Pi, () => new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir), p => new() { ["TranscriptPath"] = p }); + new(HarnessId.Pi, () => new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()), p => new() { ["TranscriptPath"] = p }); RoutedSourceCase AntigravityCase() => new(HarnessId.Antigravity, () => new AntigravityImportSource(AntigravityHarness.Over(GeminiHarness.FromEnvironment(Home)).Paths), p => new() { ["TranscriptPath"] = p }); @@ -1194,7 +1195,7 @@ RoutedSourceCase CursorCase() => new(HarnessId.Cursor, () => new CursorImportSource(Config.Root, Path.Combine(_tempDir, $"unused-cursor-projects-{Guid.NewGuid():N}"), - Path.Combine(_tempDir, $"unused-cursor-workspace-storage-{Guid.NewGuid():N}")), + Path.Combine(_tempDir, $"unused-cursor-workspace-storage-{Guid.NewGuid():N}"), router: new GitProviderRouter()), p => new() { ["TranscriptPath"] = p, ["WorkspaceFolder"] = "/Users/me/proj" }); async Task AssertAlreadyLoadedOmitsDefaultVisibility(RoutedSourceCase rc) { @@ -1433,12 +1434,12 @@ public async Task HandleImport_autoSkipExclusions_completes_without_prompting_an // happened to look like an interactive TTY, this call could block forever on // Console.ReadLine(). It must not, regardless of ambient TTY state. var import = new ImportCommand(Config.Root, - Resolutions.Of(new Profile { ExcludedPaths = [excludedDir] }, "autoskip-test", _server.Url!), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()); + Resolutions.Of(new Profile { ExcludedPaths = [excludedDir] }, "autoskip-test", _server.Url!), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()); var task = import.HandleImport( filterCwd: null, minLines: 1, - sources: [new ClaudeImportSource(Config.Root, projectsDir)], + sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())], scope: new ImportScope.All(), skipConfirmation: true, autoSkipExclusions: true, diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/McpAnalyticsServerTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/McpAnalyticsServerTests.cs index 7646d7e0c..ea9328571 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/McpAnalyticsServerTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/McpAnalyticsServerTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Text.Json.Nodes; using Capacitor.Cli.Commands; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -9,7 +10,7 @@ public class McpAnalyticsServerTests { // Resolutions.None: these tests exercise routing, not profile selection. McpAnalyticsServer Server() => - new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), new FixedCapacitorHttpClient(), NoTelemetry.Startup); + new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter()); static JsonObject Args(string json) => JsonNode.Parse(json)!.AsObject(); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerReviewerVendorsTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerReviewerVendorsTests.cs index 5b4425357..a7e4618b3 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerReviewerVendorsTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerReviewerVendorsTests.cs @@ -4,6 +4,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -13,7 +14,7 @@ public class McpFlowsServerReviewerVendorsTests { // Resolutions.None: these tests exercise routing, not profile selection. McpFlowsServer Server() => new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), - new FixedCapacitorHttpClient(), NoTelemetry.Startup); + new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter()); static JsonObject ToolCall() => new() { ["params"] = new JsonObject { ["name"] = "list_reviewer_vendors", ["arguments"] = new JsonObject() } diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerSettlementRetryTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerSettlementRetryTests.cs index 782a16513..ed000025b 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerSettlementRetryTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerSettlementRetryTests.cs @@ -4,6 +4,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -29,7 +30,7 @@ public class McpFlowsServerSettlementRetryTests { // Resolutions.None: these tests exercise routing, not profile selection. McpFlowsServer Server() => new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), - new FixedCapacitorHttpClient(), NoTelemetry.Startup); + new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter()); // Every wait in both retry lanes runs on the injected clock, so these tests are instant and // the requested schedule is directly assertable (VirtualFlowRetryClock.Delays). diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerTests.cs index 6fb943c7f..66705a12e 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerTests.cs @@ -4,6 +4,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -13,7 +14,7 @@ public class McpFlowsServerTests { // Resolutions.None: these tests exercise routing, not profile selection. McpFlowsServer Server() => new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), - new FixedCapacitorHttpClient(), NoTelemetry.Startup); + new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter()); // The ack retry's 2s wait now runs on the injected clock, so these tests stay instant while // still asserting the real schedule (VirtualFlowRetryClock.Delays records every requested wait). diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerVendorOverrideTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerVendorOverrideTests.cs index 94d065e5e..16165bc96 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerVendorOverrideTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerVendorOverrideTests.cs @@ -4,6 +4,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -17,7 +18,7 @@ public class McpFlowsServerVendorOverrideTests { // Resolutions.None: these tests exercise routing, not profile selection. McpFlowsServer Server() => new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), - new FixedCapacitorHttpClient(), NoTelemetry.Startup); + new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter()); static JsonObject StartArguments(string? vendor = null) { var args = new JsonObject { diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ParticipantUnreachableRetryTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ParticipantUnreachableRetryTests.cs index 9369b1436..ebdb50bef 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ParticipantUnreachableRetryTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ParticipantUnreachableRetryTests.cs @@ -4,6 +4,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -30,7 +31,7 @@ public class ParticipantUnreachableRetryTests { // Resolutions.None: these tests exercise routing, not profile selection. McpFlowsServer Server() => new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), - new FixedCapacitorHttpClient(), NoTelemetry.Startup); + new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter()); static VirtualFlowRetryClock Clock() => new(); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ReplayChildContentCapabilityTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ReplayChildContentCapabilityTests.cs index a3ccc6bf2..9530ba397 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ReplayChildContentCapabilityTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ReplayChildContentCapabilityTests.cs @@ -12,6 +12,7 @@ using Capacitor.Cli.Harness.Kiro; using Capacitor.Cli.Harness.OpenCode; using Capacitor.Cli.Harness.Pi; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -95,13 +96,13 @@ IImportSource MakeSource(string vendor) { var scratch = tmp.PathTo("capability-probe"); return vendor switch { - "claude" => new ClaudeImportSource(Config.Root, scratch), - "codex" => new CodexImportSource(Config.Root, scratch), - "copilot" => new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths), - "cursor" => new CursorImportSource(Config.Root, scratch, scratch), + "claude" => new ClaudeImportSource(Config.Root, scratch, router: new GitProviderRouter()), + "codex" => new CodexImportSource(Config.Root, scratch, router: new GitProviderRouter()), + "copilot" => new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths, router: new GitProviderRouter()), + "cursor" => new CursorImportSource(Config.Root, scratch, scratch, router: new GitProviderRouter()), "gemini" => new GeminiImportSource(scratch), - "kiro" => new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir), - "pi" => new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir), + "kiro" => new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()), + "pi" => new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()), "opencode" => new OpenCodeImportSource(Path.Combine(scratch, "db"), Path.Combine(scratch, "ledger")), "antigravity" => new AntigravityImportSource(new(new(scratch), "")), _ => throw new ArgumentOutOfRangeException(nameof(vendor), vendor, "unclassified import source"), diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ReviewerVendorFallbackTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ReviewerVendorFallbackTests.cs index 123390ed1..4acb3e5bc 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ReviewerVendorFallbackTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ReviewerVendorFallbackTests.cs @@ -4,6 +4,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -20,7 +21,7 @@ public class ReviewerVendorFallbackTests { // Resolutions.None: these tests exercise routing, not profile selection. McpFlowsServer Server() => new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), - new FixedCapacitorHttpClient(), NoTelemetry.Startup); + new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter()); // The wire shape TryParseCodedError accepts: a JSON object with a non-empty string "error" // plus a string "message" — the CLI-side reading of the server's FlowReviewerResultError. diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SetupChosenServerTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SetupChosenServerTests.cs index 4779022e3..8d918f89c 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/SetupChosenServerTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/SetupChosenServerTests.cs @@ -4,6 +4,7 @@ using Capacitor.Cli.Core.Config; using Capacitor.Cli.Core.Http; using Microsoft.Extensions.DependencyInjection; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -28,7 +29,7 @@ SetupCommand Started(ProfileContext startup) { new AuthProviderDiscovery(factory), NoTelemetry.Facade, AuthEndpoints.Defaults, new FakeFacadeFactory(_ => throw new InvalidOperationException("no façade in these tests")), FakeImportRunner.Succeeding(), - new ChosenServerHttp(Config.Root, startup, ProfileOverrides.None, MachineAuth.None)); + new ChosenServerHttp(Config.Root, startup, ProfileOverrides.None, MachineAuth.None), router: new GitProviderRouter()); } /// A first run: nothing resolved a server before the command started, which is the case that diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs index dde88277f..a0940a562 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs @@ -8,6 +8,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Capacitor.Cli.Core.Harness; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -33,7 +34,7 @@ SetupCommand Command(ISetupImportRunner imports) => AuthFixtures.NewTokenStore(Config.Root), new RecordingBrowser(), Home, TestHarnesses.Under(Home), new AgentsPaths(Home), new FixedCapacitorHttpClient(), Provisioning, Discovery, NoTelemetry.Facade, AuthEndpoints.Defaults, RealFacades(), imports, - new ChosenServerHttp(Config.Root, Resolutions.None(Config.Root), ProfileOverrides.None, MachineAuth.None)); + new ChosenServerHttp(Config.Root, Resolutions.None(Config.Root), ProfileOverrides.None, MachineAuth.None), router: new GitProviderRouter()); /// The real façade: these tests drive the import and argv legs, not a substituted login. IOnboardingFacadeFactory RealFacades() => diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SetupFacadeParityTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SetupFacadeParityTests.cs index d093f6fd5..663d45673 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/SetupFacadeParityTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/SetupFacadeParityTests.cs @@ -8,6 +8,7 @@ using Spectre.Console; using TUnit.Assertions.Enums; using Profile = Capacitor.Cli.Core.Config.Profile; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -83,7 +84,7 @@ SetupCommand Command(CliTelemetry telemetry, IOnboardingFacadeFactory facades) = Home, TestHarnesses.Under(Home), new AgentsPaths(Home), new FixedCapacitorHttpClient(), Provisioning, Discovery, telemetry, AuthEndpoints.Defaults, facades, FakeImportRunner.Throwing(new InvalidOperationException("these tests stop before the import step")), - new ChosenServerHttp(Config.Root, Resolutions.None(Config.Root), ProfileOverrides.None, MachineAuth.None)); + new ChosenServerHttp(Config.Root, Resolutions.None(Config.Root), ProfileOverrides.None, MachineAuth.None), router: new GitProviderRouter()); // ── Step 1: RunDiscoveryAsync (GitHub) ────────────────────────────────── diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SetupImportLaneTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SetupImportLaneTests.cs index 1a0f49798..ab1db6bef 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/SetupImportLaneTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/SetupImportLaneTests.cs @@ -3,6 +3,7 @@ using Capacitor.Cli.Core.FirstRun; using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Harness.Claude; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -105,7 +106,7 @@ static FirstRunImportAnswer Answer( SetupImportLane Lane(Func> runner) => new(Config.Root, Resolutions.None(Config.Root), Home, new FixedCapacitorHttpClient(), - TestHarnesses.Under(Home), runner); + TestHarnesses.Under(Home), new GitProviderRouter(), runner); /// A run that reported its Done grid with nothing failed. static Task Clean() => @@ -251,10 +252,10 @@ public async Task Discovery_resolves_its_windows_against_the_instant_it_is_given ImportCommand.ImportDiscoveryResult? found = null; await new ImportCommand(Config.Root, Resolutions.None(Config.Root), Home, - TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport( + TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport( filterCwd: null, minLines: 1, - sources: [new ClaudeImportSource(Config.Root, projects)], + sources: [new ClaudeImportSource(Config.Root, projects, router: new GitProviderRouter())], discoverOnly: true, discoverJson: true, windowsAsOf: asOf, @@ -275,7 +276,7 @@ await Assert.That(await ThirtyDayCountAsOf(new DateTimeOffset(2026, 4, 16, 0, 0, [Test] public async Task Every_harness_has_a_source_when_nothing_filters_them() { - var built = SetupCommand.BuildImportSources(Config.Root, TestHarnesses.Under(Home)); + var built = SetupCommand.BuildImportSources(Config.Root, TestHarnesses.Under(Home), router: new GitProviderRouter()); await Assert.That(built.Select(b => b.Vendor)) .IsEquivalentTo(HarnessRegistry.Identities.Select(h => h.Id)); @@ -285,7 +286,7 @@ await Assert.That(built.Select(b => b.Vendor)) public async Task Only_the_named_vendors_sources_are_built() { // The filter is applied to what gets scanned, which is what makes a reported figure already // scoped rather than needing subtraction afterwards. - var built = SetupCommand.BuildImportSources(Config.Root, TestHarnesses.Under(Home), [HarnessId.Claude, HarnessId.Codex]); + var built = SetupCommand.BuildImportSources(Config.Root, TestHarnesses.Under(Home), new GitProviderRouter(), [HarnessId.Claude, HarnessId.Codex]); await Assert.That(built.Select(s => s.Vendor)).IsEquivalentTo([HarnessId.Claude, HarnessId.Codex]); } @@ -294,7 +295,7 @@ public async Task Only_the_named_vendors_sources_are_built() { public async Task An_empty_vendor_list_builds_nothing_rather_than_everything() { // "Scan nothing" is a real answer — every agent on the machine was left unrecorded — and // collapsing it to "no filter" would import exactly what the user declined. - await Assert.That(SetupCommand.BuildImportSources(Config.Root, TestHarnesses.Under(Home), [])).IsEmpty(); + await Assert.That(SetupCommand.BuildImportSources(Config.Root, TestHarnesses.Under(Home), new GitProviderRouter(), [])).IsEmpty(); } // ---- What the run reports back to the flow. diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ShutdownTranscriptSpoolTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ShutdownTranscriptSpoolTests.cs index cffcc7af0..88ec26bfa 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ShutdownTranscriptSpoolTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ShutdownTranscriptSpoolTests.cs @@ -1,6 +1,7 @@ using Capacitor.Cli.Commands; using Capacitor.Cli.Core; using Capacitor.Cli.Core.Harness.Cursor; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -13,7 +14,7 @@ namespace Capacitor.Cli.Tests.Unit.Commands; public class ShutdownTranscriptSpoolTests { [TempHome] public required TempHome Home { get; init; } - WatchCommand Watch => field ??= new(Config.Root, Resolutions.None(Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient())); + WatchCommand Watch => field ??= new(Config.Root, Resolutions.None(Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), new GitProviderRouter()); CursorMarkers Markers => new(Config.Root); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/StatusWaitArgumentTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/StatusWaitArgumentTests.cs index 676d2b327..8f06d30e2 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/StatusWaitArgumentTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/StatusWaitArgumentTests.cs @@ -3,6 +3,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -22,7 +23,7 @@ public class StatusWaitArgumentTests { // Resolutions.None: these tests exercise routing, not profile selection. McpFlowsServer Server() => new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), - new FixedCapacitorHttpClient(), NoTelemetry.Startup); + new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter()); static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(3); static readonly TimeSpan PollCap = TimeSpan.FromMinutes(8); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ToolCallBudgetTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ToolCallBudgetTests.cs index 116dcc739..1b68274ee 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ToolCallBudgetTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ToolCallBudgetTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Text.Json.Nodes; using Capacitor.Cli.Commands; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -22,7 +23,7 @@ public class ToolCallBudgetTests { // Resolutions.None: these tests exercise routing, not profile selection. McpFlowsServer Server() => new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), - new FixedCapacitorHttpClient(), NoTelemetry.Startup); + new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter()); static JsonObject StartArguments() => new() { ["kind"] = "code-review", diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/UnusableUrlGuardTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/UnusableUrlGuardTests.cs index 876ffbb2c..04592e3ba 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/UnusableUrlGuardTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/UnusableUrlGuardTests.cs @@ -2,6 +2,7 @@ using Capacitor.Cli.Commands.Harness; using Capacitor.Cli.Core; using Capacitor.Cli.Core.Config; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -204,7 +205,7 @@ public async Task Suppresses_a_disabled_session_given_a_dashed_payload_id() { // normalization, so passing the raw payload id straight through would miss it entirely. var body = $$"""{"session_id":"{{dashed}}","hook_event_name":"SessionStart"}"""; - await Assert.That(await new ClaudeHookCommand(Config.Root, Resolutions.None(Config.Root), _clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).ShouldSuppressCaptureAsync( + await Assert.That(await new ClaudeHookCommand(Config.Root, Resolutions.None(Config.Root), _clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).ShouldSuppressCaptureAsync( dashless, body, "session-start", activeProfile: null, _clock.Budget(Ceiling))).IsTrue(); } @@ -215,7 +216,7 @@ public async Task Session_end_suppression_also_clears_the_marker() { var body = $$"""{"session_id":"{{sid}}","hook_event_name":"SessionEnd"}"""; - await Assert.That(await new ClaudeHookCommand(Config.Root, Resolutions.None(Config.Root), _clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).ShouldSuppressCaptureAsync( + await Assert.That(await new ClaudeHookCommand(Config.Root, Resolutions.None(Config.Root), _clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).ShouldSuppressCaptureAsync( sid, body, "session-end", activeProfile: null, _clock.Budget(Ceiling))).IsTrue(); // Collapsing the gate into a plain boolean would have dropped this cleanup. @@ -228,7 +229,7 @@ public async Task Does_not_suppress_an_ordinary_session() { var sid = Guid.NewGuid().ToString("N"); var body = $$"""{"session_id":"{{sid}}","hook_event_name":"SessionStart"}"""; - await Assert.That(await new ClaudeHookCommand(Config.Root, Resolutions.None(Config.Root), _clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).ShouldSuppressCaptureAsync( + await Assert.That(await new ClaudeHookCommand(Config.Root, Resolutions.None(Config.Root), _clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).ShouldSuppressCaptureAsync( sid, body, "session-start", activeProfile: null, _clock.Budget(Ceiling))).IsFalse(); } @@ -237,7 +238,7 @@ public async Task Does_not_suppress_an_ordinary_session() { public async Task Cursor_never_builds_a_client_for_an_unusable_url() { var entered = false; - var exit = await new CursorHookCommand(Config.Root, Bad, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Bad, new FixedCapacitorHttpClient())).HandleWithDeps( + var exit = await new CursorHookCommand(Config.Root, Bad, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Bad, new FixedCapacitorHttpClient()), router: new GitProviderRouter()).HandleWithDeps( new StringReader("""{"hook_event_name":"sessionStart","session_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"""), _ => { entered = true; @@ -257,7 +258,7 @@ public async Task Cursor_never_builds_a_client_for_an_unusable_url() { public async Task Claude_never_builds_a_client_for_an_unusable_url() { var entered = false; - var exit = await new ClaudeHookCommand(Config.Root, Bad, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Bad, new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).HandleWithDeps( + var exit = await new ClaudeHookCommand(Config.Root, Bad, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Bad, new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).HandleWithDeps( new HookSpool(_dir), stdin: new StringReader($$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}"}"""), clientFactory: () => { diff --git a/test/Capacitor.Cli.Tests.Unit/CwdRepositoryTests.cs b/test/Capacitor.Cli.Tests.Unit/CwdRepositoryTests.cs index 700ed160d..7399e60ec 100644 --- a/test/Capacitor.Cli.Tests.Unit/CwdRepositoryTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/CwdRepositoryTests.cs @@ -30,7 +30,7 @@ static CommandRunner RecordingRunner(List commands, string? origin = "gi public async Task Construction_spawns_nothing() { var commands = new List(); - _ = new CwdRepository(Config.Root, Cwd.Path, RecordingRunner(commands)); + _ = new CwdRepository(Config.Root, Cwd.Path, new GitProviderRouter(), RecordingRunner(commands)); await Assert.That(commands).IsEmpty(); } @@ -38,7 +38,7 @@ public async Task Construction_spawns_nothing() { [Test] public async Task Hash_comes_from_origin_without_a_provider_probe() { var commands = new List(); - var repo = new CwdRepository(Config.Root, Cwd.Path, RecordingRunner(commands)); + var repo = new CwdRepository(Config.Root, Cwd.Path, new GitProviderRouter(), RecordingRunner(commands)); var hash = await repo.GetHashAsync(); @@ -50,7 +50,7 @@ public async Task Hash_comes_from_origin_without_a_provider_probe() { [Test] public async Task Resolution_runs_once_per_instance() { var commands = new List(); - var repo = new CwdRepository(Config.Root, Cwd.Path, RecordingRunner(commands)); + var repo = new CwdRepository(Config.Root, Cwd.Path, new GitProviderRouter(), RecordingRunner(commands)); await repo.GetHashAsync(); var spawned = commands.Count; @@ -64,7 +64,7 @@ public async Task Resolution_runs_once_per_instance() { [Test] public async Task Outside_a_checkout_there_is_no_repository_and_no_hash() { - var repo = new CwdRepository(Config.Root, Cwd.Path, (_, _, _, _) => Task.FromResult(null)); + var repo = new CwdRepository(Config.Root, Cwd.Path, new GitProviderRouter(), (_, _, _, _) => Task.FromResult(null)); await Assert.That(await repo.GetAsync()).IsNull(); await Assert.That(await repo.GetHashAsync()).IsNull(); @@ -73,7 +73,7 @@ public async Task Outside_a_checkout_there_is_no_repository_and_no_hash() { [Test] public async Task A_checkout_without_an_origin_remote_has_no_hash() { var commands = new List(); - var repo = new CwdRepository(Config.Root, Cwd.Path, RecordingRunner(commands, origin: null)); + var repo = new CwdRepository(Config.Root, Cwd.Path, new GitProviderRouter(), RecordingRunner(commands, origin: null)); await Assert.That(await repo.GetAsync()).IsNotNull(); await Assert.That(await repo.GetHashAsync()).IsNull(); diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Antigravity/AntigravitySessionStartMemoryTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Antigravity/AntigravitySessionStartMemoryTests.cs index 1b646efdf..9737773da 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Antigravity/AntigravitySessionStartMemoryTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Antigravity/AntigravitySessionStartMemoryTests.cs @@ -4,6 +4,7 @@ using Capacitor.Cli.Tests.Unit.SessionStartMemory; using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Harness.Antigravity; @@ -30,7 +31,7 @@ public class AntigravitySessionStartMemoryTests { // The server URL is the resolution's, so a test proving the url guard fires hands in the bad one // here rather than as an argument. AntigravityHookCommand Hook(string serverUrl = "https://example.test") => - new(Config.Root, Resolutions.At(serverUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(serverUrl, Config.Root), new FixedCapacitorHttpClient())); + new(Config.Root, Resolutions.At(serverUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(serverUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()); [TempConfigRoot] public required TempConfigRoot Config { get; init; } static string Write(string? fragment) { @@ -150,7 +151,7 @@ public async Task A_store_that_cannot_be_constructed_resolves_to_null_rather_tha [Test] public async Task A_non_PreInvocation_event_writes_nothing_and_exits_zero() { var sw = new StringWriter(); - var code = await new AntigravityHookCommand(Config.Root, Resolutions.At("https://example.test", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("https://example.test", Config.Root), new FixedCapacitorHttpClient())).Handle(["--antigravity", "Stop"], new StringReader("{}"), sw); + var code = await new AntigravityHookCommand(Config.Root, Resolutions.At("https://example.test", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("https://example.test", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["--antigravity", "Stop"], new StringReader("{}"), sw); await Assert.That(code).IsEqualTo(0); await Assert.That(sw.ToString()).IsEqualTo(""); @@ -159,7 +160,7 @@ public async Task A_non_PreInvocation_event_writes_nothing_and_exits_zero() { [Test] public async Task A_malformed_payload_writes_nothing_and_exits_zero() { var sw = new StringWriter(); - var code = await new AntigravityHookCommand(Config.Root, Resolutions.At("https://example.test", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("https://example.test", Config.Root), new FixedCapacitorHttpClient())).Handle(["--antigravity", "PreInvocation"], new StringReader("{not json"), sw); + var code = await new AntigravityHookCommand(Config.Root, Resolutions.At("https://example.test", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("https://example.test", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["--antigravity", "PreInvocation"], new StringReader("{not json"), sw); await Assert.That(code).IsEqualTo(0); await Assert.That(sw.ToString()).IsEqualTo(""); @@ -235,7 +236,7 @@ public async Task HandleCore_consults_the_fallback_for_an_empty_workspacePaths_p var consulted = false; var sw = new StringWriter(); - var code = await new AntigravityHookCommand(Config.Root, Resolutions.At("https://example.test", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("https://example.test", Config.Root), new FixedCapacitorHttpClient())).Handle(["--antigravity", "PreInvocation"], + var code = await new AntigravityHookCommand(Config.Root, Resolutions.At("https://example.test", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("https://example.test", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["--antigravity", "PreInvocation"], new StringReader($$""" {"conversationId":"{{conversationId}}","transcriptPath":"/tmp/t.jsonl","workspacePaths":[]} """), diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeHookExclusionGateTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeHookExclusionGateTests.cs index f4f9739cb..ea8bfe4c6 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeHookExclusionGateTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeHookExclusionGateTests.cs @@ -3,6 +3,7 @@ using Capacitor.Cli.Commands.Harness; using Capacitor.Cli.Core.Config; using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Harness.Claude; @@ -25,7 +26,7 @@ ClaudeHookCommand Hook() => new(Config.Root, Resolutions.None(Config.Root), _clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), - SystemProcessStarter.Instance); + SystemProcessStarter.Instance, router: new GitProviderRouter()); // The gate reads the budget only for the repo probe, which these path-exclusion payloads never // reach; what they vary is the profile, not the clock. Any live ceiling will do. diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeImportSourceTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeImportSourceTests.cs index d15ee6cc9..aca6265b4 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeImportSourceTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeImportSourceTests.cs @@ -3,6 +3,7 @@ using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core.Harness.Claude; using Capacitor.Cli.Harness.Claude; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Harness.Claude; @@ -13,33 +14,33 @@ public class ClaudeImportSourceTests { [Test] public async Task vendor_is_claude() { - var src = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects); + var src = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects, router: new GitProviderRouter()); await Assert.That(src.Vendor).IsEqualTo(HarnessId.Claude); } [Test] public async Task supports_title_generation() { - var src = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects); + var src = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects, router: new GitProviderRouter()); await Assert.That(src.SupportsTitleGeneration).IsTrue(); } [Test] public async Task is_available_when_projects_dir_exists() { using var tmp = new TempDir(); - var src = new ClaudeImportSource(Config.Root, tmp.Path); + var src = new ClaudeImportSource(Config.Root, tmp.Path, router: new GitProviderRouter()); await Assert.That(src.IsAvailable).IsTrue(); } [Test] public async Task is_unavailable_when_projects_dir_missing() { using var missingDir = TempDir.WithPathTo("kcap-claude-source-missing", out var missing); - var src = new ClaudeImportSource(Config.Root, missing); + var src = new ClaudeImportSource(Config.Root, missing, router: new GitProviderRouter()); await Assert.That(src.IsAvailable).IsFalse(); } [Test] public async Task import_session_async_throws_not_implemented() { - var src = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects); + var src = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects, router: new GitProviderRouter()); var classification = new ImportCommand.SessionClassification { SessionId = "abc", FilePath = "/tmp/none", diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexImportSourceTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexImportSourceTests.cs index c570e5df2..44145a3d6 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexImportSourceTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexImportSourceTests.cs @@ -3,6 +3,7 @@ using Capacitor.Cli.Core.Harness.Codex; using Capacitor.Cli.Core; using Capacitor.Cli.Harness.Codex; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Harness.Codex; @@ -13,33 +14,33 @@ public class CodexImportSourceTests { [Test] public async Task vendor_is_codex() { - var src = new CodexImportSource(Config.Root, CodexHarness.FromEnvironment(Home).Paths.Sessions); + var src = new CodexImportSource(Config.Root, CodexHarness.FromEnvironment(Home).Paths.Sessions, router: new GitProviderRouter()); await Assert.That(src.Vendor).IsEqualTo(HarnessId.Codex); } [Test] public async Task supports_title_generation() { - var src = new CodexImportSource(Config.Root, CodexHarness.FromEnvironment(Home).Paths.Sessions); + var src = new CodexImportSource(Config.Root, CodexHarness.FromEnvironment(Home).Paths.Sessions, router: new GitProviderRouter()); await Assert.That(src.SupportsTitleGeneration).IsTrue(); } [Test] public async Task is_available_when_sessions_dir_exists() { using var tmp = new TempDir(); - var src = new CodexImportSource(Config.Root, tmp.Path); + var src = new CodexImportSource(Config.Root, tmp.Path, router: new GitProviderRouter()); await Assert.That(src.IsAvailable).IsTrue(); } [Test] public async Task is_unavailable_when_sessions_dir_missing() { using var missingDir = TempDir.WithPathTo("kcap-codex-source-missing", out var missing); - var src = new CodexImportSource(Config.Root, missing); + var src = new CodexImportSource(Config.Root, missing, router: new GitProviderRouter()); await Assert.That(src.IsAvailable).IsFalse(); } [Test] public async Task import_session_async_throws_not_implemented() { - var src = new CodexImportSource(Config.Root, CodexHarness.FromEnvironment(Home).Paths.Sessions); + var src = new CodexImportSource(Config.Root, CodexHarness.FromEnvironment(Home).Paths.Sessions, router: new GitProviderRouter()); var classification = new ImportCommand.SessionClassification { SessionId = "abc", FilePath = "/tmp/none", diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexImportTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexImportTests.cs index 5a0c84cfa..504a502a5 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexImportTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexImportTests.cs @@ -6,6 +6,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Harness.Codex; @@ -239,6 +240,7 @@ await File.WriteAllLinesAsync(path, [ using var client = new HttpClient(); var result = await TranscriptFileClassification.ClassifyAsync( + new GitProviderRouter(), Config.Root, Home, client, @@ -278,6 +280,7 @@ await File.WriteAllLinesAsync(path, [ using var client = new HttpClient(); var result = await TranscriptFileClassification.ClassifyAsync( + new GitProviderRouter(), Config.Root, Home, client, diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexSubagentDiscoveryTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexSubagentDiscoveryTests.cs index e92521869..c87c3a300 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexSubagentDiscoveryTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexSubagentDiscoveryTests.cs @@ -1,6 +1,7 @@ using Capacitor.Cli.Commands; using Capacitor.Cli.Core.Harness.Codex; using Capacitor.Cli.Harness.Codex; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Harness.Codex; @@ -264,7 +265,7 @@ public async Task ImportDiscovery_ExcludesSubagentAndIndeterminate_KeepsTopLevel Path.Combine(day, $"rollout-2026-08-10T17-24-19-{malformedDashed}.jsonl"), "not json at all\n"); - var source = new CodexImportSource(Config.Root, tmp.Path); + var source = new CodexImportSource(Config.Root, tmp.Path, router: new GitProviderRouter()); var discovered = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None); var ids = discovered.Select(d => d.SessionId).OrderBy(x => x, StringComparer.Ordinal).ToList(); diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Copilot/CopilotImportSourceTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Copilot/CopilotImportSourceTests.cs index 5fb37527e..57b70800c 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Copilot/CopilotImportSourceTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Copilot/CopilotImportSourceTests.cs @@ -4,6 +4,7 @@ using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core.Harness.Copilot; using Capacitor.Cli.Harness.Copilot; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Harness.Copilot; @@ -34,7 +35,7 @@ public async Task discovery_skips_scaffolding_dirs_without_events_jsonl() { await File.WriteAllTextAsync( Path.Combine(paths.SessionStateDir, Sid2, "workspace.yaml"), $"id: {Sid2}\ncwd: /work/b\n"); - var source = new CopilotImportSource(Config.Root, paths); + var source = new CopilotImportSource(Config.Root, paths, router: new GitProviderRouter()); var sessions = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None); await Assert.That(sessions.Count).IsEqualTo(1); @@ -52,7 +53,7 @@ public async Task discovery_reads_workspace_yaml_metadata() { name: "Create a file hello.txt containing 'hello world'", createdAt: "2026-06-10T20:23:25.556Z"); - var source = new CopilotImportSource(Config.Root, paths); + var source = new CopilotImportSource(Config.Root, paths, router: new GitProviderRouter()); var sessions = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None); await Assert.That(sessions.Count).IsEqualTo(1); @@ -70,7 +71,7 @@ public async Task discovery_prefers_current_root_over_legacy_for_same_session() WriteSession(paths.LegacySessionStateDir, Sid1, cwd: "/work/legacy"); WriteSession(paths.LegacySessionStateDir, Sid2, cwd: "/work/legacy-only"); - var source = new CopilotImportSource(Config.Root, paths); + var source = new CopilotImportSource(Config.Root, paths, router: new GitProviderRouter()); var sessions = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None); await Assert.That(sessions.Count).IsEqualTo(2); @@ -87,7 +88,7 @@ public async Task session_filter_matches_dashless_and_dashed_input() { WriteSession(paths.SessionStateDir, Sid1, cwd: "/work/a"); WriteSession(paths.SessionStateDir, Sid2, cwd: "/work/b"); - var source = new CopilotImportSource(Config.Root, paths); + var source = new CopilotImportSource(Config.Root, paths, router: new GitProviderRouter()); var byDashed = await source.DiscoverAsync(new DiscoveryFilters(null, Sid1, null, 0), CancellationToken.None); await Assert.That(byDashed.Count).IsEqualTo(1); @@ -105,7 +106,7 @@ public async Task cwd_filter_excludes_other_workspaces_and_sessions_without_cwd( WriteSession(paths.SessionStateDir, Sid1, cwd: "/work/a"); WriteSession(paths.SessionStateDir, Sid2, cwd: null); // no workspace.yaml cwd - var source = new CopilotImportSource(Config.Root, paths); + var source = new CopilotImportSource(Config.Root, paths, router: new GitProviderRouter()); var matched = await source.DiscoverAsync(new DiscoveryFilters("/work/a", null, null, 0), CancellationToken.None); await Assert.That(matched.Count).IsEqualTo(1); @@ -119,7 +120,7 @@ public async Task since_filter_gates_on_session_start() { WriteSession(paths.SessionStateDir, Sid1, cwd: "/work/a", createdAt: "2026-06-01T10:00:00Z"); WriteSession(paths.SessionStateDir, Sid2, cwd: "/work/b", createdAt: "2026-06-09T10:00:00Z"); - var source = new CopilotImportSource(Config.Root, paths); + var source = new CopilotImportSource(Config.Root, paths, router: new GitProviderRouter()); var matched = await source.DiscoverAsync( new DiscoveryFilters(null, null, new DateOnly(2026, 6, 5), 0), CancellationToken.None); diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorGuardWiringTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorGuardWiringTests.cs index d6f4aa71a..1e5f8edec 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorGuardWiringTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorGuardWiringTests.cs @@ -3,6 +3,7 @@ using Capacitor.Cli.Core.Harness.Cursor; using Capacitor.Cli.Harness.Cursor; using Microsoft.AspNetCore.SignalR.Client; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Harness.Cursor; @@ -19,7 +20,7 @@ public class CursorGuardWiringTests { [TempHome] public required TempHome Home { get; init; } WatchCommand? _watch; - WatchCommand Watch => _watch ??= new(Config.Root, Resolutions.None(Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient())); + WatchCommand Watch => _watch ??= new(Config.Root, Resolutions.None(Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), new GitProviderRouter()); CursorMarkers Markers => new(Config.Root); diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorImportSourceTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorImportSourceTests.cs index 4ec375e26..b9252cbd5 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorImportSourceTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorImportSourceTests.cs @@ -7,6 +7,7 @@ using Capacitor.Cli.Core.Harness.Cursor; using Capacitor.Cli.Harness.Cursor; using TUnit.Core.Enums; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Harness.Cursor; @@ -29,14 +30,14 @@ public class CursorImportSourceTests { [Test] public async Task vendor_is_cursor() { using var fx = new ProjectsDirFixture(); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); await Assert.That(src.Vendor).IsEqualTo(HarnessId.Cursor); } [Test] public async Task does_not_support_title_generation() { using var fx = new ProjectsDirFixture(); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); await Assert.That(src.SupportsTitleGeneration).IsFalse(); } @@ -64,14 +65,14 @@ public async Task session_start_payload_carries_pr_fields_when_repo_has_a_pr() { [Test] public async Task is_available_when_projects_dir_exists() { using var fx = new ProjectsDirFixture(); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); await Assert.That(src.IsAvailable).IsTrue(); } [Test] public async Task is_unavailable_when_projects_dir_missing() { using var missingDir = TempDir.WithPathTo("kcap-cursor-missing", out var missing); - var src = new CursorImportSource(Config.Root, missing, missing); + var src = new CursorImportSource(Config.Root, missing, missing, router: new GitProviderRouter()); await Assert.That(src.IsAvailable).IsFalse(); } @@ -90,7 +91,7 @@ await Assert.That(CursorImportSource.EncodeWorkspacePath("/Users/me/dev/foo-bar" [Test] public async Task discover_returns_empty_when_projects_dir_missing() { using var missingDir = TempDir.WithPathTo("kcap-cursor-missing", out var missing); - var src = new CursorImportSource(Config.Root, missing, missing); + var src = new CursorImportSource(Config.Root, missing, missing, router: new GitProviderRouter()); var result = await src.DiscoverAsync(Filters(), CancellationToken.None); await Assert.That(result.Count).IsEqualTo(0); } @@ -101,7 +102,7 @@ public async Task discover_walks_jsonl_files() { fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{\"x\":1}\n"); fx.AddSession("Users-me-proj", "22222222-2222-2222-2222-222222222222", "{\"x\":2}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var got = await src.DiscoverAsync(Filters(), CancellationToken.None); await Assert.That(got.Count).IsEqualTo(2); @@ -116,7 +117,7 @@ public async Task discover_resolves_cwd_via_workspace_storage_when_sanitized_mat fx.AddWorkspaceJson("hash-aaa", "file:///Users/me/dev/foo"); fx.AddSession("Users-me-dev-foo", "33333333-3333-3333-3333-333333333333", "{}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var got = await src.DiscoverAsync(Filters(), CancellationToken.None); await Assert.That(got.Count).IsEqualTo(1); @@ -128,7 +129,7 @@ public async Task discover_leaves_cwd_null_when_sanitized_not_in_workspace_stora using var fx = new ProjectsDirFixture(); fx.AddSession("Users-someone-else-proj", "44444444-4444-4444-4444-444444444444", "{}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var got = await src.DiscoverAsync(Filters(), CancellationToken.None); await Assert.That(got.Count).IsEqualTo(1); @@ -141,7 +142,7 @@ public async Task discover_applies_session_filter_dashless() { fx.AddSession("Users-me-proj", "55555555-5555-5555-5555-555555555555", "{}\n"); fx.AddSession("Users-me-proj", "66666666-6666-6666-6666-666666666666", "{}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); // Pass the dashed form; the filter must normalize to dashless before matching. var got = await src.DiscoverAsync(Filters(filterSession: "55555555-5555-5555-5555-555555555555"), CancellationToken.None); @@ -157,7 +158,7 @@ public async Task discover_applies_cwd_filter_against_resolved_workspace_folder( fx.AddSession("Users-me-dev-match", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "{}\n"); fx.AddSession("Users-me-dev-other", "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "{}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var got = await src.DiscoverAsync(Filters(filterCwd: "/Users/me/dev/match"), CancellationToken.None); await Assert.That(got.Count).IsEqualTo(1); @@ -173,7 +174,7 @@ public async Task classify_marks_new_when_server_has_no_state() { "11111111-1111-1111-1111-111111111111", "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n" ); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); using var handler = new StubHandler( getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound) @@ -203,7 +204,7 @@ public async Task classify_keeps_file_path_empty_so_orchestrator_routes_to_Impor using var fx = new ProjectsDirFixture(); fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{}\n{}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound)); using var client = new HttpClient(handler); @@ -227,7 +228,7 @@ public async Task classify_marks_already_loaded_when_server_at_or_past_last_non_ "11111111-1111-1111-1111-111111111111", "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n" ); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); // Three non-blank lines at indexes 0,1,2 → last_line_number=2 means fully loaded. using var handler = new StubHandler( @@ -255,7 +256,7 @@ public async Task classify_marks_partial_with_resume_from_when_server_mid_file() "11111111-1111-1111-1111-111111111111", "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n" ); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); using var handler = new StubHandler( getResponse: _ => new HttpResponseMessage(HttpStatusCode.OK) { @@ -278,7 +279,7 @@ await src.DiscoverAsync(Filters(), CancellationToken.None), public async Task classify_marks_too_short_below_min_lines() { using var fx = new ProjectsDirFixture(); fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{\"a\":1}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound)); using var client = new HttpClient(handler); @@ -301,7 +302,7 @@ public async Task classify_returns_probe_error_when_watermark_returns_5xx() { "11111111-1111-1111-1111-111111111111", "{\"a\":1}\n{\"b\":2}\n" ); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.InternalServerError)); using var client = new HttpClient(handler); @@ -330,7 +331,7 @@ public async Task classify_skips_a_quarantined_standalone_session() { fx.AddSession("Users-me-proj", sessionIdWithDashes, "{\"a\":1}\n{\"b\":2}\n"); Markers.Quarantine(sessionId, "transcript rewrite detected"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound)); using var client = new HttpClient(handler); @@ -354,7 +355,7 @@ public async Task classify_does_not_skip_a_non_quarantined_session() { // test's quarantine marker for that id (see classify_skips_a_quarantined_standalone_session). fx.AddSession("Users-me-proj", Guid.NewGuid().ToString(), "{\"a\":1}\n{\"b\":2}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound)); using var client = new HttpClient(handler); @@ -417,7 +418,7 @@ public async Task import_session_posts_lifecycle_then_transcript_then_session_en "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n" ); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var posted = new List<(string Path, string Body)>(); @@ -490,7 +491,7 @@ public async Task import_session_populates_started_at_and_ended_at_from_file_tim File.SetCreationTimeUtc(jsonl, created); File.SetLastWriteTimeUtc(jsonl, modified); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var posted = new List<(string Path, string Body)>(); @@ -534,7 +535,7 @@ public async Task import_session_returns_failed_when_session_start_post_fails() using var fx = new ProjectsDirFixture(); var jsonl = fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var posted = new List(); @@ -575,7 +576,7 @@ public async Task import_session_returns_failed_when_session_end_post_fails() { using var fx = new ProjectsDirFixture(); var jsonl = fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); using var handler = new StubHandler( postCapture: (req, _) => req.RequestUri!.AbsolutePath == "/hooks/session-end/cursor" @@ -616,7 +617,7 @@ public async Task import_session_emits_lifecycle_only_for_already_loaded_status( "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n" ); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var posted = new List(); @@ -665,7 +666,7 @@ public async Task already_loaded_parent_reports_sent_child_content_when_attachin var parentJsonl = fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n"); var childJsonl = fx.AddSession("Users-me-proj", "22222222-2222-2222-2222-222222222222", "{\"x\":1}\n{\"y\":2}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); using var handler = new StubHandler( getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound), // child subsession watermark: nothing sent yet @@ -709,7 +710,7 @@ public async Task already_loaded_parent_with_already_loaded_child_reports_no_sen var parentJsonl = fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n"); var childJsonl = fx.AddSession("Users-me-proj", "22222222-2222-2222-2222-222222222222", "{\"x\":1}\n{\"y\":2}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); using var handler = new StubHandler( // Subsession watermark already covers both child lines (0-indexed last_line_number=1). @@ -766,7 +767,7 @@ public async Task already_loaded_parent_with_failing_child_watermark_probe_conse var parentJsonl = fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n"); var childJsonl = fx.AddSession("Users-me-proj", "22222222-2222-2222-2222-222222222222", "{\"x\":1}\n{\"y\":2}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var posted = new List(); @@ -822,7 +823,7 @@ public async Task already_loaded_parent_with_known_child_watermark_and_lines_bey // ingested), so lines 1 and 2 are genuinely new. var childJsonl = fx.AddSession("Users-me-proj", "22222222-2222-2222-2222-222222222222", "{\"x\":1}\n{\"y\":2}\n{\"z\":3}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); using var handler = new StubHandler( getResponse: _ => new HttpResponseMessage(HttpStatusCode.OK) { @@ -869,7 +870,7 @@ public async Task cursor_watermark_probe_retries_once_on_transient_500_then_succ // are genuinely new. var childJsonl = fx.AddSession("Users-me-proj", "22222222-2222-2222-2222-222222222222", "{\"x\":1}\n{\"y\":2}\n{\"z\":3}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var getCalls = 0; var posted = new List<(string Path, string Body)>(); @@ -933,7 +934,7 @@ public async Task cursor_watermark_probe_falls_open_after_exactly_2_attempts_on_ var parentJsonl = fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n"); var childJsonl = fx.AddSession("Users-me-proj", "22222222-2222-2222-2222-222222222222", "{\"x\":1}\n{\"y\":2}\n{\"z\":3}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var getCalls = 0; var posted = new List<(string Path, string Body)>(); @@ -992,7 +993,7 @@ public async Task cursor_watermark_probe_does_not_retry_a_404() { var parentJsonl = fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n"); var childJsonl = fx.AddSession("Users-me-proj", "22222222-2222-2222-2222-222222222222", "{\"x\":1}\n{\"y\":2}\n{\"z\":3}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var getCalls = 0; @@ -1039,7 +1040,7 @@ public async Task cursor_watermark_probe_does_not_retry_a_429() { var parentJsonl = fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n"); var childJsonl = fx.AddSession("Users-me-proj", "22222222-2222-2222-2222-222222222222", "{\"x\":1}\n{\"y\":2}\n{\"z\":3}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var getCalls = 0; @@ -1179,7 +1180,7 @@ public async Task import_session_attaches_repository_from_detected_workspace_rep RemoteUrl = "git@github.com:kurrent-io/kcap-server.git", } ) - ); + , router: new GitProviderRouter()); var posted = new List<(string Path, string Body)>(); using var handler = new StubHandler( @@ -1224,7 +1225,7 @@ public async Task import_session_omits_repository_when_workspace_repo_undetected fx.ProjectsDir, fx.WorkspaceStorageDir, repoDetector: _ => Task.FromResult(null) - ); + , router: new GitProviderRouter()); var posted = new List<(string Path, string Body)>(); using var handler = new StubHandler( @@ -1269,7 +1270,7 @@ public async Task import_session_omits_repository_when_workspace_repo_undetected "{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":" + childUserText + "}]}}\n" + "{\"role\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]}}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); using var getHandler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound)); using var getClient = new HttpClient(getHandler); var discovered = await src.DiscoverAsync(Filters(), CancellationToken.None); @@ -1407,7 +1408,7 @@ public async Task import_session_posts_nothing_when_quarantined_between_classify Markers.Quarantine(sessionId, "transcript rewrite detected"); try { - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var posted = new List(); using var handler = new StubHandler( @@ -1445,7 +1446,7 @@ public async Task import_session_proceeds_normally_when_quarantine_identity_is_s using var fx = new ProjectsDirFixture(); var jsonl = fx.AddSession( "Users-me-proj", "11111111-1111-1111-1111-111111111111", "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var sessionId = "11111111111111111111111111111111"; var posted = new List(); @@ -1487,7 +1488,7 @@ public async Task import_session_aborts_transcript_but_still_closes_the_session_ var sessionIdWithDashes = Guid.NewGuid().ToString(); var sessionId = CursorImportSource.NormalizeCursorSessionId(sessionIdWithDashes); var jsonl = fx.AddSession("Users-me-proj", sessionIdWithDashes, "{\"a\":1}\n{\"b\":2}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var posted = new List(); using var handler = new StubHandler(postCapture: (req, _) => { @@ -1537,7 +1538,7 @@ public async Task import_session_aborts_the_remaining_batch_when_quarantine_is_w var sessionId = CursorImportSource.NormalizeCursorSessionId(sessionIdWithDashes); var lines = string.Concat(Enumerable.Range(0, 150).Select(i => $$"""{"n":{{i}}}""" + "\n")); var jsonl = fx.AddSession("Users-me-proj", sessionIdWithDashes, lines); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var posted = new List(); var transcriptPosts = 0; @@ -1597,7 +1598,7 @@ public async Task import_session_aborts_when_quarantine_is_written_during_the_on var sessionId = CursorImportSource.NormalizeCursorSessionId(sessionIdWithDashes); var lines = string.Concat(Enumerable.Range(0, 30).Select(i => $$"""{"n":{{i}}}""" + "\n")); var jsonl = fx.AddSession("Users-me-proj", sessionIdWithDashes, lines); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var posted = new List(); var transcriptPosts = 0; @@ -1658,7 +1659,7 @@ public async Task import_session_closes_and_fails_when_a_child_transcript_quaran var parentJsonl = fx.AddSession("Users-me-proj", parentIdWithDashes, "{\"a\":1}\n"); var childJsonl = fx.AddSession("Users-me-proj", childIdWithDashes, "{\"b\":1}\n{\"c\":2}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var posted = new List(); using var handler = new StubHandler( @@ -1728,7 +1729,7 @@ public async Task import_session_never_starts_a_later_child_once_the_family_is_f var childAJsonl = fx.AddSession("Users-me-proj", childAIdWithDashes, "{\"b\":1}\n"); var childBJsonl = fx.AddSession("Users-me-proj", childBIdWithDashes, "{\"c\":1}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var bodies = new List<(string Path, string Body)>(); using var handler = new StubHandler( @@ -1803,7 +1804,7 @@ public async Task classify_resolves_parent_quarantine_via_the_persisted_live_mar Markers.Quarantine(parentId, "transcript rewrite detected"); try { - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound)); using var client = new HttpClient(handler); @@ -1861,7 +1862,7 @@ public async Task cross_workspace_prompt_match_does_not_link_child_to_parent_in_ "{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":" + childUserText + "}]}}\n" + "{\"role\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]}}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound)); using var client = new HttpClient(handler); @@ -1887,7 +1888,7 @@ public async Task classify_throws_promptly_when_cancelled_during_same_workspace_ fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{}\n"); fx.AddSession("Users-me-proj", "22222222-2222-2222-2222-222222222222", "{}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound)); using var client = new HttpClient(handler); @@ -1906,7 +1907,7 @@ public async Task import_session_omits_workspace_roots_when_cwd_unresolved() { using var fx = new ProjectsDirFixture(); var jsonl = fx.AddSession("unknown-workspace", "11111111-1111-1111-1111-111111111111", "{}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var posted = new List<(string Path, string Body)>(); @@ -1948,7 +1949,7 @@ public async Task import_session_resumes_from_resume_line_for_partial_status() { "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n" ); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var posted = new List<(string Path, string Body)>(); @@ -2004,7 +2005,7 @@ public async Task classify_sets_excluded_repo_key_when_workspace_repo_matches_ex repoDetector: _ => Task.FromResult( new RepositoryPayload { Owner = "acme", RepoName = "secret" } ) - ); + , router: new GitProviderRouter()); using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound)); using var client = new HttpClient(handler); @@ -2041,7 +2042,7 @@ public async Task classify_does_not_invoke_repo_detection_when_excluded_repos_em return Task.FromResult(null); } - ); + , router: new GitProviderRouter()); using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound)); using var client = new HttpClient(handler); @@ -2073,7 +2074,7 @@ public async Task classify_caches_repo_detection_per_workspace_across_sessions() return Task.FromResult(new RepositoryPayload { Owner = "o", RepoName = "r" }); } - ); + , router: new GitProviderRouter()); using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound)); using var client = new HttpClient(handler); @@ -2101,7 +2102,7 @@ public async Task discover_cwd_filter_matches_case_insensitively_on_macos_and_wi fx.AddWorkspaceJson("hash-aaa", "file:///Users/me/dev/MyProj"); fx.AddSession("Users-me-dev-MyProj", "11111111-1111-1111-1111-111111111111", "{}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); // Caller passes a lower-cased cwd, e.g. from a shell tab-completion. var got = await src.DiscoverAsync(Filters(filterCwd: "/users/me/dev/myproj"), CancellationToken.None); @@ -2127,7 +2128,7 @@ public async Task discover_since_filter_uses_file_creation_time_not_last_write() File.SetCreationTimeUtc(jsonl, thirtyDaysAgo); File.SetLastWriteTimeUtc(jsonl, DateTime.UtcNow); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var since = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-7)); var got = await src.DiscoverAsync(Filters(since: since), CancellationToken.None); @@ -2144,7 +2145,7 @@ public async Task discover_marks_cwd_null_when_two_workspaces_encode_to_the_same fx.AddWorkspaceJson("hash-b", "file:///foo-bar"); fx.AddSession("foo-bar", "11111111-1111-1111-1111-111111111111", "{}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); var got = await src.DiscoverAsync(Filters(), CancellationToken.None); await Assert.That(got.Count).IsEqualTo(1); diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorLiveSubagentIntegrationTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorLiveSubagentIntegrationTests.cs index e963d12d6..41a01b79b 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorLiveSubagentIntegrationTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorLiveSubagentIntegrationTests.cs @@ -5,6 +5,7 @@ using Capacitor.Cli.Core; using Capacitor.Cli.Core.Harness.Cursor; using Capacitor.Cli.Harness.Cursor; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Harness.Cursor; @@ -173,7 +174,7 @@ public Fixture(ConfigRoot config, HttpStatusCode postStatus = HttpStatusCode.OK) } public Task HandleAsync(string sessionId, string eventName, string? transcriptPath, string extraFields = "") => - new CursorHookCommand(Config, Resolutions.At("http://localhost", Config), new HookClock(TimeProvider.System), _home, TestHarnesses.Under(_home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config, Resolutions.At("http://localhost", Config), new FixedCapacitorHttpClient())).HandleCore( + new CursorHookCommand(Config, Resolutions.At("http://localhost", Config), new HookClock(TimeProvider.System), _home, TestHarnesses.Under(_home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config, Resolutions.At("http://localhost", Config), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).HandleCore( Client, stdin: new StringReader( $$"""{"hook_event_name":"{{eventName}}","session_id":"{{sessionId}}","transcript_path":"{{transcriptPath?.Replace(@"\", @"\\")}}"{{extraFields}}}""" diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorOrphanedChildStandaloneTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorOrphanedChildStandaloneTests.cs index b5a73d6d3..24fc5c57f 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorOrphanedChildStandaloneTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorOrphanedChildStandaloneTests.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Capacitor.Cli.Commands; using Capacitor.Cli.Harness.Cursor; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Harness.Cursor; @@ -43,7 +44,7 @@ public async Task classify_correlates_child_to_parent_even_when_parent_is_outsid using var fx = new ProjectsDirFixture(); WriteParentAndChild(fx); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound)); using var client = new HttpClient(handler); @@ -81,7 +82,7 @@ public async Task classify_does_not_correlate_when_parent_and_child_are_in_diffe "{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":" + childUserText + "}]}}\n" + "{\"role\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]}}\n"); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound)); using var client = new HttpClient(handler); @@ -101,7 +102,7 @@ public async Task orphaned_child_imports_standalone_when_parent_is_not_in_the_ro using var fx = new ProjectsDirFixture(); WriteParentAndChild(fx); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); using var getHandler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound)); using var getClient = new HttpClient(getHandler); @@ -153,7 +154,7 @@ public async Task reconciliation_prunes_a_child_excluded_from_the_routed_plan_of using var fx = new ProjectsDirFixture(); WriteParentAndChild(fx); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); using var getHandler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound)); using var getClient = new HttpClient(getHandler); @@ -206,7 +207,7 @@ public async Task reconciliation_leaves_nested_child_untouched_when_parent_is_in using var fx = new ProjectsDirFixture(); WriteParentAndChild(fx); - var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir); + var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter()); using var getHandler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound)); using var getClient = new HttpClient(getHandler); diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorReconnectRewindTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorReconnectRewindTests.cs index eac96fbaf..4f41ac25e 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorReconnectRewindTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorReconnectRewindTests.cs @@ -3,6 +3,7 @@ using Capacitor.Cli.Core.Harness.Cursor; using Capacitor.Cli.Harness.Cursor; using Microsoft.AspNetCore.SignalR.Client; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Harness.Cursor; @@ -20,7 +21,7 @@ namespace Capacitor.Cli.Tests.Unit.Harness.Cursor; public class CursorReconnectRewindTests { [TempHome] public required TempHome Home { get; init; } - WatchCommand Watch => field ??= new(Config.Root, Resolutions.None(Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient())); + WatchCommand Watch => field ??= new(Config.Root, Resolutions.None(Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), new GitProviderRouter()); CursorMarkers Markers => new(Config.Root); diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorSubagentStaleStateTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorSubagentStaleStateTests.cs index 85dcad3c5..d1450b139 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorSubagentStaleStateTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorSubagentStaleStateTests.cs @@ -4,6 +4,7 @@ using Capacitor.Cli.Core; using Capacitor.Cli.Core.Harness.Cursor; using Capacitor.Cli.Harness.Cursor; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Harness.Cursor; @@ -102,7 +103,7 @@ public async Task A_marker_without_an_ack_suppresses_the_raw_event_and_the_trans using var client = new HttpClient(handler); var spool = new HookSpool(tmp.PathTo("spool")); - await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore( + await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore( client, new StringReader($$"""{"hook_event_name":"afterAgentThought","session_id":"{{child}}","generation_id":"g","text":"t","transcript_path":"{{childFile.Replace(@"\", @"\\")}}"}"""), spool); @@ -162,7 +163,7 @@ public async Task Successful_start_with_a_failed_marker_write_leaves_ack_and_wat // posted), so a test that bypassed it by calling HandleSubagentChildEventAsync would // keep passing after the remedy landed — defeating the whole point of a // characterization test. - await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore( + await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore( client, new StringReader($$"""{"hook_event_name":"sessionStart","session_id":"{{child}}","transcript_path":"{{childPath.Replace(@"\", @"\\")}}"}"""), spool); @@ -209,7 +210,7 @@ public async Task Spooled_start_with_a_failed_marker_write_dual_routes_on_the_ne var spool = new HookSpool(tmp.PathTo("spool")); // Again through the REAL CALLER — see the note in the test above. - await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore( + await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore( client, new StringReader($$"""{"hook_event_name":"sessionStart","session_id":"{{child}}","transcript_path":"{{childPath.Replace(@"\", @"\\")}}"}"""), spool); @@ -223,7 +224,7 @@ public async Task Spooled_start_with_a_failed_marker_write_dual_routes_on_the_ne // ordinary top-level route. THE FINDING: two watchers now tail the SAME transcript, // one under the parent and one as the child's own session. routes.Clear(); - await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore( + await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore( client, new StringReader($$"""{"hook_event_name":"afterAgentResponse","session_id":"{{child}}","transcript_path":"{{childPath.Replace(@"\", @"\\")}}"}"""), spool); diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorTopLevelStreamingTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorTopLevelStreamingTests.cs index 2c18f35a0..de4040370 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorTopLevelStreamingTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorTopLevelStreamingTests.cs @@ -2,6 +2,7 @@ using Capacitor.Cli.Core; using Capacitor.Cli.Harness.Cursor; using Microsoft.AspNetCore.SignalR.Client; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Harness.Cursor; @@ -18,7 +19,7 @@ namespace Capacitor.Cli.Tests.Unit.Harness.Cursor; public class CursorTopLevelStreamingTests { [TempHome] public required TempHome Home { get; init; } - WatchCommand Watch => field ??= new(Config.Root, Resolutions.None(Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient())); + WatchCommand Watch => field ??= new(Config.Root, Resolutions.None(Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), new GitProviderRouter()); [TempConfigRoot] public required TempConfigRoot Config { get; init; } diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorWatcherSpawnTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorWatcherSpawnTests.cs index 8c6c66912..be4ba5f4c 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorWatcherSpawnTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorWatcherSpawnTests.cs @@ -4,6 +4,7 @@ using Capacitor.Cli.Core; using Capacitor.Cli.Core.Harness.Cursor; using Capacitor.Cli.Harness.Cursor; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Harness.Cursor; @@ -25,7 +26,7 @@ public class CursorWatcherSpawnTests { CursorHookCommand Hook(ConfigRoot root, IWatcherSpawner spawner) => new(root, Resolutions.At("http://s", root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), - TestWatchers.For(root, Resolutions.At("http://s", root), new FixedCapacitorHttpClient(), spawner)); + TestWatchers.For(root, Resolutions.At("http://s", root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()); [Test] public async Task SessionEnd_never_spawns() => @@ -168,7 +169,7 @@ public async Task Deferred_spool_drain_delivering_a_spooled_subagent_start_spawn // stays queued. Asserting "no spawn" only means something after a real drain attempt: // straight after Append no production code has run, so the assertion could not fail. startFails = true; - await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore( + await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore( client, new StringReader($$"""{"hook_event_name":"postToolUse","session_id":"{{child}}","tool_name":"Bash"}"""), spool); @@ -181,7 +182,7 @@ public async Task Deferred_spool_drain_delivering_a_spooled_subagent_start_spawn // (before the isSubagentChild divert even runs), and that success is what must // trigger the deferred spawn. startFails = false; - await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore( + await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore( client, new StringReader($$"""{"hook_event_name":"postToolUse","session_id":"{{child}}","tool_name":"Bash"}"""), spool); @@ -240,14 +241,14 @@ public async Task Permanently_dropped_subagent_start_gates_all_child_transcript_ // 2nd invocation: any later hook for this child. HandleCore's generic top-of-method // spool drain retries the spooled subagent-start FIRST — this time it 400s, which // HookSpool treats as a permanent Drop (the entry is discarded, not re-queued). - await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore( + await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore( client, new StringReader($$"""{"hook_event_name":"afterAgentThought","session_id":"{{child}}","generation_id":"g","text":"t","transcript_path":"{{childFileEscaped}}"}"""), spool); // 3rd invocation: another content-less hook. An emptied backlog is not an // acknowledgement, so no agent-routed backfill may run while the marker is absent. - await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore( + await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore( client, new StringReader($$"""{"hook_event_name":"postToolUse","session_id":"{{child}}","tool_name":"Bash","transcript_path":"{{childFileEscaped}}"}"""), spool); diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiHookOutputContractTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiHookOutputContractTests.cs index 7f634d7b7..9e3f9dfaf 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiHookOutputContractTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiHookOutputContractTests.cs @@ -2,6 +2,7 @@ using Capacitor.Cli.Commands; using Capacitor.Cli.Commands.Harness; using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Harness.Gemini; @@ -185,7 +186,7 @@ async Task RunAsync(string payload) { // A URL no POST can reach: these paths must all return before any network call, and a test // that quietly started talking to a live server would be measuring something else. - await new GeminiHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload)); + await new GeminiHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); return capture.GetCapturedOutput(); } diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiSessionStartMemoryTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiSessionStartMemoryTests.cs index 99c515766..44490eafb 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiSessionStartMemoryTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiSessionStartMemoryTests.cs @@ -2,6 +2,7 @@ using Capacitor.Cli.Commands; using Capacitor.Cli.SessionStartMemory; using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Harness.Gemini; @@ -151,7 +152,7 @@ async Task CaptureHandleStdout(string payload) { using var capture = ConsoleOutput.StartCapture(); // baseUrl is unreachable on purpose: these paths must return before any network work. - await new GeminiHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload)); + await new GeminiHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); return capture.GetCapturedOutput(); } diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Kiro/KiroImportSourceTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Kiro/KiroImportSourceTests.cs index 91d7c7232..b33871357 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Kiro/KiroImportSourceTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Kiro/KiroImportSourceTests.cs @@ -1,6 +1,7 @@ using Capacitor.Cli.Commands; using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Harness.Kiro; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Harness.Kiro; @@ -23,7 +24,7 @@ public async Task discovery_reads_jsonl_and_sibling_json_metadata() { tmp.CreateFile($"{Dashed}.json", """{"cwd":"/work","title":"Hi there","created_at":"2026-06-17T10:30:00Z","session_state":{"rts_model_state":{"model_info":{"model_id":"auto"}}}}"""); - var src = new KiroImportSource(Config.Root, tmp.Path); + var src = new KiroImportSource(Config.Root, tmp.Path, router: new GitProviderRouter()); await Assert.That(src.IsAvailable).IsTrue(); var found = await src.DiscoverAsync(new DiscoveryFilters(null, null, null, 1), CancellationToken.None); @@ -43,7 +44,7 @@ public async Task discovery_session_filter_matches_dashless_id() { using var tmp = new TempDir(); tmp.CreateFile($"{Dashed}.jsonl", "{}\n"); - var src = new KiroImportSource(Config.Root, tmp.Path); + var src = new KiroImportSource(Config.Root, tmp.Path, router: new GitProviderRouter()); var match = await src.DiscoverAsync(new DiscoveryFilters(null, Dashed.Replace("-", ""), null, 1), CancellationToken.None); await Assert.That(match.Count).IsEqualTo(1); diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Pi/PiImportSourceTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Pi/PiImportSourceTests.cs index 748bd1bc6..ff15beec0 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Pi/PiImportSourceTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Pi/PiImportSourceTests.cs @@ -2,6 +2,7 @@ using Capacitor.Cli.Commands; using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Harness.Pi; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Harness.Pi; @@ -31,7 +32,7 @@ public async Task discovery_finds_pi_session_from_header() { using var tmp = new TempDir(); WriteSession(tmp.Path, Sid1, cwd: "/work/a"); - var source = new PiImportSource(Config.Root, tmp.Path); + var source = new PiImportSource(Config.Root, tmp.Path, router: new GitProviderRouter()); var sessions = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None); await Assert.That(sessions.Count).IsEqualTo(1); @@ -47,7 +48,7 @@ public async Task discovery_walks_nested_cwd_subdirs() { WriteSession(tmp.PathTo("proj-a"), Sid1, cwd: "/work/a"); WriteSession(tmp.PathTo("proj-b"), Sid2, cwd: "/work/b"); - var source = new PiImportSource(Config.Root, tmp.Path); + var source = new PiImportSource(Config.Root, tmp.Path, router: new GitProviderRouter()); var sessions = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None); await Assert.That(sessions.Count).IsEqualTo(2); @@ -59,7 +60,7 @@ public async Task discovery_skips_non_pi_jsonl() { // A .jsonl whose first line is not a Pi session header. tmp.CreateFile("other.jsonl", "{\"type\":\"something\",\"x\":1}\n"); - var source = new PiImportSource(Config.Root, tmp.Path); + var source = new PiImportSource(Config.Root, tmp.Path, router: new GitProviderRouter()); var sessions = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None); await Assert.That(sessions.Count).IsEqualTo(0); @@ -77,7 +78,7 @@ public async Task discovery_skips_session_with_non_guid_header_id() { """{"type":"message","id":"a1","parentId":null,"message":{"role":"user","content":"hello"}}""" }); - var source = new PiImportSource(Config.Root, tmp.Path); + var source = new PiImportSource(Config.Root, tmp.Path, router: new GitProviderRouter()); var sessions = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None); await Assert.That(sessions.Count).IsEqualTo(0); @@ -95,7 +96,7 @@ public async Task discovery_recovers_session_id_from_filename_when_header_id_mis """{"type":"message","id":"a1","parentId":null,"message":{"role":"user","content":"hi"}}""" }); - var source = new PiImportSource(Config.Root, tmp.Path); + var source = new PiImportSource(Config.Root, tmp.Path, router: new GitProviderRouter()); var sessions = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None); await Assert.That(sessions.Count).IsEqualTo(1); @@ -108,7 +109,7 @@ public async Task discovery_applies_session_and_cwd_filters() { WriteSession(tmp.Path, Sid1, cwd: "/work/a"); WriteSession(tmp.Path, Sid2, cwd: "/work/b"); - var source = new PiImportSource(Config.Root, tmp.Path); + var source = new PiImportSource(Config.Root, tmp.Path, router: new GitProviderRouter()); var bySession = await source.DiscoverAsync(new DiscoveryFilters(null, Sid1, null, 0), CancellationToken.None); await Assert.That(bySession.Count).IsEqualTo(1); @@ -122,7 +123,7 @@ public async Task discovery_applies_session_and_cwd_filters() { [Test] public async Task is_available_false_when_dir_missing() { using var tmp = new TempDir(); - var source = new PiImportSource(Config.Root, tmp.PathTo("nope")); + var source = new PiImportSource(Config.Root, tmp.PathTo("nope"), router: new GitProviderRouter()); await Assert.That(source.IsAvailable).IsFalse(); } @@ -131,7 +132,7 @@ public async Task does_not_support_title_generation() { // Pi is a routed source (FilePath=""), so it never reaches the chain // title worker. Like Copilot/Cursor it relies on the server-side fallback // title; advertising true would be a no-op contract lie. - var source = new PiImportSource(Config.Root, "/nonexistent"); + var source = new PiImportSource(Config.Root, "/nonexistent", router: new GitProviderRouter()); await Assert.That(source.SupportsTitleGeneration).IsFalse(); } diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Pi/PiSessionStartMemoryTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Pi/PiSessionStartMemoryTests.cs index 6920b625a..413085c47 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Pi/PiSessionStartMemoryTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Pi/PiSessionStartMemoryTests.cs @@ -3,6 +3,7 @@ using Capacitor.Cli.SessionStartMemory; using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit.Harness.Pi; @@ -24,7 +25,7 @@ public class PiSessionStartMemoryTests { // The server URL is the resolution's, so a test proving the url guard fires hands in the bad one // here rather than as an argument. PiHookCommand Hook(string serverUrl = "http://localhost:5100") => - new(Config.Root, Resolutions.At(serverUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(serverUrl, Config.Root), new FixedCapacitorHttpClient())); + new(Config.Root, Resolutions.At(serverUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(serverUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()); static string Render(string? fragment) => PiHookCommand.RenderMemoryOutput(fragment); // Byte-identical to pre-feature behaviour on every no-index path (opt-out, failure, spent lease): diff --git a/test/Capacitor.Cli.Tests.Unit/ImportProviderDetectionTests.cs b/test/Capacitor.Cli.Tests.Unit/ImportProviderDetectionTests.cs index 57bb02da6..672f8cd33 100644 --- a/test/Capacitor.Cli.Tests.Unit/ImportProviderDetectionTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/ImportProviderDetectionTests.cs @@ -27,7 +27,7 @@ static CommandRunner Recording(List log) => (cmd, args, _, _) => { public async Task Import_detection_resolves_repo_without_spawning_a_provider_cli() { var log = new List(); - var repo = await RepositoryDetection.DetectRepositoryAsync(Config.Root, + var repo = await RepositoryDetection.DetectRepositoryAsync(new GitProviderRouter(), Config.Root, "/fake/import/skip-pr", budget: null, detectPullRequest: false, run: Recording(log)); // Base repo info still resolves from git alone… @@ -43,7 +43,7 @@ public async Task Import_detection_resolves_repo_without_spawning_a_provider_cli public async Task Live_detection_still_runs_provider_detection() { var log = new List(); - var repo = await RepositoryDetection.DetectRepositoryAsync(Config.Root, + var repo = await RepositoryDetection.DetectRepositoryAsync(new GitProviderRouter(), Config.Root, "/fake/live/do-pr", budget: null, detectPullRequest: true, run: Recording(log)); await Assert.That(log.Any(c => c.StartsWith("gh pr view", StringComparison.Ordinal))).IsTrue(); // provider detection ran diff --git a/test/Capacitor.Cli.Tests.Unit/PrDetection/GitProviderRouterTests.cs b/test/Capacitor.Cli.Tests.Unit/PrDetection/GitProviderRouterTests.cs index 430fe76e4..ad80e5f90 100644 --- a/test/Capacitor.Cli.Tests.Unit/PrDetection/GitProviderRouterTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/PrDetection/GitProviderRouterTests.cs @@ -2,19 +2,15 @@ namespace Capacitor.Cli.Tests.Unit.PrDetection; -// The router memoizes into a production static, and the per-test reset that clears it is itself a -// process-global mutation — a concurrent peer's reset or memo entry decides what this class observes. -[NotInParallel] public class GitProviderRouterTests { - [Before(Test)] - public void Reset() => GitProviderRouter.ResetMemoForTests(); + static GitProviderRouter Router => new(); static CommandRunner Never => (_, _, _, _) => throw new InvalidOperationException("probe should not run for SaaS hosts"); [Test] public async Task Saas_hosts_route_without_probing() { - await Assert.That(await GitProviderRouter.ResolveAsync("github.com", "/c", TimeSpan.FromSeconds(2), Never)).IsEqualTo(GitProviderKind.GitHub); - await Assert.That(await GitProviderRouter.ResolveAsync("gitlab.com", "/c", TimeSpan.FromSeconds(2), Never)).IsEqualTo(GitProviderKind.GitLab); + await Assert.That(await Router.ResolveAsync("github.com", "/c", TimeSpan.FromSeconds(2), Never)).IsEqualTo(GitProviderKind.GitHub); + await Assert.That(await Router.ResolveAsync("gitlab.com", "/c", TimeSpan.FromSeconds(2), Never)).IsEqualTo(GitProviderKind.GitLab); } [Test] @@ -24,41 +20,42 @@ public async Task Custom_host_in_gh_auth_status_is_github() { await Assert.That(args).IsEqualTo("auth status --json hosts"); return "{\"hosts\":{\"github.com\":[],\"ghe.corp.com\":[]}}"; }; - await Assert.That(await GitProviderRouter.ResolveAsync("ghe.corp.com", "/c", TimeSpan.FromSeconds(2), fake)).IsEqualTo(GitProviderKind.GitHub); + await Assert.That(await Router.ResolveAsync("ghe.corp.com", "/c", TimeSpan.FromSeconds(2), fake)).IsEqualTo(GitProviderKind.GitHub); } [Test] public async Task Custom_host_not_in_gh_falls_back_to_gitlab() { CommandRunner fake = (_, _, _, _) => Task.FromResult("""{"hosts":{"github.com":[]}}"""); - await Assert.That(await GitProviderRouter.ResolveAsync("gitlab.corp.com", "/c", TimeSpan.FromSeconds(2), fake)).IsEqualTo(GitProviderKind.GitLab); + await Assert.That(await Router.ResolveAsync("gitlab.corp.com", "/c", TimeSpan.FromSeconds(2), fake)).IsEqualTo(GitProviderKind.GitLab); } [Test] public async Task Probe_result_is_memoized_per_host() { var calls = 0; CommandRunner fake = (_, _, _, _) => { calls++; return Task.FromResult("""{"hosts":{"ghe.corp.com":[]}}"""); }; - await GitProviderRouter.ResolveAsync("ghe.corp.com", "/c", TimeSpan.FromSeconds(2), fake); - await GitProviderRouter.ResolveAsync("ghe.corp.com", "/c", TimeSpan.FromSeconds(2), fake); - await Assert.That(calls).IsEqualTo(1); // memoized: bulk-import loop can't multiply the probe + var router = Router; + await router.ResolveAsync("ghe.corp.com", "/c", TimeSpan.FromSeconds(2), fake); + await router.ResolveAsync("ghe.corp.com", "/c", TimeSpan.FromSeconds(2), fake); + await Assert.That(calls).IsEqualTo(1); } [Test] public async Task Null_host_is_unknown() { - await Assert.That(await GitProviderRouter.ResolveAsync(null, "/c", TimeSpan.FromSeconds(2), Never)).IsEqualTo(GitProviderKind.Unknown); + await Assert.That(await Router.ResolveAsync(null, "/c", TimeSpan.FromSeconds(2), Never)).IsEqualTo(GitProviderKind.Unknown); } [Test] public async Task Malformed_gh_json_falls_back_to_gitlab() { // `gh auth status --json hosts` returned junk → JsonNode.Parse throws → we can't confirm - // the host is a GitHub host, so best-effort GitLab (unique host avoids memo collisions). + // the host is a GitHub host, so best-effort GitLab. CommandRunner fake = (_, _, _, _) => Task.FromResult("{not valid json"); - await Assert.That(await GitProviderRouter.ResolveAsync("malformed-json.example.com", "/c", TimeSpan.FromSeconds(2), fake)).IsEqualTo(GitProviderKind.GitLab); + await Assert.That(await Router.ResolveAsync("malformed-json.example.com", "/c", TimeSpan.FromSeconds(2), fake)).IsEqualTo(GitProviderKind.GitLab); } [Test] public async Task Null_probe_result_falls_back_to_gitlab() { // Probe failed / timed out (runner returned null) → best-effort GitLab. CommandRunner fake = (_, _, _, _) => Task.FromResult(null); - await Assert.That(await GitProviderRouter.ResolveAsync("null-probe.example.com", "/c", TimeSpan.FromSeconds(2), fake)).IsEqualTo(GitProviderKind.GitLab); + await Assert.That(await Router.ResolveAsync("null-probe.example.com", "/c", TimeSpan.FromSeconds(2), fake)).IsEqualTo(GitProviderKind.GitLab); } } diff --git a/test/Capacitor.Cli.Tests.Unit/ProviderBudgetSplitTests.cs b/test/Capacitor.Cli.Tests.Unit/ProviderBudgetSplitTests.cs index 54cb30da0..f215e5547 100644 --- a/test/Capacitor.Cli.Tests.Unit/ProviderBudgetSplitTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/ProviderBudgetSplitTests.cs @@ -4,15 +4,11 @@ namespace Capacitor.Cli.Tests.Unit; /// -/// Guards the effective-provider-budget split added in #229: the probe (GitProviderRouter) and the -/// PR/MR detector share one ceiling, and the detector must run within the budget the probe LEFT -/// BEHIND — not the full cap. This is timing-dependent in production; an injected timestamp makes -/// it deterministic so a regression (handing the detector the full cap) is caught in CI. +/// The probe and the PR/MR detector share one ceiling: the detector runs within what the probe LEFT +/// BEHIND, not the full cap. Real time decides that split in production, so the timestamp is +/// injected here — otherwise handing the detector the full cap reads as a fast machine. /// public class ProviderBudgetSplitTests { - [Before(Test)] - public void Reset() => GitProviderRouter.ResetMemoForTests(); - [Test] public async Task Detector_gets_the_budget_the_probe_left_behind() { var providerCap = TimeSpan.FromSeconds(2); @@ -29,6 +25,7 @@ public async Task Detector_gets_the_budget_the_probe_left_behind() { // Custom host → the router probes (consuming the injected time); GitLab detector then runs. await RepositoryDetection.ResolveAndDetectPrAsync( + new GitProviderRouter(), "git.example.com", "owner", "repo", "main", "/cwd", providerCap, run, Timestamp); await Assert.That(detectorCap).IsNotNull(); @@ -52,6 +49,7 @@ public async Task No_detection_when_probe_exhausts_the_budget() { }; var pr = await RepositoryDetection.ResolveAndDetectPrAsync( + new GitProviderRouter(), "git.example.com", "owner", "repo", "main", "/cwd", providerCap, run, Timestamp); await Assert.That(detectorRan).IsFalse(); @@ -75,6 +73,7 @@ public async Task Non_monotonic_timestamp_never_inflates_the_detector_budget() { }; await RepositoryDetection.ResolveAndDetectPrAsync( + new GitProviderRouter(), "git.example.com", "owner", "repo", "main", "/cwd", providerCap, run, Timestamp); await Assert.That(detectorCap).IsNotNull(); @@ -111,6 +110,7 @@ public async Task Tracked_branch_lookup_gets_only_what_the_normal_lookup_left() var run = TrackedBranchRunner(() => now, t => now = t, TimeSpan.FromSeconds(1.5), calls); await RepositoryDetection.ResolveAndDetectPrAsync( + new GitProviderRouter(), "github.com", "acme", "widget", "local-name", "/cwd", TimeSpan.FromSeconds(2), run, () => now); var tracked = calls.Single(c => c.Args.StartsWith("gh pr view remote-name", StringComparison.Ordinal)); @@ -125,6 +125,7 @@ public async Task No_tracked_branch_probe_once_the_normal_lookup_spends_the_budg var run = TrackedBranchRunner(() => now, t => now = t, TimeSpan.FromSeconds(2), calls); var pr = await RepositoryDetection.ResolveAndDetectPrAsync( + new GitProviderRouter(), "github.com", "acme", "widget", "local-name", "/cwd", TimeSpan.FromSeconds(2), run, () => now); await Assert.That(pr).IsNull(); diff --git a/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionCacheTests.cs b/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionCacheTests.cs index 1b5626e6d..732e42ebf 100644 --- a/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionCacheTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionCacheTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Capacitor.Cli.Core; +using Capacitor.Cli.PrDetection; namespace Capacitor.Cli.Tests.Unit; @@ -30,7 +31,7 @@ public async Task GitCacheEntry_v2_is_stale_after_nested_group_bump() { [Test] public async Task Detects_nested_gitlab_repo_base_info() { using var repo = MakeTempRepo("git@gitlab.com:group/sub/project.git"); - var payload = await RepositoryDetection.DetectRepositoryAsync(Config.Root, repo); + var payload = await RepositoryDetection.DetectRepositoryAsync(new GitProviderRouter(), Config.Root, repo); await Assert.That(payload).IsNotNull(); await Assert.That(payload!.Owner).IsEqualTo("group/sub"); @@ -57,7 +58,7 @@ public async Task GitCacheEntry_roundtrips_host_and_version() { [Test] public async Task Detects_gitlab_repo_base_info() { using var repo = MakeTempRepo("git@gitlab.com:group/project.git"); - var payload = await RepositoryDetection.DetectRepositoryAsync(Config.Root, repo); + var payload = await RepositoryDetection.DetectRepositoryAsync(new GitProviderRouter(), Config.Root, repo); await Assert.That(payload).IsNotNull(); await Assert.That(payload!.Owner).IsEqualTo("group"); diff --git a/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionPrSkipTests.cs b/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionPrSkipTests.cs index c5d404252..2b107e6da 100644 --- a/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionPrSkipTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionPrSkipTests.cs @@ -11,9 +11,6 @@ namespace Capacitor.Cli.Tests.Unit; public class RepositoryDetectionPrSkipTests { [TempConfigRoot] public required TempConfigRoot Config { get; init; } - [Before(Test)] - public void Reset() => GitProviderRouter.ResetMemoForTests(); - static CommandRunner RecordingRunner(List commands) => (cmd, args, _, _) => { commands.Add(cmd); @@ -32,7 +29,7 @@ public async Task DetectPullRequest_false_never_spawns_gh_or_glab() { using var cwd = new TempDir(); var commands = new List(); - var repo = await RepositoryDetection.DetectRepositoryAsync(Config.Root, + var repo = await RepositoryDetection.DetectRepositoryAsync(new GitProviderRouter(), Config.Root, cwd.Path, budget: TimeSpan.FromSeconds(5), detectPullRequest: false, run: RecordingRunner(commands)); await Assert.That(repo).IsNotNull(); @@ -51,7 +48,7 @@ public async Task DetectPullRequest_true_does_probe_the_provider() { using var cwd = new TempDir(); var commands = new List(); - await RepositoryDetection.DetectRepositoryAsync(Config.Root, + await RepositoryDetection.DetectRepositoryAsync(new GitProviderRouter(), Config.Root, cwd.Path, budget: TimeSpan.FromSeconds(5), detectPullRequest: true, run: RecordingRunner(commands)); // Proves the flag actually gates the round-trip: with detection ON, the @@ -65,7 +62,7 @@ public async Task EnrichWithRepositoryInfo_false_never_spawns_gh_or_glab() { var commands = new List(); var payload = $$"""{"cwd":"{{cwd.Path.Replace("\\", "/")}}","hook_event_name":"session-start"}"""; - var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(Config.Root, + var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(new GitProviderRouter(), Config.Root, payload, budget: TimeSpan.FromSeconds(5), detectPullRequest: false, run: RecordingRunner(commands)); await Assert.That(enriched).Contains("acme"); diff --git a/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionTrackedBranchTests.cs b/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionTrackedBranchTests.cs index 1745d46c5..e2125bef7 100644 --- a/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionTrackedBranchTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionTrackedBranchTests.cs @@ -46,6 +46,7 @@ public async Task Finds_the_PR_by_the_tracked_remote_name_when_the_local_name_mi var ghCalls = new List(); var payload = await RepositoryDetection.DetectRepositoryAsync( + new GitProviderRouter(), Config.Root, repo, run: RealGitFakeGh(args => args == TrackedLookup ? TrackedPr : null, ghCalls)); await Assert.That(payload!.Branch).IsEqualTo("local-name"); @@ -60,6 +61,7 @@ public async Task A_normal_lookup_hit_never_runs_the_fallback() { var ghCalls = new List(); var payload = await RepositoryDetection.DetectRepositoryAsync( + new GitProviderRouter(), Config.Root, repo, run: RealGitFakeGh(args => args == NormalLookup ? """{"number":5,"headRefName":"local-name"}""" @@ -76,9 +78,9 @@ public async Task A_later_hit_is_a_repository_change_the_watcher_sends() { var prOpened = false; var run = RealGitFakeGh(args => prOpened && args == TrackedLookup ? TrackedPr : null); - var before = await RepositoryDetection.DetectRepositoryAsync(Config.Root, repo, run: run); + var before = await RepositoryDetection.DetectRepositoryAsync(new GitProviderRouter(), Config.Root, repo, run: run); prOpened = true; - var after = await RepositoryDetection.DetectRepositoryAsync(Config.Root, repo, run: run); + var after = await RepositoryDetection.DetectRepositoryAsync(new GitProviderRouter(), Config.Root, repo, run: run); await Assert.That(before!.PrNumber).IsNull(); await Assert.That(after!.PrNumber).IsEqualTo(874); From d1565332039552fa9abe86de2b3dd3082613a3fc Mon Sep 17 00:00:00 2001 From: Pavel Borisov Date: Tue, 15 Sep 2026 00:36:47 +0200 Subject: [PATCH 3/5] Take a WorkingDirectory rather than reading the process's own The two git children behind ResolveForRepo set no working directory of their own, so they inherited the process's and a test could steer them only by moving it; handing the value down without also handing it to them would have changed nothing. The ban covers the setter too, and one test still moves the process, because a path with no directory component resolves against nothing else. --- BannedSymbols.txt | 3 + .../CapacitorContextServices.cs | 2 + src/Capacitor.Cli.Core/Config/AppConfig.cs | 15 ++-- src/Capacitor.Cli.Core/WorkingDirectory.cs | 20 ++++++ src/Capacitor.Cli/Commands/AgentCommand.cs | 4 +- src/Capacitor.Cli/Commands/CommandServices.cs | 4 +- src/Capacitor.Cli/Commands/CurateCommand.cs | 8 ++- .../Harness/AntigravityHookCommand.cs | 4 +- .../Commands/Harness/ClaudeHookCommand.cs | 4 +- .../Commands/Harness/CodexHookCommand.cs | 4 +- .../Commands/Harness/CopilotHookCommand.cs | 4 +- .../Commands/Harness/CursorHookCommand.cs | 8 +-- .../Commands/Harness/GeminiHookCommand.cs | 4 +- .../Commands/Harness/KiroHookCommand.cs | 4 +- .../Commands/Harness/OpenCodeHookCommand.cs | 4 +- .../Commands/Harness/PiHookCommand.cs | 4 +- .../Commands/McpAnalyticsServer.cs | 4 +- src/Capacitor.Cli/Commands/McpFlowsServer.cs | 4 +- src/Capacitor.Cli/Commands/McpMemoryServer.cs | 4 +- src/Capacitor.Cli/Commands/McpReviewServer.cs | 4 +- .../Commands/McpSessionsServer.cs | 4 +- src/Capacitor.Cli/Commands/PluginCommand.cs | 10 +-- src/Capacitor.Cli/Commands/RecapCommand.cs | 5 +- src/Capacitor.Cli/Commands/SessionsCommand.cs | 5 +- src/Capacitor.Cli/Commands/SetupCommand.cs | 10 +-- src/Capacitor.Cli/Commands/SkillsCommand.cs | 4 +- .../Commands/UninstallCommand.cs | 9 +-- src/Capacitor.Cli/Commands/UseCommand.cs | 7 +- src/Capacitor.Cli/Program.cs | 11 +-- .../SessionStartMemoryHookSupport.cs | 3 +- .../SessionStartMemoryScopeResolver.cs | 5 +- .../Config/ResolveForRepoTests.cs | 24 ++----- .../Harness/Codex/CodexConfigTomlTests.cs | 4 +- .../Harness/Codex/CodexProjectKeyTests.cs | 4 +- .../Setup/BinaryProbeTests.cs | 2 + .../Pty/Unix/PtySpawnTests.cs | 2 +- .../Pty/Unix/UnixPtyProcessSpawnTests.cs | 4 +- .../Pty/Unix/UnixSpawnerThreadTests.cs | 2 +- .../Pty/Windows/ConPtyJobObjectTests.cs | 8 +-- .../AntigravitySessionStartTests.cs | 4 +- .../ClaudeHookStdoutTests.cs | 2 +- ...SessionStartHandshakeOnPostFailureTests.cs | 2 +- .../CodexSessionStartVisibilityTests.cs | 4 +- .../CursorSessionStartVisibilityTests.cs | 2 +- .../CursorTailingWatcherTests.cs | 4 +- ...SessionStartHandshakeOnPostFailureTests.cs | 2 +- .../GeminiStderrShadowedOnPostFailureTests.cs | 2 +- .../SessionStartCoordinationNoticesTests.cs | 6 +- .../SessionStartMemoryRedirectTests.cs | 2 +- .../SessionStartVisibilityTests.cs | 10 +-- .../SpoolOutageRecoveryTests.cs | 2 +- .../Program.cs | 4 +- .../Commands/CommandContainerTests.cs | 3 +- .../FlowsDriverSchemaConformanceTests.cs | 2 +- .../Harness/AntigravityHookCommandTests.cs | 12 ++-- .../Harness/ClaudeHookCommandTests.cs | 48 ++++++------- .../Harness/ClaudeHookInputWaitRelayTests.cs | 2 +- .../Commands/Harness/CodexHookCommandTests.cs | 62 ++++++++-------- .../Harness/CodexHookInputWaitRelayTests.cs | 2 +- .../Harness/CursorHookCommandTests.cs | 67 +++++++++-------- .../Commands/McpAnalyticsServerTests.cs | 3 +- .../McpFlowsServerReviewerVendorsTests.cs | 2 +- .../McpFlowsServerSettlementRetryTests.cs | 3 +- .../Commands/McpFlowsServerTests.cs | 3 +- .../McpFlowsServerVendorOverrideTests.cs | 3 +- .../ParticipantUnreachableRetryTests.cs | 3 +- .../Commands/PluginCommandAntigravityTests.cs | 13 ++-- .../Commands/PluginCommandClaudeTests.cs | 14 ++-- .../Commands/PluginCommandCodexTests.cs | 24 +++---- .../Commands/PluginCommandCopilotTests.cs | 23 +++--- .../Commands/PluginCommandCursorTests.cs | 16 ++--- .../Commands/PluginCommandGeminiTests.cs | 27 +++---- .../Commands/PluginCommandKiroTests.cs | 13 ++-- .../Commands/PluginCommandOpenCodeTests.cs | 18 ++--- .../Commands/PluginCommandPiTests.cs | 19 ++--- .../Commands/PluginCommandSkillsTests.cs | 18 ++--- .../Commands/PluginCommandStaleAgentTests.cs | 9 +-- .../PluginCommandVendorSkillsTests.cs | 20 +++--- .../Commands/ReviewerVendorFallbackTests.cs | 3 +- .../Commands/SetupChosenServerTests.cs | 2 +- .../Commands/SetupCommandTests.cs | 55 ++++++-------- .../Commands/SetupFacadeParityTests.cs | 2 +- .../Commands/StatusWaitArgumentTests.cs | 3 +- .../Commands/ToolCallBudgetTests.cs | 3 +- .../Commands/UninstallCommandTests.cs | 72 ++++++++----------- .../Commands/UnusableUrlGuardTests.cs | 10 +-- .../Commands/UseCommandTests.cs | 9 +-- .../AntigravitySessionStartMemoryTests.cs | 8 +-- .../Claude/ClaudeHookExclusionGateTests.cs | 2 +- .../Claude/ClaudeMemoryIndexLiveCertTests.cs | 2 +- .../CursorLiveSubagentIntegrationTests.cs | 2 +- .../Cursor/CursorMemoryIndexLiveCertTests.cs | 2 +- .../Cursor/CursorSubagentStaleStateTests.cs | 8 +-- .../Harness/Cursor/CursorWatcherSpawnTests.cs | 10 +-- .../Gemini/GeminiHookOutputContractTests.cs | 2 +- .../Gemini/GeminiSessionStartMemoryTests.cs | 2 +- .../Harness/Pi/PiSessionStartMemoryTests.cs | 2 +- .../ProcessHelpersTests.cs | 2 + .../MemoryIndexLiveCertHarness.cs | 4 +- 99 files changed, 466 insertions(+), 449 deletions(-) create mode 100644 src/Capacitor.Cli.Core/WorkingDirectory.cs diff --git a/BannedSymbols.txt b/BannedSymbols.txt index c4b3e9282..6b742347b 100644 --- a/BannedSymbols.txt +++ b/BannedSymbols.txt @@ -1,2 +1,5 @@ M:System.Environment.GetFolderPath(System.Environment.SpecialFolder);Take a UserHome. M:System.Environment.GetFolderPath(System.Environment.SpecialFolder,System.Environment.SpecialFolderOption);Take a UserHome. +P:System.Environment.CurrentDirectory;Take a WorkingDirectory. +M:System.IO.Directory.GetCurrentDirectory;Take a WorkingDirectory. +M:System.IO.Directory.SetCurrentDirectory(System.String);Take a WorkingDirectory. diff --git a/src/Capacitor.Cli.Core/CapacitorContextServices.cs b/src/Capacitor.Cli.Core/CapacitorContextServices.cs index 85b91cf75..092871fc4 100644 --- a/src/Capacitor.Cli.Core/CapacitorContextServices.cs +++ b/src/Capacitor.Cli.Core/CapacitorContextServices.cs @@ -13,10 +13,12 @@ public static IServiceCollection AddCapacitorContext( this IServiceCollection services, ConfigRoot config, UserHome home, + WorkingDirectory workdir, DaemonStore daemons, ProfileContext profiles) { services.AddSingleton(config); services.AddSingleton(home); + services.AddSingleton(workdir); services.AddSingleton(daemons); services.AddSingleton(profiles); diff --git a/src/Capacitor.Cli.Core/Config/AppConfig.cs b/src/Capacitor.Cli.Core/Config/AppConfig.cs index c67e63044..e001b5723 100644 --- a/src/Capacitor.Cli.Core/Config/AppConfig.cs +++ b/src/Capacitor.Cli.Core/Config/AppConfig.cs @@ -48,7 +48,7 @@ public static class AppConfig { // so the deprecation notice in LoadProfileConfig fires at most once per run. static bool _v1MigrationSignalled; - public static string RepoRoot => GetGitRepoRoot() ?? Environment.CurrentDirectory; + public static string RepoRootOf(WorkingDirectory workdir) => GetGitRepoRoot(workdir.Path) ?? workdir.Path; /// /// Resolve server URL using only the active profile (or KCAP_PROFILE / @@ -80,7 +80,8 @@ public static async Task ResolveActiveProfile(string[] args, Con return new(resolved, loaded); } - public static async Task ResolveForRepo(string[] args, ConfigRoot root, ProfileOverrides env, int gitTimeoutMs = 5000) { + public static async Task ResolveForRepo( + string[] args, ConfigRoot root, ProfileOverrides env, WorkingDirectory workdir, int gitTimeoutMs = 5000) { var idx = Array.IndexOf(args, "--server-url"); var cliServerUrl = (idx >= 0 && idx + 1 < args.Length) ? args[idx + 1] : null; @@ -107,7 +108,7 @@ public static async Task ResolveForRepo(string[] args, ConfigRoo { var config = await LoadProfileConfig(root); - var repoRoot = GetGitRepoRoot(gitTimeoutMs) ?? Environment.CurrentDirectory; + var repoRoot = GetGitRepoRoot(workdir.Path, gitTimeoutMs) ?? workdir.Path; RepoConfig? repoConfig = null; var repoConfigPath = Path.Combine(repoRoot, ".kcap.json"); @@ -121,7 +122,7 @@ public static async Task ResolveForRepo(string[] args, ConfigRoo } } - var remoteUrls = GetGitRemoteUrls(gitTimeoutMs); + var remoteUrls = GetGitRemoteUrls(workdir.Path, gitTimeoutMs); var resolver = new ProfileResolver( config, @@ -143,9 +144,10 @@ public static async Task ResolveForRepo(string[] args, ConfigRoo } } - static string[] GetGitRemoteUrls(int timeoutMs = 5000) { + static string[] GetGitRemoteUrls(string cwd, int timeoutMs = 5000) { try { var psi = new ProcessStartInfo("git", "remote -v") { + WorkingDirectory = cwd, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, @@ -175,9 +177,10 @@ static string[] GetGitRemoteUrls(int timeoutMs = 5000) { } } - static string? GetGitRepoRoot(int timeoutMs = 5000) { + static string? GetGitRepoRoot(string cwd, int timeoutMs = 5000) { try { var psi = new ProcessStartInfo("git", "rev-parse --show-toplevel") { + WorkingDirectory = cwd, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, diff --git a/src/Capacitor.Cli.Core/WorkingDirectory.cs b/src/Capacitor.Cli.Core/WorkingDirectory.cs new file mode 100644 index 000000000..ac3f4d42d --- /dev/null +++ b/src/Capacitor.Cli.Core/WorkingDirectory.cs @@ -0,0 +1,20 @@ +namespace Capacitor.Cli.Core; + +/// +/// The checkout this process acts on, resolved at an entry point and passed down. The single +/// working-directory resolution in the codebase, so a module never depends on where the process +/// happens to be standing — it acts on a checkout because its composer named one. +/// +/// Anything shelling out to git passes as the child's working directory: +/// a child inherits the process cwd otherwise, which puts the ambient value back in the answer. +/// +public sealed class WorkingDirectory(string path) { + /// The directory itself. Not guaranteed to exist. + public string Path { get; } = path; + + /// This process's working directory. Call once, in Main or the composition root. + public static WorkingDirectory FromProcess() => +#pragma warning disable RS0030 // the cwd resolution the ban points every other site at + new(Directory.GetCurrentDirectory()); +#pragma warning restore RS0030 +} diff --git a/src/Capacitor.Cli/Commands/AgentCommand.cs b/src/Capacitor.Cli/Commands/AgentCommand.cs index f0f5a9b11..9045717db 100644 --- a/src/Capacitor.Cli/Commands/AgentCommand.cs +++ b/src/Capacitor.Cli/Commands/AgentCommand.cs @@ -19,7 +19,7 @@ internal readonly record struct AgentRow( /// internal sealed class AgentCommand( DaemonStore store, ConfigRoot config, ProfileContext profiles, UserHome home, - HarnessRegistry harnesses, BinaryProbe binaries) { + HarnessRegistry harnesses, BinaryProbe binaries, WorkingDirectory workdir) { internal static readonly string[] KnownSubcommands = ["start", "ls", "stop", "attach"]; /// Verbs that only ever belonged to the pre-rename `agent` daemon group, minus the @@ -90,7 +90,7 @@ async Task RunAsync(string[] args, string? baseUrl) { var sock = store.SocketPath(name); var work = parsed.Worktree ? WorkLocation.OwnedWorktree : WorkLocation.BorrowedCwd; var (cols, rows) = TermSize(); - var spawn = FrameCodec.Spawn(parsed.Vendor, work, parsed.Private, Environment.CurrentDirectory, parsed.Passthrough, cols, rows); + var spawn = FrameCodec.Spawn(parsed.Vendor, work, parsed.Private, workdir.Path, parsed.Passthrough, cols, rows); return parsed.Detached ? await SpawnDetachedAsync(sock, spawn) diff --git a/src/Capacitor.Cli/Commands/CommandServices.cs b/src/Capacitor.Cli/Commands/CommandServices.cs index d705b3510..624c4cc74 100644 --- a/src/Capacitor.Cli/Commands/CommandServices.cs +++ b/src/Capacitor.Cli/Commands/CommandServices.cs @@ -19,12 +19,12 @@ public static class CommandServices { /// does not pay for it. /// public static IServiceCollection AddCapacitorCli( - this IServiceCollection services, ConfigRoot config, UserHome home, DaemonStore daemons, + this IServiceCollection services, ConfigRoot config, UserHome home, WorkingDirectory workdir, DaemonStore daemons, ProfileContext profiles, ProfileOverrides env, MachineAuth machine, AuthEndpoints endpoints, HookClock clock, string? baseUrl, TelemetryStartup telemetryStartup) { services - .AddCapacitorContext(config, home, daemons, profiles) + .AddCapacitorContext(config, home, workdir, daemons, profiles) .AddCapacitorCommands(); services.AddSingleton(endpoints); diff --git a/src/Capacitor.Cli/Commands/CurateCommand.cs b/src/Capacitor.Cli/Commands/CurateCommand.cs index f52acc69f..da99e9d83 100644 --- a/src/Capacitor.Cli/Commands/CurateCommand.cs +++ b/src/Capacitor.Cli/Commands/CurateCommand.cs @@ -5,15 +5,17 @@ namespace Capacitor.Cli.Commands; -class CurateCommand(ConfigRoot config, IRepositoriesApi repositories, GitProviderRouter router) { +class CurateCommand( + ConfigRoot config, IRepositoriesApi repositories, GitProviderRouter router, + WorkingDirectory workdir) { /// One page is all this command reads; hitting it exactly is what the warning below /// reports, so the request and the check must name the same number. const int PageLimit = 100; public async Task HandleApply(bool dryRun, bool yes) { - var cwd = Environment.CurrentDirectory; + var cwd = workdir.Path; - // 1. Authoritative repo-root gate (never AppConfig.RepoRoot). + // 1. Authoritative repo-root gate: the tree itself, never the fallback RepoRootOf applies. var repoRoot = GitRepository.FindRoot(cwd); if (repoRoot is null) { await Console.Error.WriteLineAsync("Not inside a git repository — run `kcap curate apply` from a repo."); diff --git a/src/Capacitor.Cli/Commands/Harness/AntigravityHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/AntigravityHookCommand.cs index 9c2551393..79b84a0a3 100644 --- a/src/Capacitor.Cli/Commands/Harness/AntigravityHookCommand.cs +++ b/src/Capacitor.Cli/Commands/Harness/AntigravityHookCommand.cs @@ -39,7 +39,7 @@ namespace Capacitor.Cli.Commands.Harness; sealed class AntigravityHookCommand( ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home, HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers, - GitProviderRouter router) { + GitProviderRouter router, WorkingDirectory workdir) { readonly AgentHookPoster _poster = new(config, profiles, http, watchers); string Url => profiles.Resolution.ServerUrl!; @@ -305,7 +305,7 @@ internal static SessionMemoryLifecycle LifecycleFor(string sessionId) => try { var store = SessionStartMemoryLeaseStore.Create(config, clock.Time); - var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, http.ForMemoryAsync, clock.Time); + var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, workdir, http.ForMemoryAsync, clock.Time); return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync( LifecycleFor(sessionId), diff --git a/src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs index 8426edc6a..75354230f 100644 --- a/src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs +++ b/src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs @@ -24,7 +24,7 @@ namespace Capacitor.Cli.Commands.Harness; public sealed class ClaudeHookCommand( ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home, HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers, - IProcessStarter starter, GitProviderRouter router) { + IProcessStarter starter, GitProviderRouter router, WorkingDirectory workdir) { string Url => profiles.Resolution.ServerUrl!; @@ -1113,7 +1113,7 @@ static void NormalizeGuidField(JsonNode node, string fieldName) { try { var store = SessionStartMemoryLeaseStore.Create(config, clock.Time); var provider = new SessionStartMemoryContextProvider( - new SessionStartMemoryScopeResolver(router, config, clock.Time), http.ForMemoryAsync, clock.Time); + new SessionStartMemoryScopeResolver(router, config, workdir, clock.Time), http.ForMemoryAsync, clock.Time); return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync( new SessionMemoryLifecycle(HarnessId.Claude, nativeSessionId, null, diff --git a/src/Capacitor.Cli/Commands/Harness/CodexHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/CodexHookCommand.cs index 06ea28e57..3d331eedf 100644 --- a/src/Capacitor.Cli/Commands/Harness/CodexHookCommand.cs +++ b/src/Capacitor.Cli/Commands/Harness/CodexHookCommand.cs @@ -41,7 +41,7 @@ namespace Capacitor.Cli.Commands.Harness; sealed class CodexHookCommand( ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home, HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers, - GitProviderRouter router) { + GitProviderRouter router, WorkingDirectory workdir) { readonly AgentHookPoster _poster = new(config, profiles, http, watchers); string Url => profiles.Resolution.ServerUrl!; @@ -157,7 +157,7 @@ internal static async Task RunSessionStartHandshakeForTest(Action writeStdout, F // the injected client factory can throw synchronously. try { var store = SessionStartMemoryLeaseStore.Create(config, clock.Time); - var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, http.ForMemoryAsync, clock.Time); + var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, workdir, http.ForMemoryAsync, clock.Time); return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync( new SessionMemoryLifecycle(HarnessId.Codex, sessionId!, LifecycleInstanceId: null, diff --git a/src/Capacitor.Cli/Commands/Harness/CopilotHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/CopilotHookCommand.cs index e6d93babd..146dc56a0 100644 --- a/src/Capacitor.Cli/Commands/Harness/CopilotHookCommand.cs +++ b/src/Capacitor.Cli/Commands/Harness/CopilotHookCommand.cs @@ -46,7 +46,7 @@ namespace Capacitor.Cli.Commands.Harness; sealed class CopilotHookCommand( ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home, HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers, - GitProviderRouter router) { + GitProviderRouter router, WorkingDirectory workdir) { readonly AgentHookPoster _poster = new(config, profiles, http, watchers); string Url => profiles.Resolution.ServerUrl!; @@ -114,7 +114,7 @@ internal static void WriteSessionStartOutput(TextWriter writer, string? fragment try { var store = SessionStartMemoryLeaseStore.Create(config, clock.Time); - var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, http.ForMemoryAsync, clock.Time); + var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, workdir, http.ForMemoryAsync, clock.Time); return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync( new SessionMemoryLifecycle(HarnessId.Copilot, sessionId, LifecycleInstanceId: null, diff --git a/src/Capacitor.Cli/Commands/Harness/CursorHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/CursorHookCommand.cs index 9af96bdd9..46fe74af2 100644 --- a/src/Capacitor.Cli/Commands/Harness/CursorHookCommand.cs +++ b/src/Capacitor.Cli/Commands/Harness/CursorHookCommand.cs @@ -25,7 +25,7 @@ namespace Capacitor.Cli.Commands.Harness; public sealed class CursorHookCommand( ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home, HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers, - GitProviderRouter router) { + GitProviderRouter router, WorkingDirectory workdir) { readonly CursorMarkers _markers = new(config); string Url => profiles.Resolution.ServerUrl!; @@ -559,8 +559,8 @@ HookBudget budget if (sessionId is null) return null; // An absent/blank Cursor workspace root must NOT fall through to the scope resolver's - // Directory.GetCurrentDirectory() fallback: that would derive a repo scope from the hook - // PROCESS's cwd and could inject an UNRELATED repository's memories into this session. + // working-directory fallback: that would derive a repo scope from the hook PROCESS's + // directory and could inject an UNRELATED repository's memories into this session. // With no authoritative workspace root there is no safe scope, so skip injection entirely. if (string.IsNullOrWhiteSpace(workspaceRoot)) return null; @@ -585,7 +585,7 @@ HookBudget budget var store = SessionStartMemoryLeaseStore.Create(config, clock.Time); // Both lanes send on the hook's own client, which stays this method's caller's to dispose. - var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, _ => Task.FromResult(client), clock.Time); + var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, workdir, _ => Task.FromResult(client), clock.Time); return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync( // ClassificationAuthoritative is hardcoded true, and this is VALID UNDER THE diff --git a/src/Capacitor.Cli/Commands/Harness/GeminiHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/GeminiHookCommand.cs index 1ed69541a..9b8912fc4 100644 --- a/src/Capacitor.Cli/Commands/Harness/GeminiHookCommand.cs +++ b/src/Capacitor.Cli/Commands/Harness/GeminiHookCommand.cs @@ -67,7 +67,7 @@ namespace Capacitor.Cli.Commands.Harness; sealed class GeminiHookCommand( ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home, HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers, - GitProviderRouter router) { + GitProviderRouter router, WorkingDirectory workdir) { readonly AgentHookPoster _poster = new(config, profiles, http, watchers); string Url => profiles.Resolution.ServerUrl!; @@ -196,7 +196,7 @@ internal static SessionMemoryLifecycle LifecycleFor(string sessionId, string? so try { var store = SessionStartMemoryLeaseStore.Create(config, clock.Time); - var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, http.ForMemoryAsync, clock.Time); + var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, workdir, http.ForMemoryAsync, clock.Time); return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync( LifecycleFor(sessionId, source), diff --git a/src/Capacitor.Cli/Commands/Harness/KiroHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/KiroHookCommand.cs index 7d26468de..fb9bdc6a8 100644 --- a/src/Capacitor.Cli/Commands/Harness/KiroHookCommand.cs +++ b/src/Capacitor.Cli/Commands/Harness/KiroHookCommand.cs @@ -39,7 +39,7 @@ namespace Capacitor.Cli.Commands.Harness; sealed class KiroHookCommand( ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home, HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers, - GitProviderRouter router) { + GitProviderRouter router, WorkingDirectory workdir) { readonly AgentHookPoster _poster = new(config, profiles, http, watchers); string Url => profiles.Resolution.ServerUrl!; @@ -106,7 +106,7 @@ internal static void WriteAgentSpawnOutput(TextWriter writer, string? fragment, try { var store = SessionStartMemoryLeaseStore.Create(config, clock.Time); - var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, http.ForMemoryAsync, clock.Time); + var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, workdir, http.ForMemoryAsync, clock.Time); return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync( new SessionMemoryLifecycle(HarnessId.Kiro, sessionId, LifecycleInstanceId: null, diff --git a/src/Capacitor.Cli/Commands/Harness/OpenCodeHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/OpenCodeHookCommand.cs index e563ff4db..b659884a3 100644 --- a/src/Capacitor.Cli/Commands/Harness/OpenCodeHookCommand.cs +++ b/src/Capacitor.Cli/Commands/Harness/OpenCodeHookCommand.cs @@ -33,7 +33,7 @@ namespace Capacitor.Cli.Commands.Harness; sealed class OpenCodeHookCommand( ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home, HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers, - GitProviderRouter router) { + GitProviderRouter router, WorkingDirectory workdir) { readonly AgentHookPoster _poster = new(config, profiles, http, watchers); string Url => profiles.Resolution.ServerUrl!; @@ -257,7 +257,7 @@ internal static SessionMemoryLifecycle LifecycleFor(string sessionId) => try { var store = SessionStartMemoryLeaseStore.Create(config, clock.Time); - var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, http.ForMemoryAsync, clock.Time); + var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, workdir, http.ForMemoryAsync, clock.Time); return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync( LifecycleFor(sessionId), diff --git a/src/Capacitor.Cli/Commands/Harness/PiHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/PiHookCommand.cs index d02ed6ff0..dab7ccb73 100644 --- a/src/Capacitor.Cli/Commands/Harness/PiHookCommand.cs +++ b/src/Capacitor.Cli/Commands/Harness/PiHookCommand.cs @@ -38,7 +38,7 @@ namespace Capacitor.Cli.Commands.Harness; sealed class PiHookCommand( ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home, HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers, - GitProviderRouter router) { + GitProviderRouter router, WorkingDirectory workdir) { readonly AgentHookPoster _poster = new(config, profiles, http, watchers); string Url => profiles.Resolution.ServerUrl!; @@ -327,7 +327,7 @@ internal static SessionMemoryLifecycle LifecycleFor(string file, string? reason) try { var store = SessionStartMemoryLeaseStore.Create(config, clock.Time); - var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, http.ForMemoryAsync, clock.Time); + var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, workdir, http.ForMemoryAsync, clock.Time); return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync( LifecycleFor(file, reason), diff --git a/src/Capacitor.Cli/Commands/McpAnalyticsServer.cs b/src/Capacitor.Cli/Commands/McpAnalyticsServer.cs index cff1e6c6f..a4b08a22a 100644 --- a/src/Capacitor.Cli/Commands/McpAnalyticsServer.cs +++ b/src/Capacitor.Cli/Commands/McpAnalyticsServer.cs @@ -20,7 +20,7 @@ namespace Capacitor.Cli.Commands; /// rejection reasons. Structure cloned from McpMemoryServer. /// sealed class McpAnalyticsServer(ConfigRoot config, ProfileContext profiles, TokenStore tokens, ICapacitorHttpClient http, - TelemetryStartup startup, GitProviderRouter router) { + TelemetryStartup startup, GitProviderRouter router, WorkingDirectory workdir) { internal const string NotLoggedInMessage = AuthRejectionNotice.NotLoggedIn; internal const string NotSupportedMessage = @@ -38,7 +38,7 @@ static string TimeoutHintFor(string toolName) => public async Task RunAsync() { var baseUrl = profiles.Resolution.ServerUrl!; - var repository = new CwdRepository(config, Directory.GetCurrentDirectory(), router); + var repository = new CwdRepository(config, workdir.Path, router); var tools = BuildToolsList(); // Best-effort, and recorded even when the read throws: a stale token on disk must never diff --git a/src/Capacitor.Cli/Commands/McpFlowsServer.cs b/src/Capacitor.Cli/Commands/McpFlowsServer.cs index 650ab7b3d..18fe1d458 100644 --- a/src/Capacitor.Cli/Commands/McpFlowsServer.cs +++ b/src/Capacitor.Cli/Commands/McpFlowsServer.cs @@ -17,7 +17,7 @@ namespace Capacitor.Cli.Commands; class McpFlowsServer( ConfigRoot config, ProfileContext profiles, TokenStore store, ICapacitorHttpClient http, - TelemetryStartup startup, GitProviderRouter router) { + TelemetryStartup startup, GitProviderRouter router, WorkingDirectory workdir) { public async Task RunAsync(string? driverArg = null) { var baseUrl = profiles.Resolution.ServerUrl!; @@ -27,7 +27,7 @@ public async Task RunAsync(string? driverArg = null) { // session id and the working directory come from the same resolution, so a flow can never be // attributed to one session while being reviewed in another session's checkout. var requester = HarnessRequesterContext.Resolve(); - var cwd = requester.ProjectDir ?? Directory.GetCurrentDirectory(); + var cwd = requester.ProjectDir ?? workdir.Path; var repoRoot = GitRepository.FindRoot(cwd); // Prefer the `--driver` stamp from this server's own registration (deterministic for the JSON // harnesses); fall back to env inference for Claude/Codex, whose registrations are unstamped. diff --git a/src/Capacitor.Cli/Commands/McpMemoryServer.cs b/src/Capacitor.Cli/Commands/McpMemoryServer.cs index 54eed88b3..1cf9a4843 100644 --- a/src/Capacitor.Cli/Commands/McpMemoryServer.cs +++ b/src/Capacitor.Cli/Commands/McpMemoryServer.cs @@ -15,13 +15,13 @@ namespace Capacitor.Cli.Commands; sealed class McpMemoryServer(ConfigRoot config, ProfileContext profiles, TokenStore tokens, ICapacitorHttpClient http, - TelemetryStartup startup, GitProviderRouter router) { + TelemetryStartup startup, GitProviderRouter router, WorkingDirectory workdir) { internal const string NotLoggedInMessage = AuthRejectionNotice.NotLoggedIn; public async Task RunAsync() { var baseUrl = profiles.Resolution.ServerUrl!; - var repository = new CwdRepository(config, Directory.GetCurrentDirectory(), router); + var repository = new CwdRepository(config, workdir.Path, router); var machineId = await ResolveMachineIdAsync(); var tools = BuildToolsList(); diff --git a/src/Capacitor.Cli/Commands/McpReviewServer.cs b/src/Capacitor.Cli/Commands/McpReviewServer.cs index 2e7d4451a..09c8ef552 100644 --- a/src/Capacitor.Cli/Commands/McpReviewServer.cs +++ b/src/Capacitor.Cli/Commands/McpReviewServer.cs @@ -17,7 +17,7 @@ namespace Capacitor.Cli.Commands; sealed class McpReviewServer(ConfigRoot config, ProfileContext profiles, TokenStore tokens, ICapacitorHttpClient http, - TelemetryStartup startup, GitProviderRouter router) { + TelemetryStartup startup, GitProviderRouter router, WorkingDirectory workdir) { /// /// Run with an explicit session-default PR (used by kcap review <pr>). /// Tool calls may still override the default by passing a pr argument. @@ -150,7 +150,7 @@ async Task TimedDispatchToolCallAsync(JsonNode callId, JsonObject callRe async Task DetectPrFromGitAsync() { try { - var cwd = Directory.GetCurrentDirectory(); + var cwd = workdir.Path; var repoInfo = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd); if (repoInfo?.Owner is not null && repoInfo.RepoName is not null && repoInfo.PrNumber is not null) { diff --git a/src/Capacitor.Cli/Commands/McpSessionsServer.cs b/src/Capacitor.Cli/Commands/McpSessionsServer.cs index a97c92211..bd53a36ac 100644 --- a/src/Capacitor.Cli/Commands/McpSessionsServer.cs +++ b/src/Capacitor.Cli/Commands/McpSessionsServer.cs @@ -15,13 +15,13 @@ namespace Capacitor.Cli.Commands; sealed class McpSessionsServer(ConfigRoot config, ProfileContext profiles, TokenStore tokens, ICapacitorHttpClient http, - TelemetryStartup startup, GitProviderRouter router) { + TelemetryStartup startup, GitProviderRouter router, WorkingDirectory workdir) { internal const string NotLoggedInMessage = AuthRejectionNotice.NotLoggedIn; public async Task RunAsync() { var baseUrl = profiles.Resolution.ServerUrl!; - var repository = new CwdRepository(config, Directory.GetCurrentDirectory(), router); + var repository = new CwdRepository(config, workdir.Path, router); var tools = BuildToolsList(); // Best-effort, and recorded even when the read throws: a stale token on disk must never diff --git a/src/Capacitor.Cli/Commands/PluginCommand.cs b/src/Capacitor.Cli/Commands/PluginCommand.cs index a0d340418..03f18ed86 100644 --- a/src/Capacitor.Cli/Commands/PluginCommand.cs +++ b/src/Capacitor.Cli/Commands/PluginCommand.cs @@ -18,7 +18,7 @@ namespace Capacitor.Cli.Commands; -public sealed class PluginCommand(PluginEnvironment env) { +public sealed class PluginCommand(PluginEnvironment env, WorkingDirectory workdir) { static readonly JsonSerializerOptions WriteOpts = new() { WriteIndented = true }; const string CodexHookCommand = "kcap hook --codex"; @@ -98,7 +98,7 @@ async Task InstallClaude(string[] args) { var scope = args.Contains("--project") ? "project" : "user"; var settingsPath = scope == "project" - ? Path.Combine(Environment.CurrentDirectory, ".claude", "settings.local.json") + ? Path.Combine(workdir.Path, ".claude", "settings.local.json") : env.Harnesses.Of().Paths.UserSettings; // --if-installed: refresh-only mode used by the npm postinstall hook. @@ -158,7 +158,7 @@ async Task RemoveClaude(string[] args) { var scope = args.Contains("--project") ? "project" : "user"; var settingsPath = scope == "project" - ? Path.Combine(Environment.CurrentDirectory, ".claude", "settings.local.json") + ? Path.Combine(workdir.Path, ".claude", "settings.local.json") : env.Harnesses.Of().Paths.UserSettings; if (!File.Exists(settingsPath)) { @@ -329,7 +329,7 @@ async Task InstallCodex(string[] args) { var scope = args.Contains("--project") ? "project" : "user"; var hooksPath = scope == "project" - ? Path.Combine(Environment.CurrentDirectory, ".codex", "hooks.json") + ? Path.Combine(workdir.Path, ".codex", "hooks.json") : codex.UserHooksJson; // --if-installed: refresh-only mode used by the npm postinstall hook and @@ -507,7 +507,7 @@ async Task RemoveCodex(string[] args) { var scope = args.Contains("--project") ? "project" : "user"; var hooksPath = scope == "project" - ? Path.Combine(Environment.CurrentDirectory, ".codex", "hooks.json") + ? Path.Combine(workdir.Path, ".codex", "hooks.json") : codex.UserHooksJson; var hooksRemoved = false; diff --git a/src/Capacitor.Cli/Commands/RecapCommand.cs b/src/Capacitor.Cli/Commands/RecapCommand.cs index 3c16d1074..ea68911c1 100644 --- a/src/Capacitor.Cli/Commands/RecapCommand.cs +++ b/src/Capacitor.Cli/Commands/RecapCommand.cs @@ -10,10 +10,9 @@ namespace Capacitor.Cli.Commands; class RecapCommand( ConfigRoot config, ISessionsApi sessionsApi, IRepositoriesApi repositoriesApi, - GitProviderRouter router) { + GitProviderRouter router, WorkingDirectory workdir) { public async Task HandleRepoRecap(int limit = 10) { - var cwd = Directory.GetCurrentDirectory(); - var repo = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd); + var repo = await RepositoryDetection.DetectRepositoryAsync(router, config, workdir.Path); if (repo?.Owner is null || repo.RepoName is null) { Console.Error.WriteLine("Not in a git repository with a remote origin."); diff --git a/src/Capacitor.Cli/Commands/SessionsCommand.cs b/src/Capacitor.Cli/Commands/SessionsCommand.cs index e9d0c630f..137e96782 100644 --- a/src/Capacitor.Cli/Commands/SessionsCommand.cs +++ b/src/Capacitor.Cli/Commands/SessionsCommand.cs @@ -9,7 +9,8 @@ namespace Capacitor.Cli.Commands; class SessionsCommand( - ConfigRoot config, ProfileContext profiles, ICapacitorHttpClient http, GitProviderRouter router) { + ConfigRoot config, ProfileContext profiles, ICapacitorHttpClient http, GitProviderRouter router, + WorkingDirectory workdir) { public async Task HandleAsync(string[] args) { var options = SessionsArgs.Parse(args, out var error); @@ -26,7 +27,7 @@ public async Task HandleAsync(string[] args) { if (options.Repo is null) { var repo = await RepositoryDetection.DetectRepositoryAsync( router, - config, Directory.GetCurrentDirectory(), detectPullRequest: false); + config, workdir.Path, detectPullRequest: false); if (repo?.Owner is null || repo.RepoName is null) { await Console.Error.WriteLineAsync("Not in a git repository with a remote origin."); diff --git a/src/Capacitor.Cli/Commands/SetupCommand.cs b/src/Capacitor.Cli/Commands/SetupCommand.cs index 4a3372a64..c4a5b3d34 100644 --- a/src/Capacitor.Cli/Commands/SetupCommand.cs +++ b/src/Capacitor.Cli/Commands/SetupCommand.cs @@ -421,7 +421,7 @@ public sealed class SetupCommand( UserHome home, HarnessRegistry harnesses, AgentsPaths agents, ICapacitorHttpClient http, TenantProvisioningClient provisioning, AuthProviderDiscovery discovery, CliTelemetry telemetry, AuthEndpoints endpoints, IOnboardingFacadeFactory facades, ISetupImportRunner imports, - ChosenServerHttp chosenHttp, GitProviderRouter router) { + ChosenServerHttp chosenHttp, GitProviderRouter router, WorkingDirectory workdir) { public async Task HandleAsync(string[] args) { var serverUrlArg = GetArg(args, "--server-url"); @@ -500,11 +500,11 @@ await Console.Error.WriteLineAsync( // unrelated to any project, or — worse — under a subdirectory of the repo if we // used cwd directly, which means two devs running setup from different subdirs // install hooks in different places. - var gitRoot = GitRepository.FindRoot(Environment.CurrentDirectory); + var gitRoot = GitRepository.FindRoot(workdir.Path); if (legacyProjectScope && gitRoot is null) { await Console.Error.WriteLineAsync( - $"--plugin-scope project requires a git working tree, but '{Environment.CurrentDirectory}' is not inside one."); + $"--plugin-scope project requires a git working tree, but '{workdir.Path}' is not inside one."); await Console.Error.WriteLineAsync( "Either re-run `kcap setup` from inside your repo, or drop --plugin-scope project to install user-scope hooks."); return 1; @@ -976,7 +976,7 @@ await SetupDaemonService.RunAsync( // PR/MR detection would run extra provider probes/subprocesses for nothing here. var currentRepoDetected = await RepositoryDetection.DetectRepositoryAsync( router, - config, Environment.CurrentDirectory, detectPullRequest: false); + config, workdir.Path, detectPullRequest: false); (string Owner, string Name)? currentRepo = currentRepoDetected is { Owner: { } o, RepoName: { } n } ? (o, n) : null; @@ -1035,7 +1035,7 @@ await RunImportStepAsync( // RepositoryDetection.DetectRepositoryAsync), which weakens grouping in the UI. if (gitRoot is null) { AnsiConsole.MarkupLine( - $"\n [yellow]Tip:[/] you ran setup outside a git working tree ([dim]{Markup.Escape(Environment.CurrentDirectory)}[/])."); + $"\n [yellow]Tip:[/] you ran setup outside a git working tree ([dim]{Markup.Escape(workdir.Path)}[/])."); AnsiConsole.MarkupLine( " Hooks fire from any directory, but sessions recorded outside a repo won't include owner/repo/branch context."); AnsiConsole.MarkupLine( diff --git a/src/Capacitor.Cli/Commands/SkillsCommand.cs b/src/Capacitor.Cli/Commands/SkillsCommand.cs index a604200c6..32bacdab7 100644 --- a/src/Capacitor.Cli/Commands/SkillsCommand.cs +++ b/src/Capacitor.Cli/Commands/SkillsCommand.cs @@ -19,7 +19,7 @@ namespace Capacitor.Cli.Commands; /// class SkillsCommand( ConfigRoot config, HarnessRegistry harnesses, AgentsPaths agents, IRepositoriesApi repositories, - GitProviderRouter router) { + GitProviderRouter router, WorkingDirectory workdir) { // The background refresh keys off each manifest's synced_at, so a burst of session starts // costs one network round-trip per interval per target, not one per session. static readonly TimeSpan AutoSyncInterval = TimeSpan.FromHours(6); @@ -36,7 +36,7 @@ internal static IReadOnlyList Targets(HarnessRegistry harnesses, A ]; public async Task HandleSync(bool dryRun, bool auto = false) { - var cwd = Environment.CurrentDirectory; + var cwd = workdir.Path; if (GitRepository.FindRoot(cwd) is null) { await Console.Error.WriteLineAsync("Not inside a git repository — run `kcap skills sync` from a repo."); diff --git a/src/Capacitor.Cli/Commands/UninstallCommand.cs b/src/Capacitor.Cli/Commands/UninstallCommand.cs index 0c2becfac..db3281a37 100644 --- a/src/Capacitor.Cli/Commands/UninstallCommand.cs +++ b/src/Capacitor.Cli/Commands/UninstallCommand.cs @@ -32,7 +32,8 @@ namespace Capacitor.Cli.Commands; /// public sealed class UninstallCommand( DaemonStore store, ConfigRoot config, ProfileContext profiles, UserHome home, - HarnessRegistry harnesses, BinaryProbe binaries, AgentsPaths agents, WatcherManager watchers) { + HarnessRegistry harnesses, BinaryProbe binaries, AgentsPaths agents, WatcherManager watchers, + WorkingDirectory workdir) { public async Task HandleAsync(string[] args) { var skipPrompt = args.Contains("--yes") || args.Contains("-y"); var keepConfig = args.Contains("--keep-config"); @@ -41,11 +42,11 @@ public async Task HandleAsync(string[] args) { string? projectRoot = null; if (includeProject) { - projectRoot = GitRepository.FindRoot(Environment.CurrentDirectory); + projectRoot = GitRepository.FindRoot(workdir.Path); if (projectRoot is null) { await Console.Error.WriteLineAsync( - $"--project requires a git working tree, but '{Environment.CurrentDirectory}' is not inside one."); + $"--project requires a git working tree, but '{workdir.Path}' is not inside one."); await Console.Error.WriteLineAsync( "Re-run from inside your repo, or drop --project to only remove user-level configuration."); @@ -129,7 +130,7 @@ await Console.Out.WriteLineAsync( if (await new CleanupCommand(watchers).HandleCleanup() != 0) hadFailures = true; var env = PluginEnvironment.FromProcess(await AppConfig.LoadProfileConfig(config), home, harnesses); - var pluginCommand = new PluginCommand(env); + var pluginCommand = new PluginCommand(env, workdir); // User-level agent integrations. Each remove command is idempotent and // no-ops if the target file doesn't exist, so it's safe to call all of diff --git a/src/Capacitor.Cli/Commands/UseCommand.cs b/src/Capacitor.Cli/Commands/UseCommand.cs index fe8062556..cd662a8c4 100644 --- a/src/Capacitor.Cli/Commands/UseCommand.cs +++ b/src/Capacitor.Cli/Commands/UseCommand.cs @@ -5,7 +5,7 @@ namespace Capacitor.Cli.Commands; -public sealed class UseCommand(ConfigRoot config) { +public sealed class UseCommand(ConfigRoot config, WorkingDirectory workdir) { public async Task HandleAsync(string[] args) { if (args.Length < 2) { await Console.Error.WriteLineAsync("Usage: kcap use [--global] [--save]"); @@ -15,9 +15,10 @@ public async Task HandleAsync(string[] args) { var name = args[1]; var global = args.Contains("--global"); var save = args.Contains("--save"); - var repoPath = global ? null : AppConfig.RepoRoot; + var repoRoot = AppConfig.RepoRootOf(workdir); + var repoPath = global ? null : repoRoot; - return await SetProfile(name, repoPath, global, save, save ? AppConfig.RepoRoot : null); + return await SetProfile(name, repoPath, global, save, save ? repoRoot : null); } internal async Task SetProfile( diff --git a/src/Capacitor.Cli/Program.cs b/src/Capacitor.Cli/Program.cs index 4d6f48d39..80a27456a 100644 --- a/src/Capacitor.Cli/Program.cs +++ b/src/Capacitor.Cli/Program.cs @@ -76,8 +76,9 @@ var isHook = command == "hook"; // Resolved once here and passed onward; nothing downstream resolves a root for itself. -var config = ConfigRoot.FromEnvironment(); -var home = UserHome.FromEnvironment(); +var config = ConfigRoot.FromEnvironment(); +var home = UserHome.FromEnvironment(); +var workdir = WorkingDirectory.FromProcess(); // Claude kills a SessionEnd hook after 1.5 s (ClaudeSessionEndHandoff), so the hand-off sits // ahead of ResolveServerUrl's git probes and the global spool drain, each of which can spend it. @@ -104,7 +105,7 @@ var machineEnv = MachineAuth.FromEnvironment(); var endpoints = AuthEndpoints.FromEnvironment(); -var profiles = await AppConfig.ResolveForRepo(args, config, serverEnv, gitTimeoutMs: isHook || isRefreshHandoff ? 1000 : 5000); +var profiles = await AppConfig.ResolveForRepo(args, config, serverEnv, workdir, gitTimeoutMs: isHook || isRefreshHandoff ? 1000 : 5000); var baseUrl = profiles.Resolution.ServerUrl; // An app-spawned CLI child must not emit CLI-labeled telemetry nor consume the one-time privacy @@ -116,7 +117,7 @@ // asks for a command rather than handing each one its arguments. var services = new ServiceCollection() .AddCapacitorCli( - config, home, daemonPaths, profiles, serverEnv, machineEnv, endpoints, clock, baseUrl, + config, home, workdir, daemonPaths, profiles, serverEnv, machineEnv, endpoints, clock, baseUrl, telemetryStartup); await using var sp = services.BuildValidated(); @@ -661,7 +662,7 @@ var activeProfile = profiles.Name; var storedOrg = profileConfig.Profiles.GetValueOrDefault(activeProfile)?.ImportOrg; - var currentRepoDetected = await RepositoryDetection.DetectRepositoryAsync(sp.GetRequiredService(), config, Environment.CurrentDirectory); + var currentRepoDetected = await RepositoryDetection.DetectRepositoryAsync(sp.GetRequiredService(), config, workdir.Path); (string Owner, string Name)? currentRepo = currentRepoDetected is { Owner: { } o, RepoName: { } n } ? (o, n) : null; diff --git a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryHookSupport.cs b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryHookSupport.cs index 72d585149..90978b202 100644 --- a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryHookSupport.cs +++ b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryHookSupport.cs @@ -30,10 +30,11 @@ internal static class SessionStartMemoryHookSupport { public static ISessionStartContextProvider CompositeProvider( GitProviderRouter router, ConfigRoot config, + WorkingDirectory workdir, Func> client, TimeProvider time, ISessionStartMemoryScopeResolver? scopeResolver = null) { - var resolver = scopeResolver ?? new SessionStartMemoryScopeResolver(router, config, time); + var resolver = scopeResolver ?? new SessionStartMemoryScopeResolver(router, config, workdir, time); var memory = new SessionStartMemoryContextProvider(resolver, client, time); var guidelines = new SessionStartGuidelinesLane(client); diff --git a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryScopeResolver.cs b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryScopeResolver.cs index 0a67a03b7..a08744161 100644 --- a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryScopeResolver.cs +++ b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryScopeResolver.cs @@ -4,7 +4,8 @@ namespace Capacitor.Cli.SessionStartMemory; internal sealed class SessionStartMemoryScopeResolver( - GitProviderRouter router, ConfigRoot config, TimeProvider time) : ISessionStartMemoryScopeResolver { + GitProviderRouter router, ConfigRoot config, WorkingDirectory workdir, TimeProvider time) + : ISessionStartMemoryScopeResolver { public async Task ResolveAsync(string? cwd, TimeSpan budget, CancellationToken ct) { var started = time.GetTimestamp(); TimeSpan Remaining() { @@ -15,7 +16,7 @@ TimeSpan Remaining() { string? repoHash = null; string? machine = null; try { - var path = string.IsNullOrWhiteSpace(cwd) ? Directory.GetCurrentDirectory() : cwd; + var path = string.IsNullOrWhiteSpace(cwd) ? workdir.Path : cwd; var repo = await RepositoryDetection.DetectRepositoryAsync(router, config, path, Remaining(), detectPullRequest: false); if (repo?.Owner is not null && repo.RepoName is not null) repoHash = RepoHashHelper.ComputeRepoHash(repo.Owner, repo.RepoName); diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Config/ResolveForRepoTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Config/ResolveForRepoTests.cs index 7464d6556..d7f978db0 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/Config/ResolveForRepoTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/Config/ResolveForRepoTests.cs @@ -3,9 +3,7 @@ namespace Capacitor.Cli.Core.Tests.Unit.Config; /// -/// The short-circuit that skips repo discovery when an explicit URL is given. Bare -/// [NotInParallel]: the repo is discovered from the working directory, which is -/// process-global. +/// The short-circuit that skips repo discovery when an explicit URL is given. /// public class ResolveForRepoTests { [TempConfigRoot] public required TempConfigRoot Config { get; init; } @@ -27,19 +25,11 @@ async Task WriteRepoPinnedToAnotherProfile() { Repo.CreateFile(".kcap.json", """{"profile":"pinned"}"""); } - async Task ResolveIn(string[] args, ProfileOverrides env) { - var originalCwd = Environment.CurrentDirectory; - try { - Environment.CurrentDirectory = Repo.Path; + async Task ResolveIn(string[] args, ProfileOverrides env) => + (await AppConfig.ResolveForRepo(args, Config.Root, env, new WorkingDirectory(Repo.Path), gitTimeoutMs: 1000)) + .Resolution.ServerUrl; - return (await AppConfig.ResolveForRepo(args, Config.Root, env, gitTimeoutMs: 1000)) - .Resolution.ServerUrl; - } finally { - Environment.CurrentDirectory = originalCwd; - } - } - - [Test, NotInParallel] + [Test] public async Task Nothing_overridden_resolves_the_repos_own_profile() { await WriteRepoPinnedToAnotherProfile(); @@ -49,7 +39,7 @@ public async Task Nothing_overridden_resolves_the_repos_own_profile() { /// A named override outranks what the repo pins, so the profile named in /// .kcap.json loses to it. Says nothing about whether discovery ran — both paths feed the /// resolver the same override, and only the git probe tells them apart. - [Test, NotInParallel] + [Test] public async Task A_named_override_outranks_the_repos_own_profile() { await WriteRepoPinnedToAnotherProfile(); @@ -60,7 +50,7 @@ public async Task A_named_override_outranks_the_repos_own_profile() { /// An empty --server-url names no server: the resolver discards it and falls /// through, so withholding the repo inputs from that fall-through would silently answer the /// active profile instead of the one the repo pins. - [Test, NotInParallel] + [Test] public async Task An_empty_server_url_flag_still_resolves_the_repos_profile() { await WriteRepoPinnedToAnotherProfile(); diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexConfigTomlTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexConfigTomlTests.cs index 9e7823819..b4a0376e9 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexConfigTomlTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexConfigTomlTests.cs @@ -125,7 +125,8 @@ public async Task EnableNetworkAccess_is_idempotent() { public async Task EnableNetworkAccess_writes_when_config_path_has_no_directory_component() { // GetDirectoryName("config.toml") is empty; CreateDirectory("") would throw and // silently turn the write into Change.Failed without the guard. - using var tmp = new TempDir(); + using var tmp = new TempDir(); +#pragma warning disable RS0030 // a path with no directory component resolves nowhere else var originalCwd = Environment.CurrentDirectory; try { @@ -137,6 +138,7 @@ public async Task EnableNetworkAccess_writes_when_config_path_has_no_directory_c await Assert.That(File.Exists(tmp.PathTo("config.toml"))).IsTrue(); } finally { Environment.CurrentDirectory = originalCwd; +#pragma warning restore RS0030 } } diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexProjectKeyTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexProjectKeyTests.cs index 3e106b73a..9fc912532 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexProjectKeyTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexProjectKeyTests.cs @@ -35,8 +35,8 @@ public async Task NormalizeProjectKey_preserves_case_on_unix() { [Test] public async Task NormalizeProjectKey_is_absolute_and_collapsed() { - var key = CodexPaths.NormalizeProjectKey( - Path.Combine(Directory.GetCurrentDirectory(), "sub", "..", "leaf")); + using var tmp = new TempDir(); + var key = CodexPaths.NormalizeProjectKey(tmp.PathTo("sub", "..", "leaf")); await Assert.That(Path.IsPathFullyQualified(key)).IsTrue(); await Assert.That(key).DoesNotContain(".."); diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Setup/BinaryProbeTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Setup/BinaryProbeTests.cs index 4a29b9b56..ea8d4be16 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/Setup/BinaryProbeTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/Setup/BinaryProbeTests.cs @@ -57,7 +57,9 @@ public async Task Resolve_returns_a_fully_qualified_path_for_relative_input() { var staged = await Stage(tmp.PathTo(Launchable("probe"))); // A path with a directory component but no root resolves against the cwd, not the search path. +#pragma warning disable RS0030 // the base Resolve() itself resolves against; naming it is the test var relative = Path.GetRelativePath(Directory.GetCurrentDirectory(), staged); +#pragma warning restore RS0030 var resolved = BinaryProbe.Searching(null).Resolve(relative); await Assert.That(resolved).IsNotNull(); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/PtySpawnTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/PtySpawnTests.cs index 6576e2556..2a70d4236 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/PtySpawnTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/PtySpawnTests.cs @@ -192,7 +192,7 @@ static IntPtr Preflight(string exe, string?[] argv, int? execveatSupported = nul static int Spawn(IntPtr plan, out UnixPtyInterop.PtySpawnResult result, string? cwd = null, int expectedParent = -1, int cancelFd = -1) { var expected = expectedParent == -1 ? Environment.ProcessId : expectedParent; - return UnixPtyInterop.pty_spawn(plan, EmptyEnvp(), cwd ?? Directory.GetCurrentDirectory(), 40, 120, expected, cancelFd, out result); + return UnixPtyInterop.pty_spawn(plan, EmptyEnvp(), cwd ?? AppContext.BaseDirectory, 40, 120, expected, cancelFd, out result); } static void Free(IntPtr plan) { var p = plan; UnixPtyInterop.pty_plan_free(ref p); } diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/UnixPtyProcessSpawnTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/UnixPtyProcessSpawnTests.cs index abfe52260..f3d6ef1bd 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/UnixPtyProcessSpawnTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/UnixPtyProcessSpawnTests.cs @@ -17,7 +17,7 @@ public async Task Spawn_produces_a_running_process_with_a_captured_identity() { // test that let UnixPtyProcessFactory own an undisposed static singleton hung indefinitely). using var spawner = new UnixSpawnerThread(); var factory = new UnixPtyProcessFactory(spawner); - var proc = factory.Spawn("sleep", ["5"], Directory.GetCurrentDirectory()); + var proc = factory.Spawn("sleep", ["5"], AppContext.BaseDirectory); try { await Assert.That(proc.Pid).IsGreaterThan(0); await Assert.That(proc.StartIdentity).IsNotNull(); @@ -64,7 +64,7 @@ public async Task Terminate_kills_the_leaders_whole_process_group() { // that leaks in production. Only a signal to the GROUP reaches it. var proc = factory.Spawn( "/bin/sh", ["-c", "(trap '' HUP; exec sleep 300) & echo \"CHILD:$!:DONE\"; wait"], - Directory.GetCurrentDirectory()); + AppContext.BaseDirectory); try { var childPid = await ReadReportedChildPidAsync(proc); await Assert.That(childPid).IsGreaterThan(0); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/UnixSpawnerThreadTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/UnixSpawnerThreadTests.cs index 94d4a0658..6627f777f 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/UnixSpawnerThreadTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/UnixSpawnerThreadTests.cs @@ -62,7 +62,7 @@ public async Task Agent_survives_unrelated_pool_thread_churn_while_the_thread_li var rc = UnixPtyInterop.pty_preflight("/bin/sleep", ["sleep", "3", null], [null], execveatSupported, out var plan); await Assert.That(rc).IsEqualTo(0); - var result = spawner.SpawnOn(plan, [null], Directory.GetCurrentDirectory(), 40, 120, Environment.ProcessId, -1); + var result = spawner.SpawnOn(plan, [null], AppContext.BaseDirectory, 40, 120, Environment.ProcessId, -1); try { await Assert.That(result.Pid).IsGreaterThan(0); for (var i = 0; i < 5; i++) await Task.Run(() => { }); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Windows/ConPtyJobObjectTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Windows/ConPtyJobObjectTests.cs index 5fbd25207..3da35503f 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Windows/ConPtyJobObjectTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Windows/ConPtyJobObjectTests.cs @@ -18,7 +18,7 @@ public async Task Disposing_the_process_kills_child_and_grandchild() { // immediate child — is under test. await using var proc = ConPtyProcess.Spawn( "cmd.exe", ["/c", "start /min cmd.exe /c timeout /t 60 >NUL & timeout /t 60 >NUL"], - Directory.GetCurrentDirectory()); + AppContext.BaseDirectory); await Task.Delay(500); // let the grandchild actually spawn before we kill the job @@ -44,7 +44,7 @@ public async Task Job_sets_no_breakaway_flag_so_escape_is_impossible() { // NOT by joining this test host to proc's killing job and disposing it (which would // close the last handle and have the OS kill the host). Reading the flags is host-safe; // proc.DisposeAsync closes proc's own killing job, killing proc's child, never the host. - await using var proc = ConPtyProcess.Spawn("cmd.exe", ["/c", "exit"], Directory.GetCurrentDirectory()); + await using var proc = ConPtyProcess.Spawn("cmd.exe", ["/c", "exit"], AppContext.BaseDirectory); var limitFlags = ConPtyJobObjectTestHelper.QueryJobLimitFlags(ConPtyInteropTestAccessor.JobHandle(proc)); @@ -68,7 +68,7 @@ public async Task A_daemon_already_inside_an_outer_job_still_nests() { var outerJob = ConPtyInterop.CreateJobObjectW(IntPtr.Zero, null); ConPtyJobObjectTestHelper.AssignSelfToJob(outerJob); - await using var proc = ConPtyProcess.Spawn("cmd.exe", ["/c", "timeout /t 5 >NUL"], Directory.GetCurrentDirectory()); + await using var proc = ConPtyProcess.Spawn("cmd.exe", ["/c", "timeout /t 5 >NUL"], AppContext.BaseDirectory); // Nesting succeeded iff the spawn didn't throw AND the child is (transitively) a // member of the outer job too — checked via the native IsProcessInJob. @@ -100,7 +100,7 @@ public async Task Spawn_fails_closed_when_create_process_fails() { var threw = false; try { - await using var proc = ConPtyProcess.Spawn(missing, [], Directory.GetCurrentDirectory()); + await using var proc = ConPtyProcess.Spawn(missing, [], AppContext.BaseDirectory); } catch (InvalidOperationException) { threw = true; // Spawn threw → failed closed, no uncontained child created } diff --git a/test/Capacitor.Cli.Tests.Integration/AntigravitySessionStartTests.cs b/test/Capacitor.Cli.Tests.Integration/AntigravitySessionStartTests.cs index d79158b5f..1b35aa821 100644 --- a/test/Capacitor.Cli.Tests.Integration/AntigravitySessionStartTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/AntigravitySessionStartTests.cs @@ -71,7 +71,7 @@ public async Task PreInvocation_posts_session_start_with_profile_visibility() { } """; - var exit = await new AntigravityHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["hook", "--antigravity", "PreInvocation"], new StringReader(payload), + var exit = await new AntigravityHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["hook", "--antigravity", "PreInvocation"], new StringReader(payload), new StringWriter()); await Assert.That(exit).IsEqualTo(0); @@ -118,7 +118,7 @@ public async Task PreInvocation_for_excluded_path_is_skipped_without_posting() { } """; - var exit = await new AntigravityHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["hook", "--antigravity", "PreInvocation"], new StringReader(payload), + var exit = await new AntigravityHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["hook", "--antigravity", "PreInvocation"], new StringReader(payload), new StringWriter()); await Assert.That(exit).IsEqualTo(0); diff --git a/test/Capacitor.Cli.Tests.Integration/ClaudeHookStdoutTests.cs b/test/Capacitor.Cli.Tests.Integration/ClaudeHookStdoutTests.cs index 9a209318a..58c06989b 100644 --- a/test/Capacitor.Cli.Tests.Integration/ClaudeHookStdoutTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/ClaudeHookStdoutTests.cs @@ -61,7 +61,7 @@ static string SessionStartPayloadWithoutTranscriptPath() => // concurrently-running test writes to Console can contaminate it. async Task RunSessionStartAsync() { var stdout = new StringWriter(); - await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath()), stdout: stdout); + await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath()), stdout: stdout); return stdout.ToString(); } diff --git a/test/Capacitor.Cli.Tests.Integration/CodexSessionStartHandshakeOnPostFailureTests.cs b/test/Capacitor.Cli.Tests.Integration/CodexSessionStartHandshakeOnPostFailureTests.cs index d5bb56afd..64e1511f9 100644 --- a/test/Capacitor.Cli.Tests.Integration/CodexSessionStartHandshakeOnPostFailureTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/CodexSessionStartHandshakeOnPostFailureTests.cs @@ -58,7 +58,7 @@ public async Task A_rejected_lifecycle_post_still_satisfies_the_blocking_stdout_ using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); // The rejection is still reported — this is not "pretend it worked". await Assert.That(exit).IsEqualTo(1); diff --git a/test/Capacitor.Cli.Tests.Integration/CodexSessionStartVisibilityTests.cs b/test/Capacitor.Cli.Tests.Integration/CodexSessionStartVisibilityTests.cs index c9dbee333..4e271f45d 100644 --- a/test/Capacitor.Cli.Tests.Integration/CodexSessionStartVisibilityTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/CodexSessionStartVisibilityTests.cs @@ -53,7 +53,7 @@ public async Task SessionStart_stamps_default_visibility_from_active_profile() { } """; - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start/codex").UsingPost()); @@ -98,7 +98,7 @@ public async Task SessionStart_for_excluded_repo_is_skipped_and_marks_session_di using var capture = ConsoleOutput.StartCapture(); try { - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); // No /hooks/session-start/codex POST. diff --git a/test/Capacitor.Cli.Tests.Integration/CursorSessionStartVisibilityTests.cs b/test/Capacitor.Cli.Tests.Integration/CursorSessionStartVisibilityTests.cs index 11a53407f..bc106a7a5 100644 --- a/test/Capacitor.Cli.Tests.Integration/CursorSessionStartVisibilityTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/CursorSessionStartVisibilityTests.cs @@ -60,7 +60,7 @@ async Task RunSessionStartAndCaptureBodyAsync(string defaultVisibility using var client = new HttpClient(); var spool = new HookSpool(tmp.CreateDir("spool").Path); - var exit = await new CursorHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).HandleCore(client, new StringReader(body), spool); + var exit = await new CursorHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(client, new StringReader(body), spool); await Assert.That(exit).IsEqualTo(0); var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start/cursor").UsingPost()); diff --git a/test/Capacitor.Cli.Tests.Integration/CursorTailingWatcherTests.cs b/test/Capacitor.Cli.Tests.Integration/CursorTailingWatcherTests.cs index 412b8b83a..fa269900f 100644 --- a/test/Capacitor.Cli.Tests.Integration/CursorTailingWatcherTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/CursorTailingWatcherTests.cs @@ -189,7 +189,7 @@ public async Task Reactivation_ViaSessionStartHook_SpawnsAFreshWatcher() { var spool = new HookSpool(tmp.PathTo("spool")); var body = $$"""{"hook_event_name":"sessionStart","session_id":"{{sessionId}}","transcript_path":"{{transcriptPath.Replace(@"\", @"\\")}}"}"""; - var exit = await new CursorHookCommand(Config.Root, Resolutions.At(server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore(client, new StringReader(body), spool); + var exit = await new CursorHookCommand(Config.Root, Resolutions.At(server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(client, new StringReader(body), spool); await Assert.That(exit).IsEqualTo(0); await Assert.That(spawner.Keys).IsEquivalentTo([sessionId]); @@ -223,7 +223,7 @@ public async Task Reactivation_ViaNonSessionStartHook_SpawnsAFreshWatcher() { var spool = new HookSpool(tmp.PathTo("spool")); var body = $$"""{"hook_event_name":"postToolUse","session_id":"{{sessionId}}","transcript_path":"{{transcriptPath.Replace(@"\", @"\\")}}","tool_name":"Bash"}"""; - var exit = await new CursorHookCommand(Config.Root, Resolutions.At(server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore(client, new StringReader(body), spool); + var exit = await new CursorHookCommand(Config.Root, Resolutions.At(server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(client, new StringReader(body), spool); await Assert.That(exit).IsEqualTo(0); await Assert.That(spawner.Keys).IsEquivalentTo([sessionId]); diff --git a/test/Capacitor.Cli.Tests.Integration/GeminiSessionStartHandshakeOnPostFailureTests.cs b/test/Capacitor.Cli.Tests.Integration/GeminiSessionStartHandshakeOnPostFailureTests.cs index 8ca961fec..e71d96548 100644 --- a/test/Capacitor.Cli.Tests.Integration/GeminiSessionStartHandshakeOnPostFailureTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/GeminiSessionStartHandshakeOnPostFailureTests.cs @@ -100,7 +100,7 @@ public async Task A_rejected_lifecycle_post_still_delivers_the_memory_index_and_ // The real memory factory, resolving against this test's own config root: discovery finds no // /auth/config, falls back to a token store that holds nothing, and hands back an // unauthenticated client — which is exactly what the stub wants, without a seam. - var exit = await new GeminiHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()) + var exit = await new GeminiHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)) .Handle(new StringReader(payload)); return (exit, capture.GetCapturedOutput()); diff --git a/test/Capacitor.Cli.Tests.Integration/GeminiStderrShadowedOnPostFailureTests.cs b/test/Capacitor.Cli.Tests.Integration/GeminiStderrShadowedOnPostFailureTests.cs index 0347cf889..aa106a30d 100644 --- a/test/Capacitor.Cli.Tests.Integration/GeminiStderrShadowedOnPostFailureTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/GeminiStderrShadowedOnPostFailureTests.cs @@ -96,7 +96,7 @@ static string SelectedByGemini(string stdout, string stderr) => using var capture = ConsoleOutput.StartFullCapture(); - var exit = await new GeminiHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()) + var exit = await new GeminiHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)) .Handle(new StringReader(payload)); return (exit, capture.GetCapturedOutput(), capture.GetCapturedError()); diff --git a/test/Capacitor.Cli.Tests.Integration/SessionStartCoordinationNoticesTests.cs b/test/Capacitor.Cli.Tests.Integration/SessionStartCoordinationNoticesTests.cs index c2c44ffb0..0b9a5c002 100644 --- a/test/Capacitor.Cli.Tests.Integration/SessionStartCoordinationNoticesTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/SessionStartCoordinationNoticesTests.cs @@ -84,7 +84,7 @@ public async Task Advertises_the_capability_and_renders_returned_notices() { """); var stdout = new StringWriter(); - await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).Handle(new StringReader(Payload()), stdout: stdout); + await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(Payload()), stdout: stdout); // Capability advertised on the request. await Assert.That(PostedBody()["coordination_notices"]?.GetValue()).IsEqualTo("v1"); @@ -103,7 +103,7 @@ public async Task Opt_out_suppresses_capability_and_render() { GivenServerReturns("""{ "coordination_notices": [ { "text": "someone else is on this bug" } ] }"""); var stdout = new StringWriter(); - await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).Handle(new StringReader(Payload()), stdout: stdout); + await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(Payload()), stdout: stdout); await Assert.That(PostedBody()["coordination_notices"]).IsNull(); await Assert.That(stdout.ToString()).DoesNotContain("## Coordination notices"); @@ -117,7 +117,7 @@ public async Task Malformed_notices_field_does_not_fail_the_hook() { GivenServerReturns("""{ "coordination_notices": "v1" }"""); var stdout = new StringWriter(); - await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).Handle(new StringReader(Payload()), stdout: stdout); + await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(Payload()), stdout: stdout); // Capability still advertised; render silently emits nothing (fail-open, no crash). await Assert.That(PostedBody()["coordination_notices"]?.GetValue()).IsEqualTo("v1"); diff --git a/test/Capacitor.Cli.Tests.Integration/SessionStartMemoryRedirectTests.cs b/test/Capacitor.Cli.Tests.Integration/SessionStartMemoryRedirectTests.cs index f1fb656ef..86ef8614a 100644 --- a/test/Capacitor.Cli.Tests.Integration/SessionStartMemoryRedirectTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/SessionStartMemoryRedirectTests.cs @@ -92,7 +92,7 @@ public async Task The_memory_index_fetch_does_not_follow_a_redirect() { using var capture = ConsoleOutput.StartCapture(); - var exit = await new GeminiHookCommand(Config.Root, profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, sp.GetRequiredService(), TestWatchers.For(Config.Root, profiles, sp.GetRequiredService()), router: new GitProviderRouter()) + var exit = await new GeminiHookCommand(Config.Root, profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, sp.GetRequiredService(), TestWatchers.For(Config.Root, profiles, sp.GetRequiredService()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)) .Handle(new StringReader(payload)); return (exit, capture.GetCapturedOutput()); diff --git a/test/Capacitor.Cli.Tests.Integration/SessionStartVisibilityTests.cs b/test/Capacitor.Cli.Tests.Integration/SessionStartVisibilityTests.cs index 6c75f4943..1acd07317 100644 --- a/test/Capacitor.Cli.Tests.Integration/SessionStartVisibilityTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/SessionStartVisibilityTests.cs @@ -52,7 +52,7 @@ public async Task Stamps_private_visibility_from_active_profile_v2_config() { _server.Given(Request.Create().WithPath("/hooks/session-start").UsingPost()) .RespondWith(Response.Create().WithStatusCode(200).WithBody("{}")); - await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath())); + await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath())); var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start").UsingPost()); await Assert.That(requests.Count).IsEqualTo(1); @@ -77,7 +77,7 @@ public async Task Lowercases_mixedcase_visibility_from_v2_config() { _server.Given(Request.Create().WithPath("/hooks/session-start").UsingPost()) .RespondWith(Response.Create().WithStatusCode(200).WithBody("{}")); - await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath())); + await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath())); var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start").UsingPost()); await Assert.That(requests.Count).IsEqualTo(1); @@ -102,7 +102,7 @@ public async Task Falls_back_to_org_public_when_v2_config_visibility_is_invalid( _server.Given(Request.Create().WithPath("/hooks/session-start").UsingPost()) .RespondWith(Response.Create().WithStatusCode(200).WithBody("{}")); - await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath())); + await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath())); var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start").UsingPost()); await Assert.That(requests.Count).IsEqualTo(1); @@ -125,7 +125,7 @@ public async Task Stamps_harness_inventory_onto_session_start_body() { _server.Given(Request.Create().WithPath("/hooks/session-start").UsingPost()) .RespondWith(Response.Create().WithStatusCode(200).WithBody("{}")); - await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath())); + await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath())); var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start").UsingPost()); await Assert.That(requests.Count).IsEqualTo(1); @@ -165,7 +165,7 @@ public async Task Skips_session_start_when_repo_is_excluded_by_active_profile_v2 } """; - await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).Handle(new StringReader(payload)); + await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start").UsingPost()); await Assert.That(requests.Count).IsEqualTo(0); diff --git a/test/Capacitor.Cli.Tests.Integration/SpoolOutageRecoveryTests.cs b/test/Capacitor.Cli.Tests.Integration/SpoolOutageRecoveryTests.cs index 93279d21f..d933f7fe1 100644 --- a/test/Capacitor.Cli.Tests.Integration/SpoolOutageRecoveryTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/SpoolOutageRecoveryTests.cs @@ -70,7 +70,7 @@ static string StopPayload(string sid = Sid) => // HandleCore takes a pre-built HttpClient so we bypass auth entirely. Task Invoke(HttpClient client, string payload) => - new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()) + new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)) .HandleCore(client, AuthStatus.Ok, MakeSpool(), new StringReader(payload)); IEnumerable SpoolFiles => diff --git a/test/Capacitor.Cli.Tests.Unit.NativeTestHost/Program.cs b/test/Capacitor.Cli.Tests.Unit.NativeTestHost/Program.cs index c9ae482d8..277f7bf2e 100644 --- a/test/Capacitor.Cli.Tests.Unit.NativeTestHost/Program.cs +++ b/test/Capacitor.Cli.Tests.Unit.NativeTestHost/Program.cs @@ -14,7 +14,7 @@ // the outer test kills (SIGKILL) to observe PDEATHSIG, so a graceful Dispose() never runs // and never needs to. var factory = new UnixPtyProcessFactory(new UnixSpawnerThread()); - var proc = factory.Spawn("sleep", ["30"], Directory.GetCurrentDirectory()); + var proc = factory.Spawn("sleep", ["30"], AppContext.BaseDirectory); Console.WriteLine($"PID={proc.Pid}"); Console.Out.Flush(); Thread.Sleep(Timeout.Infinite); // block until the outer test kills THIS process @@ -54,7 +54,7 @@ using var spawner = new UnixSpawnerThread(); var factory = new UnixPtyProcessFactory(spawner); - var child = factory.Spawn("sleep", ["5"], Directory.GetCurrentDirectory()); + var child = factory.Spawn("sleep", ["5"], AppContext.BaseDirectory); try { var childIdentity = child.StartIdentity; if (string.IsNullOrEmpty(childIdentity)) { diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/CommandContainerTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/CommandContainerTests.cs index 7b882d282..985eeafd1 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/CommandContainerTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/CommandContainerTests.cs @@ -2,6 +2,7 @@ using Capacitor.Cli.Commands; using Capacitor.Cli.Core.Config; using Microsoft.Extensions.DependencyInjection; +using Capacitor.Cli.Core; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -19,7 +20,7 @@ public class CommandContainerTests { ServiceProvider Build(string? baseUrl) => new ServiceCollection() .AddCapacitorCli( - Config.Root, Home, Daemons.Store, + Config.Root, Home, new WorkingDirectory(AppContext.BaseDirectory), Daemons.Store, baseUrl is null ? Resolutions.None(Config.Root) : Resolutions.At(baseUrl, Config.Root), ProfileOverrides.None, MachineAuth.None, AuthEndpoints.Defaults, new HookClock(TimeProvider.System), baseUrl, NoTelemetry.Startup) diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/FlowsDriverSchemaConformanceTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/FlowsDriverSchemaConformanceTests.cs index 265ddf00a..c7a6db4d4 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/FlowsDriverSchemaConformanceTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/FlowsDriverSchemaConformanceTests.cs @@ -314,7 +314,7 @@ static async Task InstallAndRead(Arm arm) { // rewriting the profile's network-access config. ? ["plugin", "install", arm.Flag, "--skip-codex-network-access"] : ["plugin", "install", arm.Flag, "--if-installed"]; - var exit = await new PluginCommand(env).HandleAsync(argv); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(argv); if (exit != 0) throw new InvalidOperationException($"{arm.Name}: installer exited {exit}"); var path = arm.ConfigPath(env); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/AntigravityHookCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/AntigravityHookCommandTests.cs index 6fb270026..11c50331d 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/AntigravityHookCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/AntigravityHookCommandTests.cs @@ -35,7 +35,7 @@ public async Task EventArg_is_null_when_missing_or_a_flag_follows() { [Test] public async Task Missing_event_exits_zero_without_touching_network() { // Control hooks must always exit 0 so Antigravity doesn't treat the hook as failed. - var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["hook", "--antigravity"], new StringReader(""), new StringWriter()); + var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["hook", "--antigravity"], new StringReader(""), new StringWriter()); await Assert.That(rc).IsEqualTo(0); } @@ -43,7 +43,7 @@ public async Task Missing_event_exits_zero_without_touching_network() { public async Task PreInvocation_with_non_string_fields_fails_open() { // conversationId as a non-string shape must not throw (GetValue would); // it fails open to a no-op. - var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["hook", "--antigravity", "PreInvocation"], + var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["hook", "--antigravity", "PreInvocation"], new StringReader("""{"conversationId":123,"transcriptPath":{"nested":true}}"""), new StringWriter()); await Assert.That(rc).IsEqualTo(0); @@ -56,14 +56,14 @@ public async Task PreInvocation_with_non_string_fields_fails_open() { [Arguments("PostToolUse")] public async Task Non_PreInvocation_events_are_no_ops(string ev) { // These must return 0 and never read stdin / hit the network. - var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["hook", "--antigravity", ev], + var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["hook", "--antigravity", ev], new ThrowingReader(), new StringWriter()); await Assert.That(rc).IsEqualTo(0); } [Test] public async Task PreInvocation_with_malformed_payload_fails_open() { - var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["hook", "--antigravity", "PreInvocation"], + var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["hook", "--antigravity", "PreInvocation"], new StringReader("{ not json"), new StringWriter()); await Assert.That(rc).IsEqualTo(0); } @@ -71,11 +71,11 @@ public async Task PreInvocation_with_malformed_payload_fails_open() { [Test] public async Task PreInvocation_without_conversation_or_transcript_is_a_no_op() { // No conversationId → nothing to key on. - await Assert.That(await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["hook", "--antigravity", "PreInvocation"], + await Assert.That(await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["hook", "--antigravity", "PreInvocation"], new StringReader("""{"transcriptPath":"/t.jsonl"}"""), new StringWriter())).IsEqualTo(0); // conversationId but no transcriptPath → nothing to tail. - await Assert.That(await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["hook", "--antigravity", "PreInvocation"], + await Assert.That(await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["hook", "--antigravity", "PreInvocation"], new StringReader("""{"conversationId":"abc"}"""), new StringWriter())).IsEqualTo(0); } diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs index 554b9d7c0..719d93bf6 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs @@ -66,7 +66,7 @@ public async Task pre_tool_use_is_decided_before_any_client_is_created() { "version: 1\nrules:\n - match: { kind: shell, command: \"git push --force*\" }\n outcome: deny\n"); var stdout = new StringWriter(); - var exit = await new ClaudeHookCommand(Config.Root, fx.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, fx.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()) + var exit = await new ClaudeHookCommand(Config.Root, fx.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, fx.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)) .HandleWithDeps(fx.Spool, new StringReader( $$"""{"hook_event_name":"PreToolUse","session_id":"{{Sid}}","tool_name":"Bash","tool_input":{"command":"git push --force"},"cwd":"/tmp"}"""), () => throw new InvalidOperationException("the seam must decide before a client exists"), @@ -91,7 +91,7 @@ public async Task pre_tool_use_fails_open_when_the_branch_throws() { "version: 1\nrules:\n - match: { kind: shell, command: \"git push --force*\" }\n outcome: deny\n"); var stdout = new ClosedPipeWriter(); - var exit = await new ClaudeHookCommand(Config.Root, fx.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, fx.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()) + var exit = await new ClaudeHookCommand(Config.Root, fx.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, fx.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)) .HandleWithDeps(fx.Spool, new StringReader( $$"""{"hook_event_name":"PreToolUse","session_id":"{{Sid}}","tool_name":"Bash","tool_input":{"command":"git push --force"},"cwd":"/tmp"}"""), () => throw new InvalidOperationException("the seam must decide before a client exists"), @@ -114,7 +114,7 @@ public async Task session_start_surfaces_a_degraded_policy_snapshot() { using var fx = new Fixture(Config.Root); var stdout = new StringWriter { NewLine = "\n" }; - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( $$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""), stdout: stdout); @@ -135,7 +135,7 @@ public async Task session_start_merges_a_policy_degradation_into_the_401_notice( using var fx = new Fixture(Config.Root, HttpStatusCode.Unauthorized); var stdout = new StringWriter { NewLine = "\n" }; - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( $$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""), stdout: stdout); @@ -160,7 +160,7 @@ public async Task session_start_freezes_the_policy_snapshot_on_the_degraded_arm( var stdout = new StringWriter { NewLine = "\n" }; // Unusable URL: no client is ever built, so HandleWithDeps returns from the degraded arm. - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("not-a-url", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("not-a-url", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()) + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("not-a-url", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("not-a-url", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)) .HandleWithDeps(fx.Spool, new StringReader( $$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""), () => throw new InvalidOperationException("no client is buildable for an unusable URL"), @@ -182,7 +182,7 @@ public async Task stop_clears_the_turn_journal() { await Assert.That(File.Exists(Config.Root.Path("policy", "journal", $"{Sid}.json"))).IsTrue(); using var fx = new Fixture(Config.Root); - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( $$"""{"hook_event_name":"Stop","session_id":"{{Sid}}","cwd":"/tmp"}""")); @@ -231,7 +231,7 @@ public async Task memory_store_initialization_failure_does_not_suppress_session_ using var fx = new Fixture(Config.Root); MemoryStoreProbe.Poison(Config.Root); - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( $$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}""")); @@ -243,7 +243,7 @@ public async Task memory_store_initialization_failure_does_not_suppress_session_ public async Task disabled_memory_index_does_not_construct_the_lease_store() { using var fx = new Fixture(Config.Root); fx.MemoryIndexBody = """[{"memory_id":"m1","slug":"s","audience":"org","description":"d","kind":"preference"}]"""; // decoy — must never be fetched - var hook = new ClaudeHookCommand(Config.Root, Resolutions.Of(new Profile { DisableMemoryIndex = true }, serverUrl: "http://localhost"), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.Of(new Profile { DisableMemoryIndex = true }, serverUrl: "http://localhost"), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()); + var hook = new ClaudeHookCommand(Config.Root, Resolutions.Of(new Profile { DisableMemoryIndex = true }, serverUrl: "http://localhost"), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.Of(new Profile { DisableMemoryIndex = true }, serverUrl: "http://localhost"), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)); var exit = await hook.HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( @@ -264,7 +264,7 @@ public async Task update_check_off_suppresses_the_in_agent_nudge_even_when_serve using var fx = new Fixture(Config.Root) { RespondJson = """{"version": "999.0.0"}""" }; var stdout = new StringWriter(); - var hook = new ClaudeHookCommand(Config.Root, Resolutions.Of(new Profile { UpdateCheck = false }, serverUrl: fx.MemoryServerUrl), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.Of(new Profile { UpdateCheck = false }, serverUrl: fx.MemoryServerUrl), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()); + var hook = new ClaudeHookCommand(Config.Root, Resolutions.Of(new Profile { UpdateCheck = false }, serverUrl: fx.MemoryServerUrl), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.Of(new Profile { UpdateCheck = false }, serverUrl: fx.MemoryServerUrl), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)); var exit = await hook.HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( @@ -287,7 +287,7 @@ public async Task update_check_on_still_emits_the_in_agent_nudge_for_a_newer_ser using var fx = new Fixture(Config.Root) { RespondJson = """{"version": "999.0.0"}""" }; var stdout = new StringWriter(); - var hook = new ClaudeHookCommand(Config.Root, Resolutions.Of(new Profile { UpdateCheck = true }, serverUrl: fx.MemoryServerUrl), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.Of(new Profile { UpdateCheck = true }, serverUrl: fx.MemoryServerUrl), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()); + var hook = new ClaudeHookCommand(Config.Root, Resolutions.Of(new Profile { UpdateCheck = true }, serverUrl: fx.MemoryServerUrl), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.Of(new Profile { UpdateCheck = true }, serverUrl: fx.MemoryServerUrl), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)); var exit = await hook.HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( @@ -760,7 +760,7 @@ public async Task session_start_on_401_exits_zero_and_nudges_the_user_to_log_in( using var fx = new Fixture(Config.Root, HttpStatusCode.Unauthorized); var stdout = new StringWriter { NewLine = "\n" }; - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( $$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""), @@ -782,7 +782,7 @@ public async Task session_start_on_401_is_spooled_for_replay_after_login() { using var fx = new Fixture(Config.Root, HttpStatusCode.Unauthorized); var stdout = new StringWriter { NewLine = "\n" }; - await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( + await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( $$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""), stdout: stdout); @@ -821,7 +821,7 @@ public async Task session_end_whose_spool_write_fails_reports_the_drop_not_a_spo var unwritable = new HookSpool(tmp.PathTo("blocker", "spool")); // a directory under a file using var capture = ConsoleOutput.StartErrorCapture("\n"); - await new ClaudeHookCommand(Config.Root, fx.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, fx.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( + await new ClaudeHookCommand(Config.Root, fx.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, fx.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( fx.Client, AuthStatus.Ok, unwritable, new StringReader( $$"""{"hook_event_name":"SessionEnd","session_id":"{{Sid}}","transcript_path":"/none","cwd":"/tmp"}""")); @@ -839,13 +839,13 @@ public async Task spooled_entry_that_hits_a_401_on_drain_is_replayed_once_the_se rejecting.Spool.Append(Sid, "session-end", $$"""{"session_id":"{{Sid}}"}"""); var stdout = new StringWriter { NewLine = "\n" }; - await new ClaudeHookCommand(Config.Root, rejecting.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, rejecting.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( + await new ClaudeHookCommand(Config.Root, rejecting.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, rejecting.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( rejecting.Client, AuthStatus.Ok, rejecting.Spool, new StringReader( $$"""{"hook_event_name":"Stop","session_id":"{{Sid}}","transcript_path":"/none","cwd":"/tmp"}"""), stdout: stdout); await Assert.That(rejecting.Spool.HasBacklog(Sid)).IsTrue(); - await new ClaudeHookCommand(Config.Root, accepting.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, accepting.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( + await new ClaudeHookCommand(Config.Root, accepting.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, accepting.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( accepting.Client, AuthStatus.Ok, rejecting.Spool, new StringReader( $$"""{"hook_event_name":"Stop","session_id":"{{Sid}}","transcript_path":"/none","cwd":"/tmp"}"""), stdout: stdout); @@ -887,7 +887,7 @@ public async Task session_end_spooled_when_client_creation_exceeds_budget() { // 13.4s already elapsed → session-end remaining = 15 - 13.4 - 1.5 ≈ 0.1s cap. var sw = System.Diagnostics.Stopwatch.StartNew(); - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), Aged(TimeSpan.FromSeconds(13.4)), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleWithDeps( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), Aged(TimeSpan.FromSeconds(13.4)), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleWithDeps( fx.Spool, new StringReader($$"""{"hook_event_name":"SessionEnd","session_id":"{{Sid}}","transcript_path":"/none","cwd":"/tmp"}"""), slowFactory); @@ -1009,7 +1009,7 @@ public async Task subagent_stop_spooled_when_client_creation_exceeds_budget() { Task.Delay(TimeSpan.FromSeconds(30)).ContinueWith(_ => new AuthAttempt(new HttpClient(), AuthStatus.Ok), TaskScheduler.Default); // 3.4s already elapsed → subagent-stop remaining = 5 - 3.4 - 1.5 ≈ 0.1s cap. var sw = System.Diagnostics.Stopwatch.StartNew(); - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), Aged(TimeSpan.FromSeconds(3.4)), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleWithDeps( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), Aged(TimeSpan.FromSeconds(3.4)), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleWithDeps( fx.Spool, new StringReader($$"""{"hook_event_name":"SubagentStop","session_id":"{{Sid}}","agent_id":"{{AgentId}}","transcript_path":"/none","cwd":"/tmp"}"""), slowFactory); @@ -1082,7 +1082,7 @@ public async Task session_start_with_expired_auth_exits_zero_and_emits_the_expir using var fx = new Fixture(Config.Root); var stdout = new StringWriter { NewLine = "\n" }; - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( fx.Client, AuthStatus.Expired, fx.Spool, new StringReader( $$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""), @@ -1098,7 +1098,7 @@ public async Task session_start_with_wrong_server_auth_exits_zero_and_emits_the_ using var fx = new Fixture(Config.Root); var stdout = new StringWriter { NewLine = "\n" }; - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( fx.Client, AuthStatus.WrongServer, fx.Spool, new StringReader( $$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""), @@ -1114,7 +1114,7 @@ public async Task stop_with_expired_auth_exits_zero_without_a_notice() { using var fx = new Fixture(Config.Root); var stdout = new StringWriter { NewLine = "\n" }; - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( fx.Client, AuthStatus.Expired, fx.Spool, new StringReader( $$"""{"hook_event_name":"Stop","session_id":"{{Sid}}","cwd":"/tmp"}"""), stdout: stdout); @@ -1135,7 +1135,7 @@ public async Task stop_on_401_exits_zero_and_nudges_the_user_to_log_in() { using var fx = new Fixture(Config.Root, HttpStatusCode.Unauthorized); var stdout = new StringWriter { NewLine = "\n" }; - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( $$"""{"hook_event_name":"Stop","session_id":"{{Sid}}","cwd":"/tmp"}"""), stdout: stdout); @@ -1151,7 +1151,7 @@ public async Task notification_on_401_exits_zero_without_a_notice() { using var fx = new Fixture(Config.Root, HttpStatusCode.Unauthorized); var stdout = new StringWriter { NewLine = "\n" }; - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( $$"""{"hook_event_name":"Notification","session_id":"{{Sid}}","cwd":"/tmp"}"""), stdout: stdout); @@ -1167,7 +1167,7 @@ public async Task stop_on_500_still_exits_non_zero_without_a_notice() { using var fx = new Fixture(Config.Root, HttpStatusCode.InternalServerError); var stdout = new StringWriter { NewLine = "\n" }; - var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( + var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, new StringReader( $$"""{"hook_event_name":"Stop","session_id":"{{Sid}}","cwd":"/tmp"}"""), stdout: stdout); @@ -1261,7 +1261,7 @@ public Fixture(ConfigRoot config, HttpStatusCode postStatus = HttpStatusCode.OK, public Task HandleAsync(string stdin, TimeSpan elapsed = default) { StubMemoryServer(); - return new ClaudeHookCommand(Config, Profiles, Aged(elapsed), _home, TestHarnesses.Under(_home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config, Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter()).HandleCore( + return new ClaudeHookCommand(Config, Profiles, Aged(elapsed), _home, TestHarnesses.Under(_home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config, Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( Client, AuthStatus.Ok, Spool, new StringReader(stdin)); } diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookInputWaitRelayTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookInputWaitRelayTests.cs index 66d766edb..191ee55b1 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookInputWaitRelayTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookInputWaitRelayTests.cs @@ -40,7 +40,7 @@ static HookClock Aged(TimeSpan elapsed) { async Task RunAsync(HostedAgent hosted, string eventName, HookClock? clock = null, string extraFields = "") { using var client = new HttpClient(new OkHandler()); var payload = $$$"""{"hook_event_name":"{{{eventName}}}","session_id":"{{{Sid}}}","cwd":"/tmp","tool_name":"Bash","tool_input":{"command":"ls"}{{{extraFields}}}}"""; - return await new ClaudeHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), clock ?? new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()) + return await new ClaudeHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), clock ?? new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)) .HandleWithDeps(new HookSpool(Config.Root), new StringReader(payload), () => Task.FromResult(new AuthAttempt(client, AuthStatus.Ok, null, null)), new StringWriter()); } diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookCommandTests.cs index dadf977d0..f3a9b4cff 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookCommandTests.cs @@ -47,7 +47,7 @@ public async Task SessionStart_posts_to_session_start_codex_with_normalized_sess } """; - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); @@ -94,7 +94,7 @@ public async Task Stop_posts_to_hooks_stop_and_emits_continue_json() { using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); // Stop now POSTs /hooks/stop exactly once, carrying the full payload. @@ -138,7 +138,7 @@ public async Task Stop_still_emits_continue_json_when_server_is_slow() { using var capture = ConsoleOutput.StartCapture(); var sw = System.Diagnostics.Stopwatch.StartNew(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); sw.Stop(); await Assert.That(exit).IsEqualTo(0); @@ -171,7 +171,7 @@ public async Task Stop_still_emits_continue_json_when_auth_discovery_is_slow() { using var capture = ConsoleOutput.StartCapture(); var sw = System.Diagnostics.Stopwatch.StartNew(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); sw.Stop(); await Assert.That(exit).IsEqualTo(0); @@ -199,7 +199,7 @@ public async Task Stop_with_malformed_base_url_still_emits_continue_and_returns_ // Scheme-less URL → IsAcceptableUrl is false → PostBestEffortAsync returns // before any client is built, spending no budget or lease. - var exit = await new CodexHookCommand(Config.Root, Resolutions.At("localhost:5108", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("localhost:5108", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At("localhost:5108", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("localhost:5108", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); var doc = JsonDocument.Parse(capture.GetCapturedOutput()); @@ -235,7 +235,7 @@ public async Task PermissionRequest_records_event_and_yields_decision_to_codex() using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); @@ -280,7 +280,7 @@ public async Task PermissionRequest_returns_quickly_when_server_is_slow() { using var capture = ConsoleOutput.StartCapture(); var sw = System.Diagnostics.Stopwatch.StartNew(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); sw.Stop(); await Assert.That(exit).IsEqualTo(0); @@ -321,7 +321,7 @@ public async Task PermissionRequest_returns_quickly_when_auth_discovery_is_slow( using var capture = ConsoleOutput.StartCapture(); var sw = System.Diagnostics.Stopwatch.StartNew(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); sw.Stop(); await Assert.That(exit).IsEqualTo(0); @@ -342,7 +342,7 @@ public async Task UserPromptSubmit_PreToolUse_PostToolUse_are_swallowed() { } """; - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); } @@ -354,7 +354,7 @@ public async Task UserPromptSubmit_PreToolUse_PostToolUse_are_swallowed() { public async Task Unknown_event_returns_zero_and_no_request() { var payload = """{"hook_event_name": "BogusEvent", "session_id": "abc"}"""; - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); await Assert.That(_server.LogEntries.Count).IsEqualTo(0); @@ -364,7 +364,7 @@ public async Task Unknown_event_returns_zero_and_no_request() { public async Task Missing_hook_event_name_returns_zero_silently() { var payload = """{"session_id": "abc"}"""; - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); await Assert.That(_server.LogEntries.Count).IsEqualTo(0); @@ -374,7 +374,7 @@ public async Task Missing_hook_event_name_returns_zero_silently() { public async Task Malformed_json_returns_zero_silently() { var payload = "{not json"; - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); } @@ -384,7 +384,7 @@ public async Task Malformed_json_returns_zero_silently() { public async Task Hook_event_name_as_number_returns_zero_without_crash() { var payload = """{"hook_event_name": 99, "session_id": "abc", "transcript_path": "/tmp/r.jsonl"}"""; - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); await Assert.That(_server.LogEntries.Count).IsEqualTo(0); @@ -403,7 +403,7 @@ public async Task Stop_with_numeric_session_id_returns_zero_without_crash() { _server.Given(Request.Create().WithPath("/hooks/session-end/codex").UsingPost()) .RespondWith(Response.Create().WithStatusCode(200).WithBody("{}")); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); @@ -448,7 +448,7 @@ public async Task Handle_skips_dispatch_when_session_is_disabled() { using var capture = ConsoleOutput.StartCapture(); try { - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); @@ -481,7 +481,7 @@ public async Task Stop_skips_the_watcher_for_an_envelope_sourced_hosted_session( Environment.SetEnvironmentVariable("KCAP_HOSTED_APPSERVER", "1"); var payload = """{"hook_event_name":"Stop","session_id":"g1-stop-suppressed","transcript_path":"/tmp/r.jsonl","cwd":"/tmp"}"""; - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); // The watcher never spawns for an envelope-sourced session... @@ -513,7 +513,7 @@ public async Task Stop_spawns_the_watcher_without_the_hosted_appserver_marker() Environment.SetEnvironmentVariable("KCAP_HOSTED_APPSERVER", null); var payload = """{"hook_event_name":"Stop","session_id":"g1-stop-control","transcript_path":"/tmp/r.jsonl","cwd":"/tmp"}"""; - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); await Assert.That(spawner.Spawned.Count).IsEqualTo(1); @@ -535,7 +535,7 @@ public async Task SessionStart_skips_the_watcher_for_an_envelope_sourced_hosted_ Environment.SetEnvironmentVariable("KCAP_HOSTED_APPSERVER", "1"); var payload = """{"hook_event_name":"SessionStart","session_id":"g1-start-suppressed","transcript_path":"/tmp/r.jsonl","cwd":"/tmp"}"""; - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); // No watcher for an envelope-sourced session; the session-start POST is untouched. @@ -571,7 +571,7 @@ public async Task PermissionRequest_with_daemon_url_set_posts_to_bridge_and_forw using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") + var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") ); await Assert.That(exit).IsEqualTo(0); @@ -593,7 +593,7 @@ public async Task PermissionRequest_in_an_envelope_sourced_hosted_session_yields using var marker = EnvScope.Exclusive("KCAP_HOSTED_APPSERVER", "1"); using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()) + var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)) .Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1","tool_name":"shell","tool_input":{"command":"ls"}}""")); await Assert.That(exit).IsEqualTo(0); @@ -613,7 +613,7 @@ public async Task PermissionRequest_with_daemon_url_emits_deny_and_exits_nonzero using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") + var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") ); await Assert.That(exit).IsEqualTo(1); @@ -627,7 +627,7 @@ public async Task PermissionRequest_with_daemon_url_emits_deny_on_connection_ref using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") + var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") ); await Assert.That(exit).IsEqualTo(1); @@ -641,7 +641,7 @@ public async Task PermissionRequest_with_non_loopback_daemon_url_emits_deny_with using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") + var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") ); await Assert.That(exit).IsEqualTo(1); @@ -663,7 +663,7 @@ public async Task PermissionRequest_with_a_blank_daemon_url_yields_to_codex_rath var hosted = new HostedAgent(null, IsRendered: false, DaemonBridge.Parse("")); using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()) + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)) .Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1","tool_name":"shell","tool_input":{"command":"ls"}}""")); await Assert.That(exit).IsEqualTo(0); @@ -677,7 +677,7 @@ public async Task PermissionRequest_with_https_daemon_url_emits_deny_without_pos using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") + var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") ); await Assert.That(exit).IsEqualTo(1); @@ -705,7 +705,7 @@ public async Task PermissionRequest_with_daemon_url_does_not_double_post_to_serv using var capture = ConsoleOutput.StartCapture(); - await new CodexHookCommand(Config.Root, Resolutions.At($"http://127.0.0.1:{server.Ports[0]}", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At($"http://127.0.0.1:{server.Ports[0]}", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") + await new CodexHookCommand(Config.Root, Resolutions.At($"http://127.0.0.1:{server.Ports[0]}", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At($"http://127.0.0.1:{server.Ports[0]}", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""") ); await Assert.That(server.LogEntries.Count).IsEqualTo(0); // server NOT touched @@ -726,7 +726,7 @@ public async Task KcapSkip_SessionStart_emits_continue_json_and_skips_server() { using var capture = ConsoleOutput.StartCapture(); try { - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader("""{"hook_event_name":"SessionStart","session_id":"abc","cwd":"/tmp","transcript_path":"/tmp/r.jsonl"}""")); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader("""{"hook_event_name":"SessionStart","session_id":"abc","cwd":"/tmp","transcript_path":"/tmp/r.jsonl"}""")); await Assert.That(exit).IsEqualTo(0); @@ -747,7 +747,7 @@ public async Task KcapSkip_Stop_emits_continue_json_and_skips_server() { using var capture = ConsoleOutput.StartCapture(); try { - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader("""{"hook_event_name":"Stop","session_id":"abc","cwd":"/tmp","transcript_path":"/tmp/r.jsonl"}""")); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader("""{"hook_event_name":"Stop","session_id":"abc","cwd":"/tmp","transcript_path":"/tmp/r.jsonl"}""")); await Assert.That(exit).IsEqualTo(0); @@ -771,7 +771,7 @@ public async Task KcapSkip_PermissionRequest_emits_empty_object_and_skips_server using var capture = ConsoleOutput.StartCapture(); try { - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"abc","tool_name":"shell"}""")); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"abc","tool_name":"shell"}""")); await Assert.That(exit).IsEqualTo(0); @@ -794,7 +794,7 @@ public async Task KcapSkip_PreToolUse_is_silent_and_skips_server() { using var capture = ConsoleOutput.StartCapture(); try { - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader("""{"hook_event_name":"PreToolUse","session_id":"abc","tool_name":"shell"}""")); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader("""{"hook_event_name":"PreToolUse","session_id":"abc","tool_name":"shell"}""")); await Assert.That(exit).IsEqualTo(0); await Assert.That(capture.GetCapturedOutput()).IsEqualTo(string.Empty); @@ -843,7 +843,7 @@ public async Task The_permission_request_bridge_draws_the_loopback_lane() { using var capture = ConsoleOutput.StartCapture(); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, http, TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), http), router: new GitProviderRouter()).Handle(new StringReader(payload)); + var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, http, TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), http), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); await Assert.That(exit).IsEqualTo(0); await Assert.That(http.Lanes).IsEquivalentTo(new[] { "Loopback" }); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookInputWaitRelayTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookInputWaitRelayTests.cs index 8d44a113e..68e559228 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookInputWaitRelayTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookInputWaitRelayTests.cs @@ -28,7 +28,7 @@ public async Task Hosted_turn_boundaries_relay_to_the_daemon_bridge(string event using var capture = ConsoleOutput.StartCapture(); var hosted = new HostedAgent("agent-1", IsRendered: false, new DaemonBridge.Loopback($"http://127.0.0.1:{bridge.Ports[0]}/tok")); - var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()) + var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)) .Handle(new StringReader($$"""{"hook_event_name":"{{eventName}}","session_id":"019e0322-05fc-7570-be65-75719c3ea861","cwd":"/tmp"}""")); await Assert.That(exit).IsEqualTo(0); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CursorHookCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CursorHookCommandTests.cs index 152d09167..2fb931fb7 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CursorHookCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CursorHookCommandTests.cs @@ -127,7 +127,7 @@ public async Task spooled_entry_that_hits_a_401_on_drain_is_replayed_once_the_se await rejecting.HandleAsync($$"""{"hook_event_name":"postToolUse","session_id":"{{Sid}}","tool_name":"Glob"}"""); await Assert.That(rejecting.Spool.HasBacklog(Sid)).IsTrue(); - await new CursorHookCommand(Config.Root, accepting.Profiles, new HookClock(accepting.Clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, accepting.Profiles, new FixedCapacitorHttpClient()), router: new GitProviderRouter()).HandleCore( + await new CursorHookCommand(Config.Root, accepting.Profiles, new HookClock(accepting.Clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, accepting.Profiles, new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( accepting.Client, stdin: new StringReader($$"""{"hook_event_name":"postToolUse","session_id":"{{Sid}}","tool_name":"Glob"}"""), spool: rejecting.Spool); @@ -179,7 +179,7 @@ public async Task telemetry_hook_does_not_recovery_spawn_while_an_earlier_canoni }); using var client = new HttpClient(handler); - var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore( + var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( client, new StringReader($$"""{"hook_event_name":"postToolUse","session_id":"{{sid}}","tool_name":"Bash","transcript_path":"/tmp/{{sid}}.jsonl"}"""), spool); @@ -348,7 +348,7 @@ public async Task a_work_budget_spent_before_dispatch_still_spools_the_event() { var clock = new HookClock(spent); // anchors on construction — advance AFTER it spent.Advance(CursorHookCommand.Ceiling - HookBudget.Safety); - var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()) + var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)) .HandleWithDeps( new StringReader("""{"hook_event_name":"sessionStart","session_id":"abc"}"""), _ => Task.FromResult(new AuthAttempt(fx.Client, AuthStatus.Ok)), @@ -370,7 +370,7 @@ public async Task expired_budget_returns_zero_not_throws() { var clock = new HookClock(spent); spent.Advance(CursorHookCommand.Ceiling); - var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).HandleCore( + var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( fx.Client, new StringReader("""{"hook_event_name":"sessionStart","session_id":"abc"}"""), fx.Spool @@ -531,7 +531,7 @@ public async Task HardCap_before_resolve_emits_nothing() { // noticing. clientFactory/spoolFactory stand in for real auth/spool setup so the // test stays hermetic while still exercising the REAL entry point's cap+emit logic. var clock = new FakeTimeProvider(); - var call = new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()) + var call = new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)) .HandleWithDeps(new NeverCompletingReader(), _ => Task.FromResult(new AuthAttempt(fx.Client, AuthStatus.Ok)), () => fx.Spool); @@ -558,7 +558,7 @@ public async Task HandleCore_deadline_win_cancels_the_abandoned_inners_token() { using var fx = new Fixture(Config.Root); var reader = new CancelObservingReader(); - var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).HandleCore(fx.Client, reader, fx.Spool); + var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(fx.Client, reader, fx.Spool); await Assert.That(exit).IsEqualTo(0); // The read never resolved (no hook_event_name was ever parsed), so there is @@ -582,7 +582,7 @@ public async Task HardCap_after_resolve_sessionStart_emits_empty_once() { fx.HoldOnPost = TimeSpan.FromMilliseconds(300); var sw = System.Diagnostics.Stopwatch.StartNew(); - var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).HandleWithDeps( + var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleWithDeps( new StringReader("""{"hook_event_name":"sessionStart","session_id":"abc"}"""), _ => Task.FromResult(new AuthAttempt(fx.Client, AuthStatus.Ok)), () => fx.Spool); @@ -609,7 +609,7 @@ public async Task HardCap_during_client_setup_emits_nothing_and_no_late_write() var sw = System.Diagnostics.Stopwatch.StartNew(); var clock = new FakeTimeProvider(); - var call = new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()) + var call = new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)) .HandleWithDeps( new StringReader("""{"hook_event_name":"sessionStart","session_id":"abc"}"""), _ => neverAuths.Task, @@ -789,36 +789,30 @@ public async Task OncePerConversation() { } [Test, NotInParallel] - public async Task AbsentWorkspaceRoot_skips_provider_even_when_process_cwd_is_a_repo() { - var originalCwd = Environment.CurrentDirectory; - // A real git repo WITH a remote as the process cwd: were the guard missing, the shared - // scope resolver's Directory.GetCurrentDirectory() fallback would derive THIS repo's scope - // and fetch its (unrelated) memories into the Cursor session. The guard must prevent any fetch. + public async Task AbsentWorkspaceRoot_skips_provider_even_when_the_working_directory_is_a_repo() { + // A real git repo WITH a remote as the hook's working directory: were the guard missing, the + // shared scope resolver's fallback would derive THIS repo's scope and fetch its (unrelated) + // memories into the Cursor session. The guard must prevent any fetch. using var repoDir = MakeTempRepoWithRemote("https://github.com/example/leak-check.git"); using var capture = ConsoleOutput.StartCapture(); - try { - Environment.CurrentDirectory = repoDir; - using var fx = new Fixture(Config.Root); - fx.MemoryIndexBody = "[]"; // decoy — never fetched because the guard short-circuits first - var sid = Guid.NewGuid().ToString("N"); + using var fx = new Fixture(Config.Root) { Workdir = repoDir }; + fx.MemoryIndexBody = "[]"; // decoy — never fetched because the guard short-circuits first + var sid = Guid.NewGuid().ToString("N"); - // No workspace_roots field at all. Generous budget (see Ready_fragment_emitted's note - // on the tight ~0.5s margin at the 2s default under full-suite CPU contention). - var exit = await fx.HandleAsync( - $$"""{"hook_event_name":"sessionStart","session_id":"{{sid}}"}"""); + // No workspace_roots field at all. Generous budget (see Ready_fragment_emitted's note + // on the tight ~0.5s margin at the 2s default under full-suite CPU contention). + var exit = await fx.HandleAsync( + $$"""{"hook_event_name":"sessionStart","session_id":"{{sid}}"}"""); - await Assert.That(exit).IsEqualTo(0); - // The guard means the provider is NEVER called when no authoritative workspace root is - // supplied — so the process cwd's repo memories can never leak — and the response is {}. - await Assert.That(fx.MemoryIndexRequested).IsFalse(); - await Assert.That(capture.GetCapturedOutput()).IsEqualTo("{}\n"); - } finally { - Environment.CurrentDirectory = originalCwd; - } + await Assert.That(exit).IsEqualTo(0); + // The guard means the provider is NEVER called when no authoritative workspace root is + // supplied — so that repo's memories can never leak — and the response is {}. + await Assert.That(fx.MemoryIndexRequested).IsFalse(); + await Assert.That(capture.GetCapturedOutput()).IsEqualTo("{}\n"); } - // Creates a throwaway git repo with a controlled origin remote so a test can put the process - // cwd inside a repository the scope resolver would otherwise detect. + // Creates a throwaway git repo with a controlled origin remote, so a test can hand the hook a + // repository the scope resolver would otherwise detect. static GitRepo MakeTempRepoWithRemote(string originUrl) { var repo = GitRepo.Create(); @@ -863,7 +857,7 @@ public async Task CancelledFetch_leaves_lease_uncommitted() { // The real scope resolver runs: its git spawn is bounded by a Stopwatch, so its wall-clock // cost cannot eat a budget that only moves when this test says so. var elapsed = System.Diagnostics.Stopwatch.StartNew(); - var call = new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).HandleCore( + var call = new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( hangingClient, new StringReader(payload), fx.Spool); // Wait (bounded, real-time) for the request to ENTER the handler, then fire the budget clock. @@ -889,7 +883,7 @@ public async Task CancelledFetch_leaves_lease_uncommitted() { clock.Advance(TimeSpan.FromSeconds(31)); fx.MemoryIndexBody = "[]"; - var exit2 = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).HandleCore( + var exit2 = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( fx.Client, new StringReader(payload), fx.Spool); await Assert.That(exit2).IsEqualTo(0); // The index GET fires again on fx.Client — proving the first, cancelled attempt's @@ -1060,8 +1054,11 @@ public Fixture(ConfigRoot config, HttpStatusCode postStatus = HttpStatusCode.OK, public HostedAgent Hosted { get; init; } = HostedAgent.Terminal; + /// The checkout the hook acts on when a payload carries no workspace root. + public string Workdir { get; init; } = AppContext.BaseDirectory; + public Task HandleAsync(string stdin) => - new CursorHookCommand(Config, Profiles, new HookClock(Clock), _home, TestHarnesses.Under(_home), Hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config, Profiles, new FixedCapacitorHttpClient()), router: new GitProviderRouter()).HandleCore( + new CursorHookCommand(Config, Profiles, new HookClock(Clock), _home, TestHarnesses.Under(_home), Hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config, Profiles, new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(Workdir)).HandleCore( Client, stdin: new StringReader(stdin), spool: Spool); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/McpAnalyticsServerTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/McpAnalyticsServerTests.cs index ea9328571..5dc54ead3 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/McpAnalyticsServerTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/McpAnalyticsServerTests.cs @@ -2,6 +2,7 @@ using System.Text.Json.Nodes; using Capacitor.Cli.Commands; using Capacitor.Cli.PrDetection; +using Capacitor.Cli.Core; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -10,7 +11,7 @@ public class McpAnalyticsServerTests { // Resolutions.None: these tests exercise routing, not profile selection. McpAnalyticsServer Server() => - new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter()); + new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)); static JsonObject Args(string json) => JsonNode.Parse(json)!.AsObject(); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerReviewerVendorsTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerReviewerVendorsTests.cs index a7e4618b3..d47540e26 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerReviewerVendorsTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerReviewerVendorsTests.cs @@ -14,7 +14,7 @@ public class McpFlowsServerReviewerVendorsTests { // Resolutions.None: these tests exercise routing, not profile selection. McpFlowsServer Server() => new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), - new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter()); + new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)); static JsonObject ToolCall() => new() { ["params"] = new JsonObject { ["name"] = "list_reviewer_vendors", ["arguments"] = new JsonObject() } diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerSettlementRetryTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerSettlementRetryTests.cs index ed000025b..8ddd3f439 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerSettlementRetryTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerSettlementRetryTests.cs @@ -5,6 +5,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Capacitor.Cli.PrDetection; +using Capacitor.Cli.Core; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -30,7 +31,7 @@ public class McpFlowsServerSettlementRetryTests { // Resolutions.None: these tests exercise routing, not profile selection. McpFlowsServer Server() => new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), - new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter()); + new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)); // Every wait in both retry lanes runs on the injected clock, so these tests are instant and // the requested schedule is directly assertable (VirtualFlowRetryClock.Delays). diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerTests.cs index 66705a12e..00c7a2ad4 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerTests.cs @@ -5,6 +5,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Capacitor.Cli.PrDetection; +using Capacitor.Cli.Core; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -14,7 +15,7 @@ public class McpFlowsServerTests { // Resolutions.None: these tests exercise routing, not profile selection. McpFlowsServer Server() => new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), - new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter()); + new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)); // The ack retry's 2s wait now runs on the injected clock, so these tests stay instant while // still asserting the real schedule (VirtualFlowRetryClock.Delays records every requested wait). diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerVendorOverrideTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerVendorOverrideTests.cs index 16165bc96..4150de1b8 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerVendorOverrideTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerVendorOverrideTests.cs @@ -5,6 +5,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Capacitor.Cli.PrDetection; +using Capacitor.Cli.Core; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -18,7 +19,7 @@ public class McpFlowsServerVendorOverrideTests { // Resolutions.None: these tests exercise routing, not profile selection. McpFlowsServer Server() => new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), - new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter()); + new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)); static JsonObject StartArguments(string? vendor = null) { var args = new JsonObject { diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ParticipantUnreachableRetryTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ParticipantUnreachableRetryTests.cs index ebdb50bef..2e4e1544e 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ParticipantUnreachableRetryTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ParticipantUnreachableRetryTests.cs @@ -5,6 +5,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Capacitor.Cli.PrDetection; +using Capacitor.Cli.Core; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -31,7 +32,7 @@ public class ParticipantUnreachableRetryTests { // Resolutions.None: these tests exercise routing, not profile selection. McpFlowsServer Server() => new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), - new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter()); + new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)); static VirtualFlowRetryClock Clock() => new(); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandAntigravityTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandAntigravityTests.cs index 796249c6d..736e94c42 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandAntigravityTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandAntigravityTests.cs @@ -5,6 +5,7 @@ using Capacitor.Cli.Core.Harness.Gemini; using Capacitor.Cli.Core.Instructions; using Capacitor.Cli.Core.Mcp; +using Capacitor.Cli.Core; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -34,7 +35,7 @@ await File.WriteAllTextAsync(env.Harnesses.Of().Paths.McpCon {"mcpServers":{"my-tool":{"command":"my-tool","args":["serve"]}}} """); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--antigravity", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--antigravity", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); var servers = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.McpConfigJson))!.AsObject()["mcpServers"]!.AsObject(); @@ -59,7 +60,7 @@ public async Task install_antigravity_installs_instructions_into_shared_gemini_m Directory.CreateDirectory(Path.GetDirectoryName(env.Harnesses.Of().Paths.InstructionsMd)!); await File.WriteAllTextAsync(env.Harnesses.Of().Paths.InstructionsMd, "# My rules\n\nAlways use tabs.\n"); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--antigravity", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--antigravity", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); var content = await File.ReadAllTextAsync(env.Harnesses.Of().Paths.InstructionsMd); @@ -74,7 +75,7 @@ public async Task install_antigravity_skip_mcp_flag_leaves_config_untouched() { var env = TestEnv(home.Path); SeedStaleHooks(env); - var exit = await new PluginCommand(env).HandleAsync( + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--antigravity", "--if-installed", "--skip-antigravity-mcp"]); await Assert.That(exit).IsEqualTo(0); @@ -87,7 +88,7 @@ public async Task install_antigravity_skip_instructions_flag_leaves_gemini_md_un var env = TestEnv(home.Path); SeedStaleHooks(env); - var exit = await new PluginCommand(env).HandleAsync( + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--antigravity", "--if-installed", "--skip-antigravity-instructions"]); await Assert.That(exit).IsEqualTo(0); @@ -109,7 +110,7 @@ public async Task remove_antigravity_unregisters_mcp_and_strips_instructions() { await File.WriteAllTextAsync(env.Harnesses.Of().Paths.InstructionsMd, "# My rules\n\nAlways use tabs.\n"); AgentInstructionsWriter.Write(env.Harnesses.Of().Paths.InstructionsMd, KcapAgentInstructions.Body); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--antigravity"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--antigravity"]); await Assert.That(exit).IsEqualTo(0); var servers = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.McpConfigJson))!.AsObject()["mcpServers"]!.AsObject(); @@ -137,7 +138,7 @@ public async Task remove_antigravity_keeps_shared_instructions_when_gemini_insta await File.WriteAllTextAsync(env.Harnesses.Of().Paths.InstructionsMd, "# My rules\n\nAlways use tabs.\n"); AgentInstructionsWriter.Write(env.Harnesses.Of().Paths.InstructionsMd, KcapAgentInstructions.Body); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--antigravity"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--antigravity"]); await Assert.That(exit).IsEqualTo(0); var content = await File.ReadAllTextAsync(env.Harnesses.Of().Paths.InstructionsMd); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandClaudeTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandClaudeTests.cs index c64318ebd..c8116ebcd 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandClaudeTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandClaudeTests.cs @@ -26,7 +26,7 @@ public async Task Install_claude_with_if_installed_is_noop_when_no_marker_and_no using var fakeHome = new TempDir(); var env = TestEnv(fakeHome.Path); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); var settingsPath = fakeHome.PathTo(".claude", "settings.json"); @@ -50,7 +50,7 @@ await File.WriteAllTextAsync(settingsPath, """ var env = TestEnv(fakeHome.Path, pluginPath: pluginDir.Path); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); // Marketplace path must now point at the new plugin dir. @@ -74,7 +74,7 @@ public async Task Install_claude_with_if_installed_is_noop_when_marker_matches_c claudeDir.CreateFile(ClaudePluginInstaller.MarkerFileName, CapacitorVersion.Current()); - var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(["plugin", "install", "--if-installed"]); + var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); var root = JsonNode.Parse(await File.ReadAllTextAsync(settingsPath))!.AsObject(); @@ -95,7 +95,7 @@ public async Task Install_claude_with_if_installed_swallows_plugin_resolution_fa // …but plugin dir resolution fails (null = no plugin available). var env = TestEnv(fakeHome.Path, pluginPath: null, stderr: capturedErr); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); await Assert.That(capturedErr.ToString()).IsEmpty(); } @@ -107,7 +107,7 @@ public async Task Install_claude_fresh_prints_restart_reminder() { var stdout = new StringWriter(); var env = TestEnv(fakeHome.Path, pluginPath: pluginDir.Path, stdout: stdout); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install"]); await Assert.That(exit).IsEqualTo(0); @@ -133,7 +133,7 @@ public async Task Install_claude_refresh_omits_restart_reminder() { """); var env = TestEnv(fakeHome.Path, pluginPath: pluginDir.Path, stdout: stdout); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); @@ -155,7 +155,7 @@ public async Task Remove_claude_deletes_marker() { """); claudeDir.CreateFile(ClaudePluginInstaller.MarkerFileName, CapacitorVersion.Current()); - var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(["plugin", "remove"]); + var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove"]); await Assert.That(exit).IsEqualTo(0); await Assert.That(File.Exists(claudeDir.PathTo(ClaudePluginInstaller.MarkerFileName))).IsFalse(); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCodexTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCodexTests.cs index b02b74c6e..967db8e9d 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCodexTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCodexTests.cs @@ -374,7 +374,7 @@ public async Task Remove_returns_false_when_nothing_to_remove() { public async Task Install_codex_with_if_installed_is_noop_when_no_marker_and_no_existing_entries() { using var fakeHome = new TempDir(); - var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath())).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--codex", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); @@ -401,7 +401,7 @@ await File.WriteAllTextAsync(hooksPath, """ } """); - var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath())).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--codex", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); @@ -431,7 +431,7 @@ await File.WriteAllTextAsync( codexDir.PathTo(CodexHooksInstaller.MarkerFileName), CapacitorVersion.Current()); - var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath())).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--codex", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); @@ -500,7 +500,7 @@ public async Task InstallCodex_prints_hooks_trust_hint_after_success() { var capturedOut = new StringWriter(); var env = TestEnv(fakeHome.GetResolvedPath(), pluginRoot.Path, stdout: capturedOut); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--codex"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--codex"]); await Assert.That(exit).IsEqualTo(0); var stdout = capturedOut.ToString(); @@ -529,7 +529,7 @@ public async Task InstallCodex_fails_before_writing_hooks_when_individual_skill_ var capturedErr = new StringWriter(); var env = TestEnv(fakeHome.GetResolvedPath(), pluginRoot.Path, stderr: capturedErr); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--codex"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--codex"]); await Assert.That(exit).IsEqualTo(1); // Atomicity contract: hooks.json must NOT exist after a failed install. @@ -553,7 +553,7 @@ public async Task InstallCodex_fails_before_writing_hooks_when_plugin_folder_mis // PluginPath = null signals ResolvePluginPath returned no plugin. var env = TestEnv(fakeHome.GetResolvedPath(), pluginPath: null, stderr: capturedErr); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--codex"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--codex"]); await Assert.That(exit).IsEqualTo(1); // The atomic-install contract: NO hooks.json may exist in the @@ -571,7 +571,7 @@ public async Task InstallCodex_registers_mcp_servers_in_config_toml() { using var pluginRoot = new TempDir(); PlantFakePlugin(pluginRoot.Path); - var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath(), pluginRoot.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath(), pluginRoot.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--codex"]); await Assert.That(exit).IsEqualTo(0); @@ -593,7 +593,7 @@ public async Task InstallCodex_if_installed_registers_missing_mcp_servers_when_h var hooksPath = codexDir.CreateFile("hooks.json", """{"sentinel": "must-survive"}"""); codexDir.CreateFile(CodexHooksInstaller.MarkerFileName, CapacitorVersion.Current()); - var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath())).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--codex", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); @@ -620,7 +620,7 @@ public async Task InstallCodex_if_installed_registers_missing_mcp_servers_after_ PluginCommand.InstallCodexHooks(hooksPath); CodexHooksInstaller.DeleteMarker(hooksPath); - var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath())).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--codex", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); @@ -641,7 +641,7 @@ public async Task InstallCodex_if_installed_leaves_config_toml_alone_when_never_ args = ["serve"] """); - var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath())).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--codex", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); @@ -655,7 +655,7 @@ public async Task RemoveCodex_user_scope_preserves_unowned_manual_mcp_servers() using var fakeHome = new TempDir(); var configPath = SeedCodexConfigWithKcapServers(fakeHome.GetResolvedPath()); - var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath())).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "remove", "--codex"]); await Assert.That(exit).IsEqualTo(0); @@ -674,7 +674,7 @@ public async Task RemoveCodex_project_scope_leaves_user_global_mcp_servers() { var configPath = SeedCodexConfigWithKcapServers(fakeHome.GetResolvedPath()); var before = await File.ReadAllTextAsync(configPath); - var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath())).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "remove", "--codex", "--project"]); await Assert.That(exit).IsEqualTo(0); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCopilotTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCopilotTests.cs index 1134861e0..6a640a596 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCopilotTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCopilotTests.cs @@ -4,6 +4,7 @@ using Capacitor.Cli.Core.Harness.Copilot; using Capacitor.Cli.Core.Instructions; using Capacitor.Cli.Core.Mcp; +using Capacitor.Cli.Core; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -29,7 +30,7 @@ await File.WriteAllTextAsync(env.Harnesses.Of().Paths.McpConfigJ {"mcpServers":{"my-tool":{"type":"stdio","command":"my-tool","args":["serve"]}}} """); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--copilot", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--copilot", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); var root = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.McpConfigJson))!.AsObject(); @@ -52,7 +53,7 @@ public async Task install_copilot_skip_flag_leaves_mcp_config_untouched() { PluginCommand.InstallCopilotHooks(env.Harnesses.Of().Paths.KcapHooksJson); CopilotHooksInstaller.DeleteMarker(env.Harnesses.Of().Paths.KcapHooksJson); - var exit = await new PluginCommand(env).HandleAsync( + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--copilot", "--if-installed", "--skip-copilot-mcp"]); await Assert.That(exit).IsEqualTo(0); @@ -65,7 +66,7 @@ public async Task install_copilot_if_installed_does_not_write_mcp_config_when_ne var env = TestEnv(home.Path); // No hooks seeded → --if-installed no-ops before touching hooks OR mcp-config. - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--copilot", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--copilot", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); await Assert.That(File.Exists(env.Harnesses.Of().Paths.McpConfigJson)).IsFalse(); @@ -80,7 +81,7 @@ public async Task install_copilot_if_installed_heals_mcp_and_instructions_when_h // still (re)create the separate MCP + instructions files if they're missing (self-heal). PluginCommand.InstallCopilotHooks(env.Harnesses.Of().Paths.KcapHooksJson); // writes hooks + current marker - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--copilot", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--copilot", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); await Assert.That(File.Exists(env.Harnesses.Of().Paths.McpConfigJson)).IsTrue(); @@ -102,7 +103,7 @@ public async Task install_copilot_if_installed_heals_mcp_and_instructions_when_h Directory.CreateDirectory(env.Harnesses.Of().Paths.KcapHooksJson); // kcap.json is a directory → write fails await File.WriteAllTextAsync(System.IO.Path.Combine(hooksDir, CopilotHooksInstaller.MarkerFileName), "0.0.0-stale"); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--copilot", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--copilot", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); // refresh swallows the hook-write failure await Assert.That(File.Exists(env.Harnesses.Of().Paths.McpConfigJson)).IsTrue(); // MCP healed despite the hook failure @@ -121,7 +122,7 @@ public async Task remove_copilot_unregisters_mcp_servers_preserving_user_entries seeded["mcpServers"]!["my-tool"] = JsonNode.Parse("""{"type":"stdio","command":"my-tool","args":["serve"]}"""); await File.WriteAllTextAsync(env.Harnesses.Of().Paths.McpConfigJson, seeded.ToJsonString()); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--copilot"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--copilot"]); await Assert.That(exit).IsEqualTo(0); var root = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.McpConfigJson))!.AsObject(); @@ -146,13 +147,13 @@ public async Task remove_copilot_retains_marker_on_failed_unregister_then_retry_ // The config is temporarily malformed/unreadable → Unregister fails-closed. await File.WriteAllTextAsync(env.Harnesses.Of().Paths.McpConfigJson, "{ not valid json"); - var failExit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--copilot"]); + var failExit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--copilot"]); await Assert.That(failExit).IsEqualTo(1); // failed MCP unregister propagates await Assert.That(new McpMarker("copilot", env.Home).Owned(env.Harnesses.Of().Paths.McpConfigJson).ToArray()).IsNotEmpty(); // marker RETAINED for retry // User fixes the file (kcap entries intact); the retry now succeeds and cleans up. await File.WriteAllTextAsync(env.Harnesses.Of().Paths.McpConfigJson, installed); - var retryExit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--copilot"]); + var retryExit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--copilot"]); await Assert.That(retryExit).IsEqualTo(0); var root = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.McpConfigJson))!.AsObject(); @@ -174,7 +175,7 @@ public async Task install_copilot_installs_instructions_preserving_user_content( Directory.CreateDirectory(System.IO.Path.GetDirectoryName(env.Harnesses.Of().Paths.InstructionsMd)!); await File.WriteAllTextAsync(env.Harnesses.Of().Paths.InstructionsMd, "# My rules\n\nAlways use tabs.\n"); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--copilot", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--copilot", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); var content = await File.ReadAllTextAsync(env.Harnesses.Of().Paths.InstructionsMd); @@ -191,7 +192,7 @@ public async Task install_copilot_skip_instructions_flag_leaves_file_untouched() PluginCommand.InstallCopilotHooks(env.Harnesses.Of().Paths.KcapHooksJson); CopilotHooksInstaller.DeleteMarker(env.Harnesses.Of().Paths.KcapHooksJson); - var exit = await new PluginCommand(env).HandleAsync( + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--copilot", "--if-installed", "--skip-copilot-instructions"]); await Assert.That(exit).IsEqualTo(0); @@ -207,7 +208,7 @@ public async Task remove_copilot_strips_instructions_block_keeping_user_content( await File.WriteAllTextAsync(env.Harnesses.Of().Paths.InstructionsMd, "# My rules\n\nAlways use tabs.\n"); AgentInstructionsWriter.Write(env.Harnesses.Of().Paths.InstructionsMd, KcapAgentInstructions.Body); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--copilot"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--copilot"]); await Assert.That(exit).IsEqualTo(0); var content = await File.ReadAllTextAsync(env.Harnesses.Of().Paths.InstructionsMd); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCursorTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCursorTests.cs index 996732db1..4345f8dda 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCursorTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCursorTests.cs @@ -13,7 +13,7 @@ public async Task install_cursor_if_installed_noops_when_marker_absent() { using var tmp = new TempHome(); var hooksPath = Path.Combine(tmp.Path, "hooks.json"); - var exit = await new PluginCommand(TestEnv(tmp.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(tmp.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--cursor", "--if-installed", "--cursor-hooks-path", hooksPath]); await Assert.That(exit).IsEqualTo(0); await Assert.That(File.Exists(hooksPath)).IsFalse(); @@ -29,7 +29,7 @@ public async Task install_cursor_if_installed_short_circuits_on_same_version_mar var marker = CursorHooksInstaller.ReadMarker(hooksPath); await Assert.That(marker).IsEqualTo(CapacitorVersion.Current()); - var exit = await new PluginCommand(TestEnv(tmp.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(tmp.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--cursor", "--if-installed", "--cursor-hooks-path", hooksPath]); await Assert.That(exit).IsEqualTo(0); await Assert.That(File.ReadAllText(hooksPath)).IsEqualTo("{}"); @@ -59,7 +59,7 @@ await File.WriteAllTextAsync(mcpPath, """ {"mcpServers":{"my-tool":{"command":"my-tool","args":["serve"]}}} """); - var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--cursor", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); @@ -83,7 +83,7 @@ public async Task install_cursor_if_installed_does_not_write_mcp_json_when_never // Hooks were never installed, so the refresh-only postinstall path // no-ops before ever touching hooks.json OR mcp.json. - var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--cursor", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); @@ -102,7 +102,7 @@ await File.WriteAllTextAsync(hooksPath, """ {"version":1,"hooks":{"sessionStart":[{"command":"kcap hook --cursor"}]}} """); - var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--cursor", "--if-installed", "--skip-cursor-mcp"]); await Assert.That(exit).IsEqualTo(0); @@ -130,7 +130,7 @@ await File.WriteAllTextAsync(hooksPath, """ seeded["mcpServers"]!["my-tool"] = JsonNode.Parse("""{"command":"my-tool","args":["serve"]}"""); await File.WriteAllTextAsync(mcpPath, seeded.ToJsonString()); - var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "remove", "--cursor"]); await Assert.That(exit).IsEqualTo(0); @@ -159,13 +159,13 @@ public async Task remove_cursor_retains_marker_on_failed_unregister_then_retry_r // The config is temporarily malformed/unreadable → Unregister fails-closed. await File.WriteAllTextAsync(mcpPath, "{ not valid json"); - var failExit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(["plugin", "remove", "--cursor"]); + var failExit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--cursor"]); await Assert.That(failExit).IsEqualTo(1); // failed MCP unregister propagates await Assert.That(new McpMarker("cursor", fakeHome.Home).Owned(mcpPath).ToArray()).IsNotEmpty(); // marker RETAINED for retry // User fixes the file (kcap entries intact); the retry now succeeds and cleans up. await File.WriteAllTextAsync(mcpPath, installed); - var retryExit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(["plugin", "remove", "--cursor"]); + var retryExit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--cursor"]); await Assert.That(retryExit).IsEqualTo(0); var root = JsonNode.Parse(await File.ReadAllTextAsync(mcpPath))!.AsObject(); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandGeminiTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandGeminiTests.cs index 7325ab29d..51b4fc3ff 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandGeminiTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandGeminiTests.cs @@ -4,6 +4,7 @@ using Capacitor.Cli.Core.Harness.Gemini; using Capacitor.Cli.Core.Instructions; using Capacitor.Cli.Core.Mcp; +using Capacitor.Cli.Core; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -29,7 +30,7 @@ public async Task install_gemini_registers_mcp_servers_into_shared_settings_pres }; await File.WriteAllTextAsync(env.Harnesses.Of().Paths.SettingsJson, seeded.ToJsonString()); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); var root = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.SettingsJson))!.AsObject(); @@ -62,7 +63,7 @@ public async Task install_gemini_skip_mcp_flag_leaves_settings_without_mcp_serve PluginCommand.InstallGeminiHooks(env.Harnesses.Of().Paths.SettingsJson); GeminiHooksInstaller.DeleteMarker(env.Harnesses.Of().Paths.SettingsJson); - var exit = await new PluginCommand(env).HandleAsync( + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--gemini", "--if-installed", "--skip-gemini-mcp"]); await Assert.That(exit).IsEqualTo(0); @@ -77,7 +78,7 @@ public async Task install_gemini_if_installed_does_not_write_anything_when_never var env = TestEnv(home.Path); // No hooks/marker seeded → --if-installed no-ops before touching settings.json OR GEMINI.md. - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); await Assert.That(File.Exists(env.Harnesses.Of().Paths.SettingsJson)).IsFalse(); @@ -93,7 +94,7 @@ public async Task install_gemini_if_installed_heals_mcp_and_instructions_when_ho // still register the MCP servers (into settings.json) + install the instructions (GEMINI.md). PluginCommand.InstallGeminiHooks(env.Harnesses.Of().Paths.SettingsJson); // writes hooks + current marker - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); var servers = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.SettingsJson))!.AsObject()["mcpServers"]!.AsObject(); @@ -114,7 +115,7 @@ await File.WriteAllTextAsync( Path.Combine(env.Harnesses.Of().Paths.Root, GeminiHooksInstaller.MarkerFileName), "0.0.0-stale"); await File.WriteAllTextAsync(env.Harnesses.Of().Paths.SettingsJson, "{ not valid json"); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); // refresh swallows the hook/MCP failures on the shared file await Assert.That(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.SettingsJson)).IsEqualTo("{ not valid json"); // untouched @@ -134,7 +135,7 @@ public async Task install_gemini_if_installed_reinstalls_hooks_when_settings_del PluginCommand.InstallGeminiHooks(env.Harnesses.Of().Paths.SettingsJson); // hooks + current marker File.Delete(env.Harnesses.Of().Paths.SettingsJson); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); var root = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.SettingsJson))!.AsObject(); @@ -155,7 +156,7 @@ public async Task remove_gemini_unregisters_mcp_servers_preserving_user_entries( seeded["mcpServers"]!["my-tool"] = JsonNode.Parse("""{"command":"my-tool","args":["serve"]}"""); await File.WriteAllTextAsync(env.Harnesses.Of().Paths.SettingsJson, seeded.ToJsonString()); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--gemini"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--gemini"]); await Assert.That(exit).IsEqualTo(0); var root = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.SettingsJson))!.AsObject(); @@ -181,13 +182,13 @@ public async Task remove_gemini_retains_marker_on_failed_unregister_then_retry_r // settings.json is temporarily malformed → Unregister fails-closed. await File.WriteAllTextAsync(env.Harnesses.Of().Paths.SettingsJson, "{ not valid json"); - var failExit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--gemini"]); + var failExit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--gemini"]); await Assert.That(failExit).IsEqualTo(1); // failed unregister propagates await Assert.That(new McpMarker("gemini", env.Home).Owned(env.Harnesses.Of().Paths.SettingsJson).ToArray()).IsNotEmpty(); // marker RETAINED for retry // User fixes the file (kcap entries intact); the retry now succeeds and cleans up. await File.WriteAllTextAsync(env.Harnesses.Of().Paths.SettingsJson, installed); - var retryExit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--gemini"]); + var retryExit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--gemini"]); await Assert.That(retryExit).IsEqualTo(0); var root = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.SettingsJson))!.AsObject(); @@ -209,7 +210,7 @@ public async Task install_gemini_installs_instructions_preserving_user_content() Directory.CreateDirectory(Path.GetDirectoryName(env.Harnesses.Of().Paths.GeminiMd)!); await File.WriteAllTextAsync(env.Harnesses.Of().Paths.GeminiMd, "# My rules\n\nAlways use tabs.\n"); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); var content = await File.ReadAllTextAsync(env.Harnesses.Of().Paths.GeminiMd); @@ -226,7 +227,7 @@ public async Task install_gemini_skip_instructions_flag_leaves_gemini_md_untouch PluginCommand.InstallGeminiHooks(env.Harnesses.Of().Paths.SettingsJson); GeminiHooksInstaller.DeleteMarker(env.Harnesses.Of().Paths.SettingsJson); - var exit = await new PluginCommand(env).HandleAsync( + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--gemini", "--if-installed", "--skip-gemini-instructions"]); await Assert.That(exit).IsEqualTo(0); @@ -242,7 +243,7 @@ public async Task remove_gemini_strips_instructions_block_keeping_user_content() await File.WriteAllTextAsync(env.Harnesses.Of().Paths.GeminiMd, "# My rules\n\nAlways use tabs.\n"); AgentInstructionsWriter.Write(env.Harnesses.Of().Paths.GeminiMd, KcapAgentInstructions.Body); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--gemini"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--gemini"]); await Assert.That(exit).IsEqualTo(0); var content = await File.ReadAllTextAsync(env.Harnesses.Of().Paths.GeminiMd); @@ -262,7 +263,7 @@ public async Task remove_gemini_clears_mcp_marker_even_when_settings_file_absent await Assert.That(new McpMarker("gemini", env.Home).Owned(env.Harnesses.Of().Paths.SettingsJson).ToArray()).IsNotEmpty(); File.Delete(env.Harnesses.Of().Paths.SettingsJson); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--gemini"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--gemini"]); await Assert.That(exit).IsEqualTo(0); // The marker is cleared despite the absent file → a future user-authored mcpServers.kcap-* diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandKiroTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandKiroTests.cs index 4802dd27b..7371a8d6f 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandKiroTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandKiroTests.cs @@ -3,6 +3,7 @@ using Capacitor.Cli.Commands; using Capacitor.Cli.Core.Harness.Kiro; using Capacitor.Cli.Core.Mcp; +using Capacitor.Cli.Core; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -31,7 +32,7 @@ await File.WriteAllTextAsync(env.Harnesses.Of().Paths.SettingsMcpJs {"mcpServers":{"my-tool":{"command":"my-tool","args":["serve"],"autoApprove":["do_thing"]}}} """); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--kiro", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--kiro", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); var servers = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.SettingsMcpJson))!.AsObject()["mcpServers"]!.AsObject(); @@ -56,7 +57,7 @@ public async Task install_kiro_skip_mcp_flag_leaves_config_untouched() { var env = TestEnv(home.Path); SeedAgent(env); - var exit = await new PluginCommand(env).HandleAsync( + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--kiro", "--if-installed", "--skip-kiro-mcp"]); await Assert.That(exit).IsEqualTo(0); @@ -71,7 +72,7 @@ public async Task install_kiro_creates_settings_dir_when_missing() { // settings/ dir does not exist yet — Register must create it. await Assert.That(Directory.Exists(Path.GetDirectoryName(env.Harnesses.Of().Paths.SettingsMcpJson)!)).IsFalse(); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--kiro", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--kiro", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); await Assert.That(File.Exists(env.Harnesses.Of().Paths.SettingsMcpJson)).IsTrue(); @@ -88,7 +89,7 @@ public async Task remove_kiro_unregisters_mcp_servers_preserving_user_entries() seeded["mcpServers"]!["my-tool"] = JsonNode.Parse("""{"command":"my-tool","args":["serve"]}"""); await File.WriteAllTextAsync(env.Harnesses.Of().Paths.SettingsMcpJson, seeded.ToJsonString()); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--kiro"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--kiro"]); await Assert.That(exit).IsEqualTo(0); var servers = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.SettingsMcpJson))!.AsObject()["mcpServers"]!.AsObject(); @@ -109,7 +110,7 @@ public async Task install_kiro_if_installed_heals_mcp_only_install_without_cloni JsonMcpConfigWriter.Register(env.Harnesses.Of().Paths.SettingsMcpJson, partial, McpConfigShape.Standard, cwd: null, new McpMarker("kiro", env.Home)); await Assert.That(File.Exists(env.Harnesses.Of().Paths.KcapAgentJson)).IsFalse(); // no agent installed - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--kiro", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--kiro", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); // The refresh reached RegisterKiroMcpServersAsync (instead of bailing on the missing agent @@ -129,7 +130,7 @@ public async Task install_kiro_if_installed_noop_when_nothing_installed() { var env = TestEnv(home.Path); // Neither agent nor MCP present → refresh must be a pure no-op (never force-installs). - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--kiro", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--kiro", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); await Assert.That(File.Exists(env.Harnesses.Of().Paths.SettingsMcpJson)).IsFalse(); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandOpenCodeTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandOpenCodeTests.cs index d82f53b31..b9250e31c 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandOpenCodeTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandOpenCodeTests.cs @@ -27,7 +27,7 @@ public async Task Install_opencode_with_if_installed_is_noop_when_not_installed( using var tmp = new TempDir(); var pluginPath = tmp.PathTo("plugins", "kcap.ts"); - var exit = await new PluginCommand(TestEnv(tmp.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(tmp.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--opencode", "--opencode-plugin-path", pluginPath, "--if-installed"]); await Assert.That(exit).IsEqualTo(0); @@ -47,7 +47,7 @@ public async Task Install_opencode_with_if_installed_refreshes_existing_plugin() // Plugin-only: skip MCP/instructions so this stays isolated to the plugin file // (their config path derives from ambient OPENCODE_CONFIG_DIR/XDG, not this TempDir). - var exit = await new PluginCommand(TestEnv(tmp.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(tmp.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--opencode", "--opencode-plugin-path", pluginPath, "--if-installed", "--skip-opencode-mcp", "--skip-opencode-instructions"]); await Assert.That(exit).IsEqualTo(0); @@ -67,7 +67,7 @@ public async Task Install_opencode_if_installed_recreates_plugin_when_file_missi // via the marker, so --if-installed must still RECREATE the missing plugin, not skip it. dir.CreateFile(".kcap-extension-version", CapacitorVersion.Current()); - var exit = await new PluginCommand(TestEnv(tmp.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(tmp.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--opencode", "--opencode-plugin-path", pluginPath, "--if-installed", "--skip-opencode-mcp", "--skip-opencode-instructions"]); await Assert.That(exit).IsEqualTo(0); @@ -87,7 +87,7 @@ public async Task Remove_opencode_deletes_plugin_and_marker() { await File.WriteAllTextAsync(pluginPath, "export const KcapPlugin = async () => ({})"); await File.WriteAllTextAsync(marker, "1.0.0"); - var exit = await new PluginCommand(TestEnv(tmp.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(tmp.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "remove", "--opencode", "--opencode-plugin-path", pluginPath]); await Assert.That(exit).IsEqualTo(0); @@ -113,7 +113,7 @@ await File.WriteAllTextAsync(env.Harnesses.Of().Paths.McpConfig {"$schema":"https://opencode.ai/config.json","mcp":{"my-tool":{"type":"local","command":["my-tool","serve"],"enabled":true}}} """); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--opencode", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--opencode", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); var root = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.McpConfigJson))!.AsObject(); @@ -139,7 +139,7 @@ public async Task install_opencode_skip_mcp_flag_leaves_config_untouched() { var env = TestEnv(home.Path); SeedPlugin(env); - var exit = await new PluginCommand(env).HandleAsync( + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--opencode", "--if-installed", "--skip-opencode-mcp"]); await Assert.That(exit).IsEqualTo(0); @@ -155,7 +155,7 @@ public async Task install_opencode_installs_instructions_into_agents_md() { Directory.CreateDirectory(Path.GetDirectoryName(env.Harnesses.Of().Paths.AgentsMd)!); await File.WriteAllTextAsync(env.Harnesses.Of().Paths.AgentsMd, "# My rules\n\nAlways use tabs.\n"); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--opencode", "--if-installed"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--opencode", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); var content = await File.ReadAllTextAsync(env.Harnesses.Of().Paths.AgentsMd); @@ -170,7 +170,7 @@ public async Task install_opencode_skip_instructions_flag_leaves_file_untouched( var env = TestEnv(home.Path); SeedPlugin(env); - var exit = await new PluginCommand(env).HandleAsync( + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--opencode", "--if-installed", "--skip-opencode-instructions"]); await Assert.That(exit).IsEqualTo(0); @@ -192,7 +192,7 @@ public async Task remove_opencode_unregisters_mcp_and_strips_instructions() { await File.WriteAllTextAsync(env.Harnesses.Of().Paths.AgentsMd, "# My rules\n\nAlways use tabs.\n"); AgentInstructionsWriter.Write(env.Harnesses.Of().Paths.AgentsMd, KcapAgentInstructions.Body); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--opencode"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--opencode"]); await Assert.That(exit).IsEqualTo(0); var mcp = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.McpConfigJson))!.AsObject()["mcp"]!.AsObject(); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandPiTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandPiTests.cs index 8be530108..40533b22e 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandPiTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandPiTests.cs @@ -2,6 +2,7 @@ using Capacitor.Cli.Core.Config; using Capacitor.Cli.Core.Harness.Pi; using Capacitor.Cli.Core.Instructions; +using Capacitor.Cli.Core; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -20,7 +21,7 @@ public class PluginCommandPiTests { public async Task Install_pi_with_if_installed_is_noop_when_not_installed() { using var fakeHome = new TempDir(); - var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--pi", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); @@ -40,7 +41,7 @@ public async Task Install_pi_with_if_installed_refreshes_existing_extension() { var extPath = extDir.PathTo("kcap.ts"); await File.WriteAllTextAsync(extPath, "// stale extension body"); - var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--pi", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); @@ -59,7 +60,7 @@ public async Task Remove_pi_deletes_extension_and_marker() { await File.WriteAllTextAsync(extPath, "export default function(pi){}"); await File.WriteAllTextAsync(marker, "1.0.0"); - var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "remove", "--pi"]); await Assert.That(exit).IsEqualTo(0); @@ -77,7 +78,7 @@ public async Task Install_pi_if_installed_installs_mcp_bridge_and_agents_md() { var extDir = fakeHome.CreateDir(".pi", "agent", "extensions"); extDir.CreateFile("kcap.ts", "// stale ingest"); - var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--pi", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); @@ -99,7 +100,7 @@ public async Task Install_pi_if_installed_heals_deleted_mcp_bridge_with_current_ PiMcpExtensionInstaller.WriteMarker(mcpPath); await Assert.That(File.Exists(mcpPath)).IsFalse(); - var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--pi", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); @@ -112,7 +113,7 @@ public async Task Install_pi_skip_mcp_omits_bridge_but_keeps_instructions() { var extDir = fakeHome.CreateDir(".pi", "agent", "extensions"); extDir.CreateFile("kcap.ts", "// stale ingest"); - var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--pi", "--if-installed", "--skip-pi-mcp"]); await Assert.That(exit).IsEqualTo(0); @@ -126,7 +127,7 @@ public async Task Install_pi_skip_instructions_omits_agents_md_but_keeps_bridge( var extDir = fakeHome.CreateDir(".pi", "agent", "extensions"); extDir.CreateFile("kcap.ts", "// stale ingest"); - var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--pi", "--if-installed", "--skip-pi-instructions"]); await Assert.That(exit).IsEqualTo(0); @@ -144,7 +145,7 @@ public async Task Install_pi_preserves_user_agents_md_content() { var agents = Path.Combine(agentDir, "AGENTS.md"); await File.WriteAllTextAsync(agents, "# My Pi instructions\nKeep this line.\n"); - var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--pi", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); @@ -165,7 +166,7 @@ public async Task Remove_pi_removes_mcp_bridge_and_instructions_block() { await File.WriteAllTextAsync(agents, "# Mine\n"); AgentInstructionsWriter.Write(agents, "kcap steering body"); - var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "remove", "--pi"]); await Assert.That(exit).IsEqualTo(0); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandSkillsTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandSkillsTests.cs index a701615f6..f105eb3a8 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandSkillsTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandSkillsTests.cs @@ -9,7 +9,7 @@ public class PluginCommandSkillsTests { public async Task Install_with_both_codex_and_skills_flags_returns_error() { using var tmp = new TempDir(); var capturedErr = new StringWriter(); - var exit = await new PluginCommand(TestEnv(fakeHome: tmp.Path, stderr: capturedErr)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome: tmp.Path, stderr: capturedErr), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--codex", "--skills"]); await Assert.That(exit).IsEqualTo(1); await Assert.That(capturedErr.ToString()).Contains("mutually exclusive"); @@ -19,7 +19,7 @@ public async Task Install_with_both_codex_and_skills_flags_returns_error() { public async Task Remove_with_both_codex_and_skills_flags_returns_error() { using var tmp = new TempDir(); var capturedErr = new StringWriter(); - var exit = await new PluginCommand(TestEnv(fakeHome: tmp.Path, stderr: capturedErr)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome: tmp.Path, stderr: capturedErr), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "remove", "--codex", "--skills"]); await Assert.That(exit).IsEqualTo(1); await Assert.That(capturedErr.ToString()).Contains("mutually exclusive"); @@ -40,7 +40,7 @@ public async Task Install_skills_writes_to_agents_dir_and_cleans_legacy() { var legacyDir = fakeHome.CreateDir(".codex", "skills"); legacyDir.CreateDir("kcap-recap"); - var exit = await new PluginCommand(TestEnv(fakeHome.Path, pluginRoot.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.Path, pluginRoot.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--skills"]); await Assert.That(exit).IsEqualTo(0); @@ -65,7 +65,7 @@ public async Task Install_skills_with_if_installed_is_noop_when_marker_absent() $"---\nname: {name}\n---\nbody"); } - var exit = await new PluginCommand(TestEnv(fakeHome.Path, pluginRoot.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.Path, pluginRoot.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--skills", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); @@ -91,7 +91,7 @@ public async Task Install_skills_with_if_installed_refreshes_when_marker_present target.CreateFile(AgentsSkillsInstaller.MarkerFileName, "old-version"); - var exit = await new PluginCommand(TestEnv(fakeHome.Path, pluginRoot.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.Path, pluginRoot.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--skills", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); @@ -130,7 +130,7 @@ await File.WriteAllTextAsync( "---\nname: kcap-recap\n---\nstale body"); await Assert.That(File.Exists(Path.Combine(target, AgentsSkillsInstaller.MarkerFileName))).IsFalse(); - var exit = await new PluginCommand(TestEnv(fakeHome.Path, pluginRoot.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.Path, pluginRoot.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--skills", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); @@ -172,7 +172,7 @@ public async Task Install_skills_with_if_installed_is_noop_when_marker_matches_c target.CreateFile(["kcap-recap", "SKILL.md"], "stale body — must NOT be overwritten"); - var exit = await new PluginCommand(TestEnv(fakeHome.Path, pluginRoot.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.Path, pluginRoot.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--skills", "--if-installed"]); await Assert.That(exit).IsEqualTo(0); @@ -194,7 +194,7 @@ public async Task Install_skills_with_if_installed_swallows_plugin_resolution_fa // …but plugin path is null (resolution failed). var env = TestEnv(fakeHome.Path, pluginPath: null, stderr: capturedErr); - var exit = await new PluginCommand(env).HandleAsync( + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "install", "--skills", "--if-installed"]); // Refresh path must never fail npm install — exit 0, nothing on stderr. @@ -217,7 +217,7 @@ public async Task Remove_skills_clears_agents_and_legacy() { Directory.CreateDirectory(Path.Combine(legacyDir, name)); } - var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync( + var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( ["plugin", "remove", "--skills"]); await Assert.That(exit).IsEqualTo(0); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandStaleAgentTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandStaleAgentTests.cs index ec26f39d5..b4f987f85 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandStaleAgentTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandStaleAgentTests.cs @@ -1,6 +1,7 @@ using Capacitor.Cli.Commands; using Capacitor.Cli.Core.Config; using Capacitor.Cli.Core.Harness.Kiro; +using Capacitor.Cli.Core; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -32,7 +33,7 @@ public async Task A_first_install_names_a_session_that_was_already_running() { var env = Env(home.Path, pipe, found: [Running]); SeedAgent(env, installed: false); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--kiro"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--kiro"]); await Assert.That(exit).IsEqualTo(0); await Assert.That(pipe.ToString()).Contains("4821"); @@ -46,7 +47,7 @@ public async Task Re_installing_over_an_existing_agent_says_nothing() { var env = Env(home.Path, pipe, found: [Running]); SeedAgent(env, installed: true); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--kiro"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--kiro"]); await Assert.That(exit).IsEqualTo(0); await Assert.That(pipe.ToString()) @@ -64,7 +65,7 @@ public async Task A_first_install_with_nothing_running_says_nothing() { var env = Env(home.Path, pipe, found: []); SeedAgent(env, installed: false); - await new PluginCommand(env).HandleAsync(["plugin", "install", "--kiro"]); + await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--kiro"]); await Assert.That(pipe.ToString()).DoesNotContain("already running"); } @@ -81,7 +82,7 @@ public async Task An_install_that_failed_claims_nothing_about_future_sessions() // captured" would be true but useless, and blaming this install for it would be a lie. Directory.CreateDirectory(env.Harnesses.Of().Paths.KcapAgentJson); - var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--kiro"]); + var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--kiro"]); await Assert.That(exit).IsEqualTo(1); await Assert.That(pipe.ToString()).DoesNotContain("4821"); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandVendorSkillsTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandVendorSkillsTests.cs index 797c31d0e..8c4de639c 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandVendorSkillsTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandVendorSkillsTests.cs @@ -29,7 +29,7 @@ public class PluginCommandVendorSkillsTests { public async Task fresh_install_writes_the_shared_agent_skills(Vendor vendor) { using var scope = new VendorScope(vendor); - var exit = await new PluginCommand(scope.Env).HandleAsync(vendor.InstallArgs(scope.Home)); + var exit = await new PluginCommand(scope.Env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(vendor.InstallArgs(scope.Home)); await Assert.That(exit).IsEqualTo(0); @@ -44,7 +44,7 @@ await Assert.That(AgentsSkillsInstaller.HasSkill(scope.Env.Agents.UserSkillsDir, public async Task refresh_does_not_create_skills_for_a_vendor_never_installed(Vendor vendor) { using var scope = new VendorScope(vendor); - var exit = await new PluginCommand(scope.Env).HandleAsync( + var exit = await new PluginCommand(scope.Env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( [.. vendor.InstallArgs(scope.Home), "--if-installed"]); await Assert.That(exit).IsEqualTo(0); @@ -60,11 +60,11 @@ public async Task refresh_does_not_resurrect_skills_the_user_removed(Vendor vend using var scope = new VendorScope(vendor); // Install for real, then remove the skills the way a user would. - await new PluginCommand(scope.Env).HandleAsync(vendor.InstallArgs(scope.Home)); - await new PluginCommand(scope.Env).HandleAsync(["plugin", "remove", "--skills"]); + await new PluginCommand(scope.Env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(vendor.InstallArgs(scope.Home)); + await new PluginCommand(scope.Env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--skills"]); await Assert.That(AgentsSkillsInstaller.IsInstalled(scope.Env.Agents.UserSkillsDir)).IsFalse(); - var exit = await new PluginCommand(scope.Env).HandleAsync( + var exit = await new PluginCommand(scope.Env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( [.. vendor.InstallArgs(scope.Home), "--if-installed"]); await Assert.That(exit).IsEqualTo(0); @@ -79,7 +79,7 @@ await Assert.That(AgentsSkillsInstaller.IsInstalled(scope.Env.Agents.UserSkillsD public async Task fresh_install_kiro_writes_its_own_skills_tree_not_the_shared_one() { using var scope = new VendorScope(Vendor.Kiro); - await new PluginCommand(scope.Env).HandleAsync(["plugin", "install", "--kiro"]); + await new PluginCommand(scope.Env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--kiro"]); // Kiro reads ~/.kiro/skills; writing the shared tree instead would be silently useless to it. await Assert.That(AgentsSkillsInstaller.IsInstalled(scope.Env.Harnesses.Of().Paths.SkillsDir)).IsTrue(); @@ -90,7 +90,7 @@ public async Task fresh_install_kiro_writes_its_own_skills_tree_not_the_shared_o public async Task fresh_install_antigravity_writes_its_own_skills_tree_not_the_shared_one() { using var scope = new VendorScope(Vendor.Antigravity); - await new PluginCommand(scope.Env).HandleAsync(["plugin", "install", "--antigravity"]); + await new PluginCommand(scope.Env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--antigravity"]); await Assert.That(AgentsSkillsInstaller.IsInstalled(scope.Env.Harnesses.Of().Paths.SkillsDir)).IsTrue(); await Assert.That(Directory.Exists(scope.Env.Agents.UserSkillsDir)).IsFalse(); @@ -101,7 +101,7 @@ public async Task fresh_install_antigravity_writes_its_own_skills_tree_not_the_s public async Task the_skip_flag_declines_the_shared_skills(Vendor vendor) { using var scope = new VendorScope(vendor); - var exit = await new PluginCommand(scope.Env).HandleAsync( + var exit = await new PluginCommand(scope.Env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync( [.. vendor.InstallArgs(scope.Home), $"--skip-{vendor.Flag}-skills"]); await Assert.That(exit).IsEqualTo(0); @@ -115,14 +115,14 @@ await Assert.That(Directory.Exists(scope.Env.Agents.UserSkillsDir)) public async Task install_sweeps_legacy_codex_skills_even_when_the_tree_is_already_current() { using var scope = new VendorScope(Vendor.Cursor); - await new PluginCommand(scope.Env).HandleAsync(Vendor.Cursor.InstallArgs(scope.Home)); + await new PluginCommand(scope.Env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(Vendor.Cursor.InstallArgs(scope.Home)); await Assert.That(AgentsSkillsInstaller.IsCurrent(scope.Env.Agents.UserSkillsDir)).IsTrue(); // A pre-migration machine still carrying the old Codex-only copy. var legacy = Path.Combine(scope.Env.Harnesses.Of().Paths.SkillsDir, "kcap-recap"); Directory.CreateDirectory(legacy); - await new PluginCommand(scope.Env).HandleAsync(Vendor.Cursor.InstallArgs(scope.Home)); + await new PluginCommand(scope.Env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(Vendor.Cursor.InstallArgs(scope.Home)); await Assert.That(Directory.Exists(legacy)) .IsFalse() diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ReviewerVendorFallbackTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ReviewerVendorFallbackTests.cs index 4acb3e5bc..9e9f0ec6f 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ReviewerVendorFallbackTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ReviewerVendorFallbackTests.cs @@ -5,6 +5,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Capacitor.Cli.PrDetection; +using Capacitor.Cli.Core; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -21,7 +22,7 @@ public class ReviewerVendorFallbackTests { // Resolutions.None: these tests exercise routing, not profile selection. McpFlowsServer Server() => new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), - new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter()); + new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)); // The wire shape TryParseCodedError accepts: a JSON object with a non-empty string "error" // plus a string "message" — the CLI-side reading of the server's FlowReviewerResultError. diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SetupChosenServerTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SetupChosenServerTests.cs index 8d918f89c..1ce3c7ae3 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/SetupChosenServerTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/SetupChosenServerTests.cs @@ -29,7 +29,7 @@ SetupCommand Started(ProfileContext startup) { new AuthProviderDiscovery(factory), NoTelemetry.Facade, AuthEndpoints.Defaults, new FakeFacadeFactory(_ => throw new InvalidOperationException("no façade in these tests")), FakeImportRunner.Succeeding(), - new ChosenServerHttp(Config.Root, startup, ProfileOverrides.None, MachineAuth.None), router: new GitProviderRouter()); + new ChosenServerHttp(Config.Root, startup, ProfileOverrides.None, MachineAuth.None), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)); } /// A first run: nothing resolved a server before the command started, which is the case that diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs index a0940a562..5dc556f30 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs @@ -29,12 +29,12 @@ public class SetupCommandTests { [TempConfigRoot] public required TempConfigRoot Config { get; init; } /// The command under test, with the import runner each test is pinning. - SetupCommand Command(ISetupImportRunner imports) => + SetupCommand Command(ISetupImportRunner imports, string workdir) => new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), new RecordingBrowser(), Home, TestHarnesses.Under(Home), new AgentsPaths(Home), new FixedCapacitorHttpClient(), Provisioning, Discovery, NoTelemetry.Facade, AuthEndpoints.Defaults, RealFacades(), imports, - new ChosenServerHttp(Config.Root, Resolutions.None(Config.Root), ProfileOverrides.None, MachineAuth.None), router: new GitProviderRouter()); + new ChosenServerHttp(Config.Root, Resolutions.None(Config.Root), ProfileOverrides.None, MachineAuth.None), router: new GitProviderRouter(), workdir: new WorkingDirectory(workdir)); /// The real façade: these tests drive the import and argv legs, not a substituted login. IOnboardingFacadeFactory RealFacades() => @@ -874,7 +874,7 @@ public async Task RunImportStepAsync_RunDecision_InvokesRunnerWithPinnedArgs() { var runner = FakeImportRunner.Succeeding(); var passed = Resolutions.At("https://example.test", Config.Root); - await Command(runner).RunImportStepAsync( + await Command(runner, Config.Directory).RunImportStepAsync( currentRepo: ("acme", "widgets"), authSatisfied: true, skipImport: false, @@ -898,7 +898,7 @@ await Command(runner).RunImportStepAsync( public async Task RunImportStepAsync_InteractiveAccept_InvokesRunner() { var runner = FakeImportRunner.Succeeding(); - await Command(runner).RunImportStepAsync( + await Command(runner, Config.Directory).RunImportStepAsync( currentRepo: ("acme", "widgets"), authSatisfied: true, skipImport: false, @@ -916,7 +916,7 @@ public async Task RunImportStepAsync_RunnerReturnsNonZero_DoesNotThrowAndComplet // Completing without an unhandled exception is the assertion: a non-zero exit // code must be swallowed (warned about, not propagated) so setup still finishes. - await Command(runner).RunImportStepAsync( + await Command(runner, Config.Directory).RunImportStepAsync( currentRepo: ("acme", "widgets"), authSatisfied: true, skipImport: false, @@ -934,7 +934,7 @@ public async Task RunImportStepAsync_RunnerThrows_DoesNotPropagateAndCompletes() // Completing without the InvalidOperationException escaping is the assertion — // import is best-effort and must never fail setup. - await Command(runner).RunImportStepAsync( + await Command(runner, Config.Directory).RunImportStepAsync( currentRepo: ("acme", "widgets"), authSatisfied: true, skipImport: false, @@ -950,7 +950,7 @@ await Command(runner).RunImportStepAsync( public async Task RunImportStepAsync_NoCurrentRepo_SkipsWithoutInvokingRunnerOrPrompting() { var runner = FakeImportRunner.Throwing(new InvalidOperationException("must not run import")); - await Command(runner).RunImportStepAsync( + await Command(runner, Config.Directory).RunImportStepAsync( currentRepo: null, authSatisfied: true, skipImport: false, @@ -966,7 +966,7 @@ await Command(runner).RunImportStepAsync( public async Task RunImportStepAsync_SkipImportFlag_SkipsWithoutInvokingRunner() { var runner = FakeImportRunner.Throwing(new InvalidOperationException("must not run import")); - await Command(runner).RunImportStepAsync( + await Command(runner, Config.Directory).RunImportStepAsync( currentRepo: ("acme", "widgets"), authSatisfied: true, skipImport: true, @@ -983,16 +983,15 @@ await Command(runner).RunImportStepAsync( // server, with only the final import call intercepted through the injected runner. // // Every test here: - // • runs from a throwaway git repo (real `git init` + `remote add origin`) so repository - // detection resolves an owner/repo — HandleAsync reads Environment.CurrentDirectory itself, - // so the process cwd has to move. + // • names a throwaway git repo (real `git init` + `remote add origin`) as the command's + // working directory, so repository detection resolves an owner/repo. // • passes every --skip-*-hooks/-mcp/-instructions/-skills flag, so no coding-agent install // runs against the injected home. // • uses auth provider "None" (a WireMock /auth/config stub): with any other provider the // --server-url path has no way to no-prompt past the login. // - // The working directory they move, the environment they probe and the /auth/config cache they - // stub are all process-global, so no cohort of key-holders can exclude the readers: bare. + // The environment they probe and the /auth/config cache they stub are both process-global, so no + // cohort of key-holders can exclude the readers: bare. static string[] SkipAllAgentInstallFlags => [ "--skip-claude-hooks", "--skip-codex-hooks", "--skip-codex-network-access", @@ -1025,7 +1024,7 @@ public async Task HandleAsync_NoPromptWithServerUrl_AutoImportsWithPinnedInvocat var args = BuildArgs("--server-url", server.Url!, "--no-prompt", "--default-visibility", "org_public"); - var exit = await Command(runner).HandleAsync(args); + var exit = await Command(runner, fixture.RepoDir).HandleAsync(args); var captured = runner.Captured; @@ -1057,7 +1056,7 @@ public async Task HandleAsync_SkipImportFlag_SuppressesAutoImport() { var args = BuildArgs("--server-url", server.Url!, "--no-prompt", "--skip-import"); - var exit = await Command(runner).HandleAsync(args); + var exit = await Command(runner, fixture.RepoDir).HandleAsync(args); await Assert.That(exit).IsEqualTo(0); await Assert.That(runner.Calls).IsEqualTo(0); @@ -1078,7 +1077,7 @@ public async Task HandleAsync_SchemeLessServerUrl_ReachesImportRunnerNormalizedW var args = BuildArgs("--server-url", schemeLessServerUrl, "--no-prompt"); - var exit = await Command(runner).HandleAsync(args); + var exit = await Command(runner, fixture.RepoDir).HandleAsync(args); await Assert.That(exit).IsEqualTo(0); await Assert.That(runner.Captured).IsNotNull(); @@ -1105,7 +1104,7 @@ public async Task HandleAsync_ConflictingKcapUrlAndProfileEnvVars_DoesNotHijackS var args = BuildArgs("--server-url", server.Url!, "--no-prompt"); - var exit = await Command(runner).HandleAsync(args); + var exit = await Command(runner, fixture.RepoDir).HandleAsync(args); var captured = runner.Captured; @@ -1123,21 +1122,15 @@ public async Task HandleAsync_ConflictingKcapUrlAndProfileEnvVars_DoesNotHijackS /// sealed class HandleAsyncE2EFixture : IAsyncDisposable { readonly GitRepo _repo; - readonly string _originalCwd; public string RepoDir => _repo.Path; - HandleAsyncE2EFixture(GitRepo repo, string originalCwd) { - _repo = repo; - _originalCwd = originalCwd; - } + HandleAsyncE2EFixture(GitRepo repo) => _repo = repo; public static async Task CreateAsync(string owner, string repo, ConfigRoot configRoot) { var repoDir = GitRepo.Create(); repoDir.AddRemote($"https://github.com/{owner}/{repo}.git"); - var originalCwd = Environment.CurrentDirectory; - var configPath = AppConfig.GetConfigPath(configRoot); if (File.Exists(configPath)) File.Delete(configPath); @@ -1147,14 +1140,10 @@ public static async Task CreateAsync(string owner, string var legacyTokens = configRoot.Path("tokens.json"); if (File.Exists(legacyTokens)) File.Delete(legacyTokens); - Environment.CurrentDirectory = repoDir.Path; - - return new HandleAsyncE2EFixture(repoDir, originalCwd); + return new HandleAsyncE2EFixture(repoDir); } public ValueTask DisposeAsync() { - Environment.CurrentDirectory = _originalCwd; - _repo.Dispose(); return ValueTask.CompletedTask; @@ -1291,7 +1280,7 @@ public async Task WorkspaceGuard_is_absent_when_no_workspace_was_asked_for() { public async Task HandleAsync_rejects_half_a_pair_before_doing_anything() { using var capture = ConsoleOutput.StartErrorCapture(); - var exit = await Command(FakeImportRunner.Throwing(new InvalidOperationException("must not run import"))).HandleAsync(["setup", "--org", "Acme"]); + var exit = await Command(FakeImportRunner.Throwing(new InvalidOperationException("must not run import")), Config.Directory).HandleAsync(["setup", "--org", "Acme"]); await Assert.That(exit).IsEqualTo(1); await Assert.That(capture.GetCapturedError()).Contains("--slug"); @@ -1302,7 +1291,7 @@ public async Task HandleAsync_rejects_half_a_pair_before_doing_anything() { public async Task HandleAsync_rejects_creating_and_pointing_at_a_server_at_once() { using var capture = ConsoleOutput.StartErrorCapture(); - var exit = await Command(FakeImportRunner.Throwing(new InvalidOperationException("must not run import"))).HandleAsync( + var exit = await Command(FakeImportRunner.Throwing(new InvalidOperationException("must not run import")), Config.Directory).HandleAsync( ["setup", "--org", "Acme", "--slug", "acme", "--server-url", "https://other.kcap.ai"]); await Assert.That(exit).IsEqualTo(1); @@ -1314,7 +1303,7 @@ public async Task HandleAsync_rejects_creating_and_pointing_at_a_server_at_once( public async Task HandleAsync_rejects_a_provider_that_cannot_create() { using var capture = ConsoleOutput.StartErrorCapture(); - var exit = await Command(FakeImportRunner.Throwing(new InvalidOperationException("must not run import"))).HandleAsync(["setup", "--org", "Acme", "--slug", "acme", "--github"]); + var exit = await Command(FakeImportRunner.Throwing(new InvalidOperationException("must not run import")), Config.Directory).HandleAsync(["setup", "--org", "Acme", "--slug", "acme", "--github"]); await Assert.That(exit).IsEqualTo(1); await Assert.That(capture.GetCapturedError()).Contains("--github"); @@ -1325,7 +1314,7 @@ public async Task HandleAsync_rejects_a_provider_that_cannot_create() { public async Task HandleAsync_still_requires_a_server_url_with_no_prompt_and_no_answers() { using var capture = ConsoleOutput.StartErrorCapture(); - var exit = await Command(FakeImportRunner.Throwing(new InvalidOperationException("must not run import"))).HandleAsync(["setup", "--no-prompt"]); + var exit = await Command(FakeImportRunner.Throwing(new InvalidOperationException("must not run import")), Config.Directory).HandleAsync(["setup", "--no-prompt"]); await Assert.That(exit).IsEqualTo(1); await Assert.That(capture.GetCapturedError()).Contains("--server-url is required"); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SetupFacadeParityTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SetupFacadeParityTests.cs index 663d45673..699de09c0 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/SetupFacadeParityTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/SetupFacadeParityTests.cs @@ -84,7 +84,7 @@ SetupCommand Command(CliTelemetry telemetry, IOnboardingFacadeFactory facades) = Home, TestHarnesses.Under(Home), new AgentsPaths(Home), new FixedCapacitorHttpClient(), Provisioning, Discovery, telemetry, AuthEndpoints.Defaults, facades, FakeImportRunner.Throwing(new InvalidOperationException("these tests stop before the import step")), - new ChosenServerHttp(Config.Root, Resolutions.None(Config.Root), ProfileOverrides.None, MachineAuth.None), router: new GitProviderRouter()); + new ChosenServerHttp(Config.Root, Resolutions.None(Config.Root), ProfileOverrides.None, MachineAuth.None), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)); // ── Step 1: RunDiscoveryAsync (GitHub) ────────────────────────────────── diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/StatusWaitArgumentTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/StatusWaitArgumentTests.cs index 8f06d30e2..a9d78567f 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/StatusWaitArgumentTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/StatusWaitArgumentTests.cs @@ -4,6 +4,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Capacitor.Cli.PrDetection; +using Capacitor.Cli.Core; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -23,7 +24,7 @@ public class StatusWaitArgumentTests { // Resolutions.None: these tests exercise routing, not profile selection. McpFlowsServer Server() => new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), - new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter()); + new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)); static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(3); static readonly TimeSpan PollCap = TimeSpan.FromMinutes(8); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ToolCallBudgetTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ToolCallBudgetTests.cs index 1b68274ee..d992de603 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ToolCallBudgetTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ToolCallBudgetTests.cs @@ -2,6 +2,7 @@ using System.Text.Json.Nodes; using Capacitor.Cli.Commands; using Capacitor.Cli.PrDetection; +using Capacitor.Cli.Core; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -23,7 +24,7 @@ public class ToolCallBudgetTests { // Resolutions.None: these tests exercise routing, not profile selection. McpFlowsServer Server() => new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), - new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter()); + new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)); static JsonObject StartArguments() => new() { ["kind"] = "code-review", diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/UninstallCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/UninstallCommandTests.cs index 687eb4fc4..d89a51b33 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/UninstallCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/UninstallCommandTests.cs @@ -12,9 +12,6 @@ namespace Capacitor.Cli.Tests.Unit.Commands; -// These move the working directory so uninstall finds a project git root, and one captures Console. -// Both are process-global, so every relative-path resolver and child-spawner is a reader: bare. -[NotInParallel] public class UninstallCommandTests { // Uninstall runs `daemon stop --yes`, which enumerates this directory and kills the PIDs it // finds; a shared one holding the test runner's own PID makes it kill its own tree. @@ -103,7 +100,7 @@ await File.WriteAllTextAsync(geminiSettings, """ // Seed config dir with a real file so we can verify deletion. await File.WriteAllTextAsync(Path.Combine(fixture.ConfigDir, "profiles.json"), "{}"); - var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes"]); + var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes"]); await Assert.That(exit).IsEqualTo(0); // Claude: kcap entries gone, user entries preserved, marker removed. @@ -162,7 +159,7 @@ public async Task User_level_uninstall_removes_pi_extension() { await File.WriteAllTextAsync(markerPi, CapacitorVersion.Current()); await File.WriteAllTextAsync(userExt, "export default function(pi){}"); - var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--keep-config"]); + var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes", "--keep-config"]); await Assert.That(exit).IsEqualTo(0); await Assert.That(File.Exists(kcapTs)).IsFalse(); @@ -191,7 +188,7 @@ await File.WriteAllTextAsync(kcapAgent, """ await File.WriteAllTextAsync(marker, CapacitorVersion.Current()); await File.WriteAllTextAsync(userAgent, """{"name":"my-agent"}"""); - var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--keep-config"]); + var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes", "--keep-config"]); await Assert.That(exit).IsEqualTo(0); await Assert.That(File.Exists(kcapAgent)).IsFalse(); @@ -222,7 +219,7 @@ await File.WriteAllTextAsync(codexHooks, """ } """); - var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--keep-config"]); + var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes", "--keep-config"]); await Assert.That(exit).IsEqualTo(0); var codexRoot = JsonNode.Parse(await File.ReadAllTextAsync(codexHooks))!.AsObject(); @@ -254,7 +251,7 @@ await File.WriteAllTextAsync(cursorHooks, """ } """); - var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--keep-config"]); + var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes", "--keep-config"]); await Assert.That(exit).IsEqualTo(0); var cursorRoot = JsonNode.Parse(await File.ReadAllTextAsync(cursorHooks))!.AsObject(); @@ -289,7 +286,7 @@ await File.WriteAllTextAsync(claudeSettings, """ } """); - var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--keep-config"]); + var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes", "--keep-config"]); await Assert.That(exit).IsEqualTo(0); var root = JsonNode.Parse(await File.ReadAllTextAsync(claudeSettings))!.AsObject(); @@ -320,7 +317,7 @@ await File.WriteAllTextAsync(claudeSettings, """ var sentinel = Path.Combine(fixture.ConfigDir, "profiles.json"); await File.WriteAllTextAsync(sentinel, """{"sentinel":"keep"}"""); - var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--keep-config"]); + var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes", "--keep-config"]); await Assert.That(exit).IsEqualTo(0); var root = JsonNode.Parse(await File.ReadAllTextAsync(claudeSettings))!.AsObject(); @@ -361,44 +358,31 @@ await File.WriteAllTextAsync(projectCodex, """ } """); - var originalCwd = Environment.CurrentDirectory; - try { - Environment.CurrentDirectory = tmp.Path; - - var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--project", "--keep-config"]); - await Assert.That(exit).IsEqualTo(0); + var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(tmp.Path)).HandleAsync(["uninstall", "--yes", "--project", "--keep-config"]); + await Assert.That(exit).IsEqualTo(0); - var claudeRoot = JsonNode.Parse(await File.ReadAllTextAsync(projectClaude))!.AsObject(); - await Assert.That(claudeRoot["userLocal"]!.GetValue()).IsEqualTo("keep"); - await Assert.That(claudeRoot["enabledPlugins"]!["kcap@kcap"]).IsNull(); + var claudeRoot = JsonNode.Parse(await File.ReadAllTextAsync(projectClaude))!.AsObject(); + await Assert.That(claudeRoot["userLocal"]!.GetValue()).IsEqualTo("keep"); + await Assert.That(claudeRoot["enabledPlugins"]!["kcap@kcap"]).IsNull(); - var codexRoot = JsonNode.Parse(await File.ReadAllTextAsync(projectCodex))!.AsObject(); - var sessionStart = codexRoot["hooks"]!["SessionStart"]!.AsArray(); - await Assert.That(sessionStart.Count).IsEqualTo(1); - await Assert.That(sessionStart[0]!["hooks"]![0]!["command"]!.GetValue()).IsEqualTo("user-script"); - } finally { - Environment.CurrentDirectory = originalCwd; - } + var codexRoot = JsonNode.Parse(await File.ReadAllTextAsync(projectCodex))!.AsObject(); + var sessionStart = codexRoot["hooks"]!["SessionStart"]!.AsArray(); + await Assert.That(sessionStart.Count).IsEqualTo(1); + await Assert.That(sessionStart[0]!["hooks"]![0]!["command"]!.GetValue()).IsEqualTo("user-script"); } - [Test] + // Bare: the error goes to the process-global Console, whose readers carry no key. + [Test, NotInParallel] public async Task Project_flag_errors_when_not_inside_git_tree() { await using var fixture = await Fixture.CreateAsync(); // A scratch dir with NO .git anywhere up the tree. using var tmp = new TempDir(); - var originalCwd = Environment.CurrentDirectory; using var capture = ConsoleOutput.StartErrorCapture(); - try { - Environment.CurrentDirectory = tmp.Path; - - var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--project"]); - await Assert.That(exit).IsEqualTo(1); - await Assert.That(capture.GetCapturedError()).Contains("--project requires a git working tree"); - } finally { - Environment.CurrentDirectory = originalCwd; - } + var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(tmp.Path)).HandleAsync(["uninstall", "--yes", "--project"]); + await Assert.That(exit).IsEqualTo(1); + await Assert.That(capture.GetCapturedError()).Contains("--project requires a git working tree"); } [Test] @@ -432,7 +416,7 @@ await File.WriteAllTextAsync( Path.Combine(cursorDir, CursorHooksInstaller.MarkerFileName), CapacitorVersion.Current()); - var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--keep-config"]); + var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes", "--keep-config"]); await Assert.That(exit).IsEqualTo(0); await Assert.That(File.Exists(Path.Combine(claudeDir, ClaudePluginInstaller.MarkerFileName))).IsFalse(); @@ -461,7 +445,7 @@ public async Task User_level_uninstall_purges_cursor_mcp_marker_even_when_json_h marker.Record(mcpPath, ["kcap-review"]); // simulates a marker surviving a manual JSON edit await Assert.That(marker.Owned(mcpPath).ToArray()).IsNotEmpty(); - var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), home, TestHarnesses.Under(home), TestBinaries.None, new AgentsPaths(home), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--keep-config"]); + var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), home, TestHarnesses.Under(home), TestBinaries.None, new AgentsPaths(home), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes", "--keep-config"]); await Assert.That(exit).IsEqualTo(0); await Assert.That(new McpMarker("cursor", home).Owned(mcpPath).ToArray()).IsEmpty(); @@ -489,7 +473,7 @@ public async Task User_level_uninstall_keeps_cursor_mcp_marker_when_unregister_f marker.Record(mcpPath, ["kcap-review"]); await Assert.That(marker.Owned(mcpPath).ToArray()).IsNotEmpty(); - var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), home, TestHarnesses.Under(home), TestBinaries.None, new AgentsPaths(home), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--keep-config"]); + var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), home, TestHarnesses.Under(home), TestBinaries.None, new AgentsPaths(home), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes", "--keep-config"]); await Assert.That(exit).IsNotEqualTo(0); // the failed cursor MCP unregister propagates // Marker retained → a retry after the user fixes the file can still find + remove the kcap entries. @@ -523,7 +507,7 @@ public async Task Sweep_removes_kcap_prefixed_skill_folders_not_in_current_sourc var legacyRetired = Path.Combine(legacyDir, "kcap-also-retired"); Directory.CreateDirectory(legacyRetired); - var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--keep-config"]); + var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes", "--keep-config"]); await Assert.That(exit).IsEqualTo(0); await Assert.That(Directory.Exists(currentSkill)).IsFalse(); @@ -561,7 +545,7 @@ await File.WriteAllTextAsync(settingsPath, """ await File.WriteAllTextAsync(sentinel, """{"sentinel":"survives-partial-failure"}"""); try { - var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes"]); + var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes"]); await Assert.That(exit).IsEqualTo(1); await Assert.That(Directory.Exists(fixture.ConfigDir)).IsTrue(); @@ -590,7 +574,7 @@ await File.WriteAllTextAsync(hooksPath, """ File.SetUnixFileMode(hooksPath, UnixFileMode.UserRead | UnixFileMode.GroupRead | UnixFileMode.OtherRead); try { - var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes"]); + var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes"]); await Assert.That(exit).IsEqualTo(1); // Failure path skips the config-dir delete so the user can re-run. @@ -621,7 +605,7 @@ public async Task Sweep_failure_propagates_to_exit_code() { UnixFileMode.OtherRead | UnixFileMode.OtherExecute); try { - var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes"]); + var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes"]); await Assert.That(exit).IsEqualTo(1); await Assert.That(Directory.Exists(fixture.ConfigDir)).IsTrue(); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/UnusableUrlGuardTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/UnusableUrlGuardTests.cs index 04592e3ba..557103f78 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/UnusableUrlGuardTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/UnusableUrlGuardTests.cs @@ -205,7 +205,7 @@ public async Task Suppresses_a_disabled_session_given_a_dashed_payload_id() { // normalization, so passing the raw payload id straight through would miss it entirely. var body = $$"""{"session_id":"{{dashed}}","hook_event_name":"SessionStart"}"""; - await Assert.That(await new ClaudeHookCommand(Config.Root, Resolutions.None(Config.Root), _clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).ShouldSuppressCaptureAsync( + await Assert.That(await new ClaudeHookCommand(Config.Root, Resolutions.None(Config.Root), _clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).ShouldSuppressCaptureAsync( dashless, body, "session-start", activeProfile: null, _clock.Budget(Ceiling))).IsTrue(); } @@ -216,7 +216,7 @@ public async Task Session_end_suppression_also_clears_the_marker() { var body = $$"""{"session_id":"{{sid}}","hook_event_name":"SessionEnd"}"""; - await Assert.That(await new ClaudeHookCommand(Config.Root, Resolutions.None(Config.Root), _clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).ShouldSuppressCaptureAsync( + await Assert.That(await new ClaudeHookCommand(Config.Root, Resolutions.None(Config.Root), _clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).ShouldSuppressCaptureAsync( sid, body, "session-end", activeProfile: null, _clock.Budget(Ceiling))).IsTrue(); // Collapsing the gate into a plain boolean would have dropped this cleanup. @@ -229,7 +229,7 @@ public async Task Does_not_suppress_an_ordinary_session() { var sid = Guid.NewGuid().ToString("N"); var body = $$"""{"session_id":"{{sid}}","hook_event_name":"SessionStart"}"""; - await Assert.That(await new ClaudeHookCommand(Config.Root, Resolutions.None(Config.Root), _clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).ShouldSuppressCaptureAsync( + await Assert.That(await new ClaudeHookCommand(Config.Root, Resolutions.None(Config.Root), _clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).ShouldSuppressCaptureAsync( sid, body, "session-start", activeProfile: null, _clock.Budget(Ceiling))).IsFalse(); } @@ -238,7 +238,7 @@ public async Task Does_not_suppress_an_ordinary_session() { public async Task Cursor_never_builds_a_client_for_an_unusable_url() { var entered = false; - var exit = await new CursorHookCommand(Config.Root, Bad, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Bad, new FixedCapacitorHttpClient()), router: new GitProviderRouter()).HandleWithDeps( + var exit = await new CursorHookCommand(Config.Root, Bad, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Bad, new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleWithDeps( new StringReader("""{"hook_event_name":"sessionStart","session_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"""), _ => { entered = true; @@ -258,7 +258,7 @@ public async Task Cursor_never_builds_a_client_for_an_unusable_url() { public async Task Claude_never_builds_a_client_for_an_unusable_url() { var entered = false; - var exit = await new ClaudeHookCommand(Config.Root, Bad, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Bad, new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter()).HandleWithDeps( + var exit = await new ClaudeHookCommand(Config.Root, Bad, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Bad, new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleWithDeps( new HookSpool(_dir), stdin: new StringReader($$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}"}"""), clientFactory: () => { diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/UseCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/UseCommandTests.cs index c1b22a536..035c98b1a 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/UseCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/UseCommandTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Capacitor.Cli.Commands; using Capacitor.Cli.Core.Config; +using Capacitor.Cli.Core; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -20,7 +21,7 @@ public async Task Use_InRepo_SetsProfileBinding() { await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(initial, ProfileConfigJsonContextIndented.Default.ProfileConfig)); - var result = await new UseCommand(Config.Root).SetProfile("contoso", repoPath: "/repos/my-project", global: false, save: false, savePath: null); + var result = await new UseCommand(Config.Root, workdir: new WorkingDirectory(AppContext.BaseDirectory)).SetProfile("contoso", repoPath: "/repos/my-project", global: false, save: false, savePath: null); await Assert.That(result).IsEqualTo(0); @@ -44,7 +45,7 @@ public async Task Use_Global_SetsActiveProfile() { await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(initial, ProfileConfigJsonContextIndented.Default.ProfileConfig)); - var result = await new UseCommand(Config.Root).SetProfile("contoso", repoPath: null, global: true, save: false, savePath: null); + var result = await new UseCommand(Config.Root, workdir: new WorkingDirectory(AppContext.BaseDirectory)).SetProfile("contoso", repoPath: null, global: true, save: false, savePath: null); await Assert.That(result).IsEqualTo(0); @@ -68,7 +69,7 @@ public async Task Use_Save_WritesRepoConfig() { await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(initial, ProfileConfigJsonContextIndented.Default.ProfileConfig)); - var result = await new UseCommand(Config.Root).SetProfile("contoso", repoPath: repoRoot, global: false, save: true, savePath: repoRoot); + var result = await new UseCommand(Config.Root, workdir: new WorkingDirectory(AppContext.BaseDirectory)).SetProfile("contoso", repoPath: repoRoot, global: false, save: true, savePath: repoRoot); await Assert.That(result).IsEqualTo(0); @@ -94,7 +95,7 @@ public async Task Use_UnknownProfile_ReturnsError() { await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(initial, ProfileConfigJsonContextIndented.Default.ProfileConfig)); - var result = await new UseCommand(Config.Root).SetProfile("nonexistent", repoPath: "/repos/x", global: false, save: false, savePath: null); + var result = await new UseCommand(Config.Root, workdir: new WorkingDirectory(AppContext.BaseDirectory)).SetProfile("nonexistent", repoPath: "/repos/x", global: false, save: false, savePath: null); await Assert.That(result).IsEqualTo(1); } diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Antigravity/AntigravitySessionStartMemoryTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Antigravity/AntigravitySessionStartMemoryTests.cs index 9737773da..4bbd25ae4 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Antigravity/AntigravitySessionStartMemoryTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Antigravity/AntigravitySessionStartMemoryTests.cs @@ -31,7 +31,7 @@ public class AntigravitySessionStartMemoryTests { // The server URL is the resolution's, so a test proving the url guard fires hands in the bad one // here rather than as an argument. AntigravityHookCommand Hook(string serverUrl = "https://example.test") => - new(Config.Root, Resolutions.At(serverUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(serverUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()); + new(Config.Root, Resolutions.At(serverUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(serverUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)); [TempConfigRoot] public required TempConfigRoot Config { get; init; } static string Write(string? fragment) { @@ -151,7 +151,7 @@ public async Task A_store_that_cannot_be_constructed_resolves_to_null_rather_tha [Test] public async Task A_non_PreInvocation_event_writes_nothing_and_exits_zero() { var sw = new StringWriter(); - var code = await new AntigravityHookCommand(Config.Root, Resolutions.At("https://example.test", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("https://example.test", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["--antigravity", "Stop"], new StringReader("{}"), sw); + var code = await new AntigravityHookCommand(Config.Root, Resolutions.At("https://example.test", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("https://example.test", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["--antigravity", "Stop"], new StringReader("{}"), sw); await Assert.That(code).IsEqualTo(0); await Assert.That(sw.ToString()).IsEqualTo(""); @@ -160,7 +160,7 @@ public async Task A_non_PreInvocation_event_writes_nothing_and_exits_zero() { [Test] public async Task A_malformed_payload_writes_nothing_and_exits_zero() { var sw = new StringWriter(); - var code = await new AntigravityHookCommand(Config.Root, Resolutions.At("https://example.test", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("https://example.test", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["--antigravity", "PreInvocation"], new StringReader("{not json"), sw); + var code = await new AntigravityHookCommand(Config.Root, Resolutions.At("https://example.test", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("https://example.test", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["--antigravity", "PreInvocation"], new StringReader("{not json"), sw); await Assert.That(code).IsEqualTo(0); await Assert.That(sw.ToString()).IsEqualTo(""); @@ -236,7 +236,7 @@ public async Task HandleCore_consults_the_fallback_for_an_empty_workspacePaths_p var consulted = false; var sw = new StringWriter(); - var code = await new AntigravityHookCommand(Config.Root, Resolutions.At("https://example.test", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("https://example.test", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(["--antigravity", "PreInvocation"], + var code = await new AntigravityHookCommand(Config.Root, Resolutions.At("https://example.test", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("https://example.test", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["--antigravity", "PreInvocation"], new StringReader($$""" {"conversationId":"{{conversationId}}","transcriptPath":"/tmp/t.jsonl","workspacePaths":[]} """), diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeHookExclusionGateTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeHookExclusionGateTests.cs index ea8bfe4c6..0ede1204b 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeHookExclusionGateTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeHookExclusionGateTests.cs @@ -26,7 +26,7 @@ ClaudeHookCommand Hook() => new(Config.Root, Resolutions.None(Config.Root), _clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), - SystemProcessStarter.Instance, router: new GitProviderRouter()); + SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)); // The gate reads the budget only for the repo probe, which these path-exclusion payloads never // reach; what they vary is the profile, not the clock. Any live ceiling will do. diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeMemoryIndexLiveCertTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeMemoryIndexLiveCertTests.cs index 2fc462b34..72f417edb 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeMemoryIndexLiveCertTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeMemoryIndexLiveCertTests.cs @@ -295,7 +295,7 @@ internal static string ExtractAssistantAnswer(string stdout) { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, - WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory + WorkingDirectory = workingDirectory ?? Capacitor.Cli.Core.WorkingDirectory.FromProcess().Path }; foreach (var arg in args) psi.ArgumentList.Add(arg); diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorLiveSubagentIntegrationTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorLiveSubagentIntegrationTests.cs index 41a01b79b..8cbe3826c 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorLiveSubagentIntegrationTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorLiveSubagentIntegrationTests.cs @@ -174,7 +174,7 @@ public Fixture(ConfigRoot config, HttpStatusCode postStatus = HttpStatusCode.OK) } public Task HandleAsync(string sessionId, string eventName, string? transcriptPath, string extraFields = "") => - new CursorHookCommand(Config, Resolutions.At("http://localhost", Config), new HookClock(TimeProvider.System), _home, TestHarnesses.Under(_home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config, Resolutions.At("http://localhost", Config), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).HandleCore( + new CursorHookCommand(Config, Resolutions.At("http://localhost", Config), new HookClock(TimeProvider.System), _home, TestHarnesses.Under(_home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config, Resolutions.At("http://localhost", Config), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( Client, stdin: new StringReader( $$"""{"hook_event_name":"{{eventName}}","session_id":"{{sessionId}}","transcript_path":"{{transcriptPath?.Replace(@"\", @"\\")}}"{{extraFields}}}""" diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorMemoryIndexLiveCertTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorMemoryIndexLiveCertTests.cs index 88fa4dff7..dc3444253 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorMemoryIndexLiveCertTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorMemoryIndexLiveCertTests.cs @@ -292,7 +292,7 @@ internal static string ExtractAssistantAnswer(string stdout) { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, - WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory + WorkingDirectory = workingDirectory ?? Capacitor.Cli.Core.WorkingDirectory.FromProcess().Path }; foreach (var arg in args) psi.ArgumentList.Add(arg); diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorSubagentStaleStateTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorSubagentStaleStateTests.cs index d1450b139..b8279d554 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorSubagentStaleStateTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorSubagentStaleStateTests.cs @@ -103,7 +103,7 @@ public async Task A_marker_without_an_ack_suppresses_the_raw_event_and_the_trans using var client = new HttpClient(handler); var spool = new HookSpool(tmp.PathTo("spool")); - await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore( + await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( client, new StringReader($$"""{"hook_event_name":"afterAgentThought","session_id":"{{child}}","generation_id":"g","text":"t","transcript_path":"{{childFile.Replace(@"\", @"\\")}}"}"""), spool); @@ -163,7 +163,7 @@ public async Task Successful_start_with_a_failed_marker_write_leaves_ack_and_wat // posted), so a test that bypassed it by calling HandleSubagentChildEventAsync would // keep passing after the remedy landed — defeating the whole point of a // characterization test. - await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore( + await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( client, new StringReader($$"""{"hook_event_name":"sessionStart","session_id":"{{child}}","transcript_path":"{{childPath.Replace(@"\", @"\\")}}"}"""), spool); @@ -210,7 +210,7 @@ public async Task Spooled_start_with_a_failed_marker_write_dual_routes_on_the_ne var spool = new HookSpool(tmp.PathTo("spool")); // Again through the REAL CALLER — see the note in the test above. - await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore( + await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( client, new StringReader($$"""{"hook_event_name":"sessionStart","session_id":"{{child}}","transcript_path":"{{childPath.Replace(@"\", @"\\")}}"}"""), spool); @@ -224,7 +224,7 @@ public async Task Spooled_start_with_a_failed_marker_write_dual_routes_on_the_ne // ordinary top-level route. THE FINDING: two watchers now tail the SAME transcript, // one under the parent and one as the child's own session. routes.Clear(); - await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore( + await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( client, new StringReader($$"""{"hook_event_name":"afterAgentResponse","session_id":"{{child}}","transcript_path":"{{childPath.Replace(@"\", @"\\")}}"}"""), spool); diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorWatcherSpawnTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorWatcherSpawnTests.cs index be4ba5f4c..e6dd1e039 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorWatcherSpawnTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorWatcherSpawnTests.cs @@ -26,7 +26,7 @@ public class CursorWatcherSpawnTests { CursorHookCommand Hook(ConfigRoot root, IWatcherSpawner spawner) => new(root, Resolutions.At("http://s", root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), - TestWatchers.For(root, Resolutions.At("http://s", root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()); + TestWatchers.For(root, Resolutions.At("http://s", root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)); [Test] public async Task SessionEnd_never_spawns() => @@ -169,7 +169,7 @@ public async Task Deferred_spool_drain_delivering_a_spooled_subagent_start_spawn // stays queued. Asserting "no spawn" only means something after a real drain attempt: // straight after Append no production code has run, so the assertion could not fail. startFails = true; - await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore( + await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( client, new StringReader($$"""{"hook_event_name":"postToolUse","session_id":"{{child}}","tool_name":"Bash"}"""), spool); @@ -182,7 +182,7 @@ public async Task Deferred_spool_drain_delivering_a_spooled_subagent_start_spawn // (before the isSubagentChild divert even runs), and that success is what must // trigger the deferred spawn. startFails = false; - await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore( + await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( client, new StringReader($$"""{"hook_event_name":"postToolUse","session_id":"{{child}}","tool_name":"Bash"}"""), spool); @@ -241,14 +241,14 @@ public async Task Permanently_dropped_subagent_start_gates_all_child_transcript_ // 2nd invocation: any later hook for this child. HandleCore's generic top-of-method // spool drain retries the spooled subagent-start FIRST — this time it 400s, which // HookSpool treats as a permanent Drop (the entry is discarded, not re-queued). - await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore( + await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( client, new StringReader($$"""{"hook_event_name":"afterAgentThought","session_id":"{{child}}","generation_id":"g","text":"t","transcript_path":"{{childFileEscaped}}"}"""), spool); // 3rd invocation: another content-less hook. An emptied backlog is not an // acknowledgement, so no agent-routed backfill may run while the marker is absent. - await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter()).HandleCore( + await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore( client, new StringReader($$"""{"hook_event_name":"postToolUse","session_id":"{{child}}","tool_name":"Bash","transcript_path":"{{childFileEscaped}}"}"""), spool); diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiHookOutputContractTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiHookOutputContractTests.cs index 9e3f9dfaf..bce141888 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiHookOutputContractTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiHookOutputContractTests.cs @@ -186,7 +186,7 @@ async Task RunAsync(string payload) { // A URL no POST can reach: these paths must all return before any network call, and a test // that quietly started talking to a live server would be measuring something else. - await new GeminiHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); + await new GeminiHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); return capture.GetCapturedOutput(); } diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiSessionStartMemoryTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiSessionStartMemoryTests.cs index 44490eafb..b5e316cb5 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiSessionStartMemoryTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiSessionStartMemoryTests.cs @@ -152,7 +152,7 @@ async Task CaptureHandleStdout(string payload) { using var capture = ConsoleOutput.StartCapture(); // baseUrl is unreachable on purpose: these paths must return before any network work. - await new GeminiHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()).Handle(new StringReader(payload)); + await new GeminiHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload)); return capture.GetCapturedOutput(); } diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Pi/PiSessionStartMemoryTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Pi/PiSessionStartMemoryTests.cs index 413085c47..e5559b3d8 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Pi/PiSessionStartMemoryTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Pi/PiSessionStartMemoryTests.cs @@ -25,7 +25,7 @@ public class PiSessionStartMemoryTests { // The server URL is the resolution's, so a test proving the url guard fires hands in the bad one // here rather than as an argument. PiHookCommand Hook(string serverUrl = "http://localhost:5100") => - new(Config.Root, Resolutions.At(serverUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(serverUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter()); + new(Config.Root, Resolutions.At(serverUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(serverUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)); static string Render(string? fragment) => PiHookCommand.RenderMemoryOutput(fragment); // Byte-identical to pre-feature behaviour on every no-index path (opt-out, failure, spent lease): diff --git a/test/Capacitor.Cli.Tests.Unit/ProcessHelpersTests.cs b/test/Capacitor.Cli.Tests.Unit/ProcessHelpersTests.cs index 497371749..a241f9f7b 100644 --- a/test/Capacitor.Cli.Tests.Unit/ProcessHelpersTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/ProcessHelpersTests.cs @@ -162,7 +162,9 @@ static string Canonical(string p) { .TrimEnd(Path.DirectorySeparatorChar); } +#pragma warning disable RS0030 // the process's own directory is what this reports await Assert.That(Canonical(reported!)).IsEqualTo(Canonical(Directory.GetCurrentDirectory())); +#pragma warning restore RS0030 } [Test] diff --git a/test/Capacitor.Cli.Tests.Unit/SessionStartMemory/MemoryIndexLiveCertHarness.cs b/test/Capacitor.Cli.Tests.Unit/SessionStartMemory/MemoryIndexLiveCertHarness.cs index 8488fb51f..437ec4b7a 100644 --- a/test/Capacitor.Cli.Tests.Unit/SessionStartMemory/MemoryIndexLiveCertHarness.cs +++ b/test/Capacitor.Cli.Tests.Unit/SessionStartMemory/MemoryIndexLiveCertHarness.cs @@ -97,7 +97,7 @@ public static async Task InitializeAndResolveServerUrlAsync() { // The operator's REAL root, named rather than resolved: this assembly pins KCAP_CONFIG_DIR at // a throwaway directory, and a cert that resolved that would read an empty config — the same // 401-in-a-different-costume the child processes below strip the variable to avoid. - var profiles = await AppConfig.ResolveForRepo([], ConfigRoot.UnderHome(UserHome.FromEnvironment().Path), ProfileOverrides.None); + var profiles = await AppConfig.ResolveForRepo([], ConfigRoot.UnderHome(UserHome.FromEnvironment().Path), ProfileOverrides.None, WorkingDirectory.FromProcess()); return profiles.Resolution.ServerUrl ?? RequiredServerUrl(); } @@ -644,7 +644,7 @@ static bool IsExecutableFile(string path) { RedirectStandardOutput = true, RedirectStandardError = true, RedirectStandardInput = stdin is not null, - WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory + WorkingDirectory = workingDirectory ?? Capacitor.Cli.Core.WorkingDirectory.FromProcess().Path }; foreach (var arg in args) psi.ArgumentList.Add(arg); From 059fcafebb293078dae7a9e00adc2d12df91e6f8 Mon Sep 17 00:00:00 2001 From: Pavel Borisov Date: Tue, 15 Sep 2026 11:59:34 +0200 Subject: [PATCH 4/5] Resolve the repo root only when a profile change needs one RepoRootOf shells out to git and blocks on the child's output before its timeout applies, so a global selection that saves nothing must not ask for it. The same guard holds a saving in-repo selection to one probe rather than two. --- src/Capacitor.Cli/Commands/UseCommand.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Capacitor.Cli/Commands/UseCommand.cs b/src/Capacitor.Cli/Commands/UseCommand.cs index cd662a8c4..3d63dfb32 100644 --- a/src/Capacitor.Cli/Commands/UseCommand.cs +++ b/src/Capacitor.Cli/Commands/UseCommand.cs @@ -15,7 +15,9 @@ public async Task HandleAsync(string[] args) { var name = args[1]; var global = args.Contains("--global"); var save = args.Contains("--save"); - var repoRoot = AppConfig.RepoRootOf(workdir); + // Resolved at most once, and not at all for a global selection that saves nothing: + // RepoRootOf shells out to git, which a change needing no repository must not wait on. + var repoRoot = !global || save ? AppConfig.RepoRootOf(workdir) : null; var repoPath = global ? null : repoRoot; return await SetProfile(name, repoPath, global, save, save ? repoRoot : null); From 9d45ed6d1570224fa34f4d1801578de99501024a Mon Sep 17 00:00:00 2001 From: Pavel Borisov Date: Tue, 15 Sep 2026 16:19:36 +0200 Subject: [PATCH 5/5] Drop a test that cannot reach the branch it names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update canonicalises the config path before the directory guard runs, so GetDirectoryName never sees a directory-less one — removing that guard outright leaves the whole class green. Moving the process working directory was only giving GetFullPath a base, and it was the last such move in the suite. --- .../Harness/Codex/CodexConfigToml.cs | 2 -- .../Harness/Codex/CodexConfigTomlTests.cs | 22 ------------------- 2 files changed, 24 deletions(-) diff --git a/src/Capacitor.Cli.Core/Harness/Codex/CodexConfigToml.cs b/src/Capacitor.Cli.Core/Harness/Codex/CodexConfigToml.cs index af1e9a843..a09a7001d 100644 --- a/src/Capacitor.Cli.Core/Harness/Codex/CodexConfigToml.cs +++ b/src/Capacitor.Cli.Core/Harness/Codex/CodexConfigToml.cs @@ -493,8 +493,6 @@ static Change Update(string configPath, Func mutate, out Except try { // First-time users have no ~/.codex; create it before the atomic rename. - // GetDirectoryName is null/empty for a directory-less path — skip the - // create in that case (the file lands in the current directory). var dir = Path.GetDirectoryName(configPath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); WriteTomlAtomic(configPath, root); diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexConfigTomlTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexConfigTomlTests.cs index b4a0376e9..0cb3a6667 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexConfigTomlTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexConfigTomlTests.cs @@ -120,28 +120,6 @@ public async Task EnableNetworkAccess_is_idempotent() { await Assert.That(second).IsEqualTo(CodexConfigToml.Change.Unchanged); } - [Test] - [NotInParallel("CwdMutation")] - public async Task EnableNetworkAccess_writes_when_config_path_has_no_directory_component() { - // GetDirectoryName("config.toml") is empty; CreateDirectory("") would throw and - // silently turn the write into Change.Failed without the guard. - using var tmp = new TempDir(); -#pragma warning disable RS0030 // a path with no directory component resolves nowhere else - var originalCwd = Environment.CurrentDirectory; - - try { - Environment.CurrentDirectory = tmp.Path; - - var change = CodexConfigToml.EnableNetworkAccess(["**.kcap.ai"], "config.toml"); - - await Assert.That(change).IsEqualTo(CodexConfigToml.Change.Updated); - await Assert.That(File.Exists(tmp.PathTo("config.toml"))).IsTrue(); - } finally { - Environment.CurrentDirectory = originalCwd; -#pragma warning restore RS0030 - } - } - [Test] public async Task EnableNetworkAccess_empty_allowlist_is_noop() { using var tmp = new TempDir();