diff --git a/ProwlCLI/Commands/OpenCommand.swift b/ProwlCLI/Commands/OpenCommand.swift index 149f663a..f38a06be 100644 --- a/ProwlCLI/Commands/OpenCommand.swift +++ b/ProwlCLI/Commands/OpenCommand.swift @@ -49,8 +49,9 @@ struct OpenCommand: ParsableCommand { private static func deriveInvocation(path: String?) -> String { guard path != nil else { return "bare" } - let args = CommandLine.arguments.dropFirst() - if args.first == "open" { + let args = ProcessInfo.processInfo.arguments.dropFirst() + let firstPositional = args.first { !$0.hasPrefix("-") } + if firstPositional == "open" { return "open-subcommand" } return "implicit-open" diff --git a/ProwlCLI/Output/OutputRenderer.swift b/ProwlCLI/Output/OutputRenderer.swift index c0ef5a14..bac756f2 100644 --- a/ProwlCLI/Output/OutputRenderer.swift +++ b/ProwlCLI/Output/OutputRenderer.swift @@ -81,6 +81,10 @@ enum OutputRenderer { return } + if response.command == "open" { + return + } + print("ok: \(response.command)") return } diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index 19717961..9949f117 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -44,8 +44,12 @@ final class ProwlCLIIntegrationTests: XCTestCase { ok: true, command: "open", schemaVersion: "prowl.cli.open.v1", - data: RawJSON(encoding: OpenPayload( + data: RawJSON(encoding: OpenResponseData( + invocation: "open-subcommand", + requestedPath: repoRoot.path, + resolvedPath: repoRoot.path, resolution: "exact-root", + appLaunched: false, broughtToFront: true )) ) @@ -60,6 +64,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) if case .open(let input) = envelope.command { let openedPath = try XCTUnwrap(input.path) + XCTAssertEqual(input.invocation, "open-subcommand") XCTAssertEqual( URL(fileURLWithPath: openedPath).resolvingSymlinksInPath().path, repoRoot.resolvingSymlinksInPath().path @@ -73,6 +78,33 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertEqual(payload["command"] as? String, "open") } + func testOpenCommandTextSuccessIsSilent() throws { + let socketPath = temporarySocketPath(suffix: "open-text") + let response = try CommandResponse( + ok: true, + command: "open", + schemaVersion: "prowl.cli.open.v1", + data: RawJSON(encoding: OpenResponseData( + invocation: "implicit-open", + requestedPath: repoRoot.path, + resolvedPath: repoRoot.path, + resolution: "exact-root", + appLaunched: false, + broughtToFront: true + )) + ) + + let (_, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["."] + ) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stdout, "") + XCTAssertEqual(result.stderr, "") + } + func testFocusCommandRoundTripsOverSocket() throws { let socketPath = temporarySocketPath(suffix: "focus") let response = CommandResponse( @@ -1191,11 +1223,19 @@ final class ProwlCLIIntegrationTests: XCTestCase { } } -private struct OpenPayload: Encodable { +private struct OpenResponseData: Encodable { + let invocation: String + let requestedPath: String? + let resolvedPath: String? let resolution: String + let appLaunched: Bool enum CodingKeys: String, CodingKey { + case invocation + case requestedPath = "requested_path" + case resolvedPath = "resolved_path" case resolution + case appLaunched = "app_launched" case broughtToFront = "brought_to_front" } diff --git a/doc-onevcat/contracts/cli/open.md b/doc-onevcat/contracts/cli/open.md index 08829762..c3684e86 100644 --- a/doc-onevcat/contracts/cli/open.md +++ b/doc-onevcat/contracts/cli/open.md @@ -17,6 +17,7 @@ The goal is to give later implementation tasks a stable machine-facing contract. - Success output must tell an agent **what Prowl actually focused or opened**. - Path fields must be normalized to **absolute paths** after CLI parsing. - Success output must be stable enough for scripts; human-oriented prose belongs to non-JSON mode. +- Human-visible success output for `open` should stay silent unless the caller asked for `--json`. ## Success payload @@ -87,10 +88,13 @@ The goal is to give later implementation tasks a stable machine-facing contract. - `created_tab`: boolean. - `true` when the operation created a new tab as part of satisfying the request. - `false` when Prowl only focused an existing target. -- `target`: object describing the final focused target. +- `target`: object or `null`, describing the final focused target. + - May be `null` for bare `prowl` when Prowl was only brought to front and there is no resolvable visible surface yet. ## `target` shape +When present, `target` has the following shape: + ### `target.worktree` - `id`: string @@ -150,7 +154,7 @@ The goal is to give later implementation tasks a stable machine-facing contract. - Success JSON should report the **resolved target**, not only the input path. - `target.tab.cwd` and `target.pane.cwd` should be the exact directory the user lands in when available. -- `created_tab` may be `false` for `exact-root` and `true` for `inside-root`; `new-root` may do either depending on implementation, so callers must trust the boolean instead of inferring it. +- `created_tab` may be `false` for `exact-root` and `true` for `inside-root`; callers must trust the boolean instead of inferring it from `resolution`. - `prowl` without arguments still returns `command: "open"`; it is the app-entry form of the same capability. ## Example: exact-root focus diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 92dffabc..21675e72 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -373,21 +373,18 @@ struct SupacodeApp: App { appStore.send(.repositories(.repositoryManagement(.openRepositories([url])))) }, createTabAtPath: { worktreeID, path in - // Find the Worktree object by ID to create a tab cd'd to the subpath. - let repositories = appStore.state.repositories - for repository in repositories.repositories { - if let worktree = repository.worktrees.first(where: { $0.id == worktreeID }) { - let quotedPath = shellQuote(path) - terminalManager.handleCommand( - .createTabWithInput( - worktree, - input: "cd -- \(quotedPath)", - runSetupScriptIfNew: false - ) - ) - return - } + let repositories = Array(appStore.state.repositories.repositories) + guard let worktree = resolveCLITerminalWorktree(id: worktreeID, repositories: repositories) else { + return } + let quotedPath = shellQuote(path) + terminalManager.handleCommand( + .createTabWithInput( + worktree, + input: "cd -- \(quotedPath)", + runSetupScriptIfNew: false + ) + ) }, resolveTarget: { selector in switch makeTargetResolver(appStore: appStore, terminalManager: terminalManager).resolve(selector) { @@ -500,6 +497,30 @@ struct SupacodeApp: App { "'\(value.replacing("'", with: "'\"'\"'"))'" } + static func resolveCLITerminalWorktree( + id: Worktree.ID, + repositories: [Repository] + ) -> Worktree? { + for repository in repositories { + if let worktree = repository.worktrees[id: id] { + return worktree + } + if repository.id == id, + repository.capabilities.supportsRunnableFolderActions, + !repository.capabilities.supportsWorktrees + { + return Worktree( + id: repository.id, + name: repository.name, + detail: repository.rootURL.path(percentEncoded: false), + workingDirectory: repository.rootURL, + repositoryRootURL: repository.rootURL + ) + } + } + return nil + } + private static func selectCLIWorktreeContext( worktreeID: Worktree.ID, appStore: StoreOf, diff --git a/supacode/CLIService/OpenCommandHandler.swift b/supacode/CLIService/OpenCommandHandler.swift index 94d848fc..d5550ff2 100644 --- a/supacode/CLIService/OpenCommandHandler.swift +++ b/supacode/CLIService/OpenCommandHandler.swift @@ -41,7 +41,7 @@ struct OpenCommandData: Codable { let appLaunched: Bool let broughtToFront: Bool let createdTab: Bool - let target: OpenTarget + let target: OpenTarget? enum CodingKeys: String, CodingKey { case invocation @@ -71,7 +71,11 @@ struct OpenCommandData: Codable { try container.encode(appLaunched, forKey: .appLaunched) try container.encode(broughtToFront, forKey: .broughtToFront) try container.encode(createdTab, forKey: .createdTab) - try container.encode(target, forKey: .target) + if let target { + try container.encode(target, forKey: .target) + } else { + try container.encodeNil(forKey: .target) + } } } @@ -221,9 +225,7 @@ final class OpenCommandHandler: CommandHandler { switch result.resolution { case .noArgument: bringAppToFront() - guard let target = await waitForOpenTarget(selector: .none) else { - return makeFailure(message: "Failed to resolve the current focused target.") - } + let target = await waitForOpenTarget(selector: .none) return makeSuccess( invocation: invocation, requestedPath: nil, @@ -238,21 +240,33 @@ final class OpenCommandHandler: CommandHandler { guard let worktreeID = result.worktreeID else { return makeFailure(message: "Resolved exact-root target is missing a worktree ID.") } + let requestedPath = result.resolvedPath ?? input.path selectWorktree(worktreeID) bringAppToFront() - guard let target = await waitForOpenTarget( + + var createdTab = false + var target = await waitForOpenTarget( selector: .worktree(worktreeID), - preferredPaneCWD: result.resolvedPath ?? input.path - ) else { + preferredPaneCWD: requestedPath + ) + if target == nil, let requestedPath { + createTabAtPath(worktreeID, requestedPath) + createdTab = true + target = await waitForOpenTarget( + selector: .worktree(worktreeID), + preferredPaneCWD: requestedPath + ) + } + guard let target else { return makeFailure(message: "Failed to resolve the focused target for '\(worktreeID)'.") } return makeSuccess( invocation: invocation, requestedPath: input.path, - resolvedPath: result.resolvedPath ?? input.path, + resolvedPath: requestedPath, resolution: .exactRoot, appLaunched: appLaunched, - createdTab: false, + createdTab: createdTab, target: target ) @@ -290,9 +304,22 @@ final class OpenCommandHandler: CommandHandler { bringAppToFront() let finalResult = await waitForManagedResult(path: path) - guard let worktreeID = finalResult?.worktreeID, - let target = await waitForOpenTarget(selector: .worktree(worktreeID)) - else { + guard let worktreeID = finalResult?.worktreeID else { + return makeFailure(message: "Failed to resolve the newly opened target for '\(path)'.") + } + + var target = await waitForOpenTarget( + selector: .worktree(worktreeID), + preferredPaneCWD: path + ) + if target == nil { + createTabAtPath(worktreeID, path) + target = await waitForOpenTarget( + selector: .worktree(worktreeID), + preferredPaneCWD: path + ) + } + guard let target else { return makeFailure(message: "Failed to resolve the newly opened target for '\(path)'.") } @@ -433,7 +460,7 @@ final class OpenCommandHandler: CommandHandler { resolution: OpenResolution, appLaunched: Bool, createdTab: Bool, - target: OpenTarget + target: OpenTarget? ) -> CommandResponse { let payload = OpenCommandData( invocation: invocation, diff --git a/supacodeTests/OpenCommandHandlerTests.swift b/supacodeTests/OpenCommandHandlerTests.swift index 27b03dd2..4f9a8fdf 100644 --- a/supacodeTests/OpenCommandHandlerTests.swift +++ b/supacodeTests/OpenCommandHandlerTests.swift @@ -127,13 +127,14 @@ struct OpenCommandHandlerTests { let data = try #require(response.data) let payload = try data.decode(as: OpenCommandData.self) + let target = try #require(payload.target) #expect(payload.resolution == "no-argument") #expect(payload.invocation == "bare") #expect(payload.requestedPath == nil) #expect(payload.resolvedPath == nil) #expect(payload.broughtToFront == true) #expect(payload.appLaunched == false) - #expect(payload.target.worktree.id == "Prowl:/Users/test/Projects/Prowl") + #expect(target.worktree.id == "Prowl:/Users/test/Projects/Prowl") let json = try jsonObject(from: response) #expect(json.keys.contains("requested_path")) @@ -143,6 +144,24 @@ struct OpenCommandHandlerTests { #expect((json["target"] as? [String: Any]) != nil) } + @MainActor + @Test func openWithNoPathSucceedsEvenWhenNoFocusedSurfaceCanBeResolved() async throws { + let handler = makeHandler( + resolveTarget: { _ in nil } + ) + + let envelope = CommandEnvelope(output: .json, command: .open(OpenInput())) + let response = await handler.handle(envelope: envelope) + + #expect(response.ok == true) + + let json = try jsonObject(from: response) + #expect(json["resolution"] as? String == "no-argument") + #expect(json["created_tab"] as? Bool == false) + #expect(json.keys.contains("target")) + #expect(json["target"] is NSNull) + } + // MARK: - Exact root @MainActor @@ -179,31 +198,84 @@ struct OpenCommandHandlerTests { let exactRootData = try #require(response.data) let payload = try exactRootData.decode(as: OpenCommandData.self) + let target = try #require(payload.target) #expect(payload.resolution == "exact-root") #expect(payload.invocation == "open-subcommand") #expect(payload.requestedPath == "/Users/test/Projects/Prowl") #expect(payload.resolvedPath == "/Users/test/Projects/Prowl") #expect(payload.createdTab == false) #expect(payload.broughtToFront == true) - #expect(payload.target.worktree.id == "Prowl:/Users/test/Projects/Prowl") - #expect(payload.target.tab.id == "0E2A7C03-9C01-4BC1-9327-6C1C7B629A52") - #expect(payload.target.pane.id == "0FB4DDB4-A797-4315-A00E-8AAFB32BFC95") + #expect(target.worktree.id == "Prowl:/Users/test/Projects/Prowl") + #expect(target.tab.id == "0E2A7C03-9C01-4BC1-9327-6C1C7B629A52") + #expect(target.pane.id == "0FB4DDB4-A797-4315-A00E-8AAFB32BFC95") let json = try jsonObject(from: response) #expect(Set(json.keys) == [ "invocation", "requested_path", "resolved_path", "resolution", "app_launched", "brought_to_front", "created_tab", "target" ]) - let target = try #require(json["target"] as? [String: Any]) - #expect(Set(target.keys) == ["worktree", "tab", "pane"]) - let worktree = try #require(target["worktree"] as? [String: Any]) + let jsonTarget = try #require(json["target"] as? [String: Any]) + #expect(Set(jsonTarget.keys) == ["worktree", "tab", "pane"]) + let worktree = try #require(jsonTarget["worktree"] as? [String: Any]) #expect(Set(worktree.keys) == ["id", "name", "path", "root_path", "kind"]) - let tab = try #require(target["tab"] as? [String: Any]) + let tab = try #require(jsonTarget["tab"] as? [String: Any]) #expect(Set(tab.keys) == ["id", "title", "cwd"]) - let pane = try #require(target["pane"] as? [String: Any]) + let pane = try #require(jsonTarget["pane"] as? [String: Any]) #expect(Set(pane.keys) == ["id", "title", "cwd"]) } + @MainActor + @Test func openExactRootCreatesNewTabWhenSelectedWorktreeHasNoVisibleSurface() async throws { + var selectedID: String? + var createdTabFor: String? + var createdTabAtPath: String? + let created = MutableBox(false) + let rootPath = "/Users/test/Projects/Prowl" + + let handler = makeHandler( + resolver: { _ in + OpenResolverResult( + resolution: .exactRoot, + worktreeID: "Prowl:/Users/test/Projects/Prowl", + worktreeName: "Prowl", + worktreePath: rootPath, + rootPath: rootPath, + worktreeKind: "git", + resolvedPath: rootPath + ) + }, + selectWorktree: { selectedID = $0 }, + createTabAtPath: { worktreeID, path in + createdTabFor = worktreeID + createdTabAtPath = path + created.value = true + }, + resolveTarget: { selector in + guard created.value, case .worktree(let value) = selector else { return nil } + return makeResolvedTarget(worktreeID: value, tabCWD: rootPath, paneCWD: rootPath) + }, + waitTimeoutNanoseconds: 3 + ) + + let envelope = CommandEnvelope( + output: .json, + command: .open(OpenInput(path: rootPath, invocation: "open-subcommand")) + ) + let response = await handler.handle(envelope: envelope) + + #expect(response.ok == true) + #expect(selectedID == "Prowl:/Users/test/Projects/Prowl") + #expect(createdTabFor == "Prowl:/Users/test/Projects/Prowl") + #expect(createdTabAtPath == rootPath) + + let json = try jsonObject(from: response) + #expect(json["resolution"] as? String == "exact-root") + #expect(json["created_tab"] as? Bool == true) + let target = try #require(json["target"] as? [String: Any]) + let pane = try #require(target["pane"] as? [String: Any]) + #expect(pane["cwd"] as? String == rootPath) + } + // MARK: - Inside root @MainActor @@ -250,13 +322,14 @@ struct OpenCommandHandlerTests { let insideRootData = try #require(response.data) let payload = try insideRootData.decode(as: OpenCommandData.self) + let target = try #require(payload.target) #expect(payload.resolution == "inside-root") #expect(payload.invocation == "implicit-open") #expect(payload.requestedPath == subpath) #expect(payload.resolvedPath == subpath) #expect(payload.createdTab == true) - #expect(payload.target.tab.cwd == subpath) - #expect(payload.target.pane.cwd == subpath) + #expect(target.tab.cwd == subpath) + #expect(target.pane.cwd == subpath) } // MARK: - New root @@ -306,7 +379,7 @@ struct OpenCommandHandlerTests { managed.value = true } }, - waitTimeoutNanoseconds: 10_000_000 + waitTimeoutNanoseconds: 3 ) let envelope = CommandEnvelope( @@ -320,10 +393,91 @@ struct OpenCommandHandlerTests { let newRootData = try #require(response.data) let payload = try newRootData.decode(as: OpenCommandData.self) + let target = try #require(payload.target) #expect(payload.resolution == "new-root") #expect(payload.requestedPath == "/Users/test/NewProject") #expect(payload.createdTab == true) - #expect(payload.target.worktree.id == "NewProject:/Users/test/NewProject") + #expect(target.worktree.id == "NewProject:/Users/test/NewProject") + } + + @MainActor + @Test func openNewRootCreatesTabWhenRepositoryBecomesManagedWithoutVisibleSurface() async throws { + var addedURL: URL? + var createdTabFor: String? + var createdTabAtPath: String? + let managed = MutableBox(false) + let created = MutableBox(false) + let rootPath = "/Users/test/NewProject" + let worktreeID = "NewProject:/Users/test/NewProject" + + let handler = makeHandler( + resolver: { _ in + if managed.value { + return OpenResolverResult( + resolution: .exactRoot, + worktreeID: worktreeID, + worktreeName: "NewProject", + worktreePath: rootPath, + rootPath: rootPath, + worktreeKind: "git", + resolvedPath: rootPath + ) + } + return OpenResolverResult( + resolution: .newRoot, + worktreeID: nil, + worktreeName: nil, + worktreePath: nil, + rootPath: nil, + worktreeKind: nil, + resolvedPath: rootPath + ) + }, + addAndOpen: { url in addedURL = url }, + createTabAtPath: { id, path in + createdTabFor = id + createdTabAtPath = path + created.value = true + }, + resolveTarget: { selector in + guard created.value, case .worktree(let value) = selector else { return nil } + return makeResolvedTarget( + worktreeID: value, + worktreeName: "NewProject", + worktreePath: rootPath, + worktreeRootPath: rootPath, + tabCWD: rootPath, + paneCWD: rootPath + ) + }, + sleep: { _ in + await MainActor.run { + managed.value = true + } + }, + waitTimeoutNanoseconds: 3 + ) + + let envelope = CommandEnvelope( + output: .json, + command: .open(OpenInput(path: rootPath, invocation: "implicit-open")) + ) + let response = await handler.handle(envelope: envelope) + + #expect(response.ok == true) + #expect( + addedURL?.standardizedFileURL.path(percentEncoded: false) + == URL(fileURLWithPath: rootPath, isDirectory: true).standardizedFileURL.path(percentEncoded: false) + ) + #expect(createdTabFor == worktreeID) + #expect(createdTabAtPath == rootPath) + + let json = try jsonObject(from: response) + #expect(json["resolution"] as? String == "new-root") + #expect(json["created_tab"] as? Bool == true) + let target = try #require(json["target"] as? [String: Any]) + let pane = try #require(target["pane"] as? [String: Any]) + #expect(pane["cwd"] as? String == rootPath) } // MARK: - Readiness gating @@ -448,7 +602,9 @@ struct OpenCommandHandlerTests { worktreeID: value, worktreeName: "test", worktreePath: "/tmp/test", - worktreeRootPath: "/tmp/test" + worktreeRootPath: "/tmp/test", + tabCWD: "/tmp/test", + paneCWD: "/tmp/test" ) }, sleep: { _ in @@ -456,7 +612,7 @@ struct OpenCommandHandlerTests { managed.value = true } }, - waitTimeoutNanoseconds: 10_000_000 + waitTimeoutNanoseconds: 3 ) let envelope = CommandEnvelope(output: .json, command: .open(OpenInput(path: "/tmp/test"))) let response = await handler.handle(envelope: envelope) diff --git a/supacodeTests/SupacodeAppCLITests.swift b/supacodeTests/SupacodeAppCLITests.swift index 91eca3dd..171514f3 100644 --- a/supacodeTests/SupacodeAppCLITests.swift +++ b/supacodeTests/SupacodeAppCLITests.swift @@ -43,4 +43,27 @@ struct SupacodeAppCLITests { #expect(keyResponse.error?.code != "NOT_IMPLEMENTED") #expect(readResponse.error?.code != "NOT_IMPLEMENTED") } + + @Test func resolveCLITerminalWorktreeBuildsSyntheticRunnableFolderWorktree() { + let repository = Repository( + id: "/Users/test/PlainFolder", + rootURL: URL(fileURLWithPath: "/Users/test/PlainFolder", isDirectory: true), + name: "PlainFolder", + kind: .plain, + worktrees: [] + ) + + let resolved = SupacodeApp.resolveCLITerminalWorktree( + id: repository.id, + repositories: [repository] + ) + + #expect(resolved?.id == repository.id) + #expect(resolved?.name == "PlainFolder") + #expect( + resolved?.workingDirectory.standardizedFileURL.path(percentEncoded: false) + == URL(fileURLWithPath: "/Users/test/PlainFolder", isDirectory: true) + .standardizedFileURL.path(percentEncoded: false) + ) + } }