native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(cli): add Install Command Line Tool via Settings, menu, and Command Palette

Adds the ability to install the `prowl` CLI from within the app:
- CLIInstallClient dependency for symlink-based install/uninstall
- Settings > Advanced: CLI install status + install/uninstall button
- Prowl menu: "Install Command Line Tool..." item
- Command Palette: "Install Command Line Tool" item
- Makefile targets: build-cli-release, embed-cli
- Xcode project: prowl-cli resource bundling
- Tests for CLIInstallClient and AppFeature reducer

onevcat 12e06688 37eafb2b

+902 -4
+12
Makefile
··· 88 88 build-cli: # Build Swift CLI binary (SPM) 89 89 swift build --product prowl 90 90 91 + build-cli-release: # Build CLI binary in release mode 92 + swift build -c release --product prowl 93 + 94 + embed-cli: build-cli-release # Build CLI and copy into Resources for app bundling 95 + @set -euo pipefail; \ 96 + bin="$$(swift build -c release --show-bin-path)/prowl"; \ 97 + dst="$(CURRENT_MAKEFILE_DIR)/Resources/prowl-cli"; \ 98 + mkdir -p "$$dst"; \ 99 + cp "$$bin" "$$dst/prowl"; \ 100 + chmod +x "$$dst/prowl"; \ 101 + echo "embedded CLI binary at $$dst/prowl" 102 + 91 103 run-app: build-app # Build then launch (Debug) with log streaming 92 104 @set -euo pipefail; \ 93 105 settings="$$(xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug -showBuildSettings -json 2>/dev/null)"; \
Resources/prowl-cli/.gitkeep

This is a binary file and will not be displayed.

+182
docs/plans/2026-04-04-cli-install-command.md
··· 1 + # CLI Install Command Implementation Plan 2 + 3 + **Goal:** Allow users to install the `prowl` CLI tool from within the Prowl app via three entry points: Settings, Prowl menu, and Command Palette. 4 + 5 + **Scope:** 6 + - In: CLIInstallClient dependency, Advanced Settings UI, Prowl menu item, Command Palette item, AppFeature wiring, Makefile CLI embedding, tests 7 + - Out: Auto-prompting on first launch, uninstall UI (can be added later), CLI build as part of Xcode build phase (manual `make build-cli` for now) 8 + 9 + **Architecture:** 10 + - `CLIInstallClient`: TCA dependency client that handles symlink creation, status checking, and bundled binary path resolution 11 + - Install action lives in `AppFeature` — all three entry points (Settings, Menu, Command Palette) funnel into the same `installCLI` action 12 + - CLI binary is embedded at `Prowl.app/Contents/Resources/prowl-cli/prowl` 13 + - Installation creates a symlink: `/usr/local/bin/prowl` → bundled binary path 14 + - Advanced Settings gets a new "Command Line Tool" section showing install status + install/uninstall button 15 + 16 + **Acceptance / Verification:** 17 + - `make build-app` succeeds 18 + - All existing tests pass 19 + - New CLIInstallClient tests pass 20 + - New AppFeature CLI install reducer tests pass 21 + - Menu item "Install Command Line Tool" visible under Prowl menu 22 + - Command Palette shows "Install Command Line Tool" item 23 + - Settings > Advanced shows CLI install section with status and action button 24 + 25 + --- 26 + 27 + ## Task 1: Create CLIInstallClient dependency 28 + 29 + **Files:** 30 + - Create: `supacode/Clients/CLIInstall/CLIInstallClient.swift` 31 + 32 + **Steps:** 33 + 1. Create `CLIInstallClient` struct following `WorkspaceClient` pattern 34 + 2. Provide operations: 35 + - `bundledCLIURL: @Sendable () -> URL?` — returns `Bundle.main.resourceURL/prowl-cli/prowl` 36 + - `installationStatus: @Sendable () -> CLIInstallStatus` — checks if symlink exists and points to correct target 37 + - `install: @Sendable (URL) async throws -> Void` — creates symlink at given path (default `/usr/local/bin/prowl`) 38 + - `uninstall: @Sendable (URL) async throws -> Void` — removes symlink at given path 39 + 3. Define `CLIInstallStatus` enum: `.notInstalled`, `.installed(path: String)`, `.installedDifferentSource(path: String)` 40 + 4. Implement `DependencyKey` with `liveValue` and `testValue` 41 + 5. Register in `DependencyValues` 42 + 43 + **Notes:** 44 + - Use `FileManager` for symlink operations 45 + - `install` should create `/usr/local/bin` directory if it doesn't exist 46 + - Check if destination already exists before creating symlink; if it's a symlink pointing elsewhere, report `.installedDifferentSource` 47 + 48 + --- 49 + 50 + ## Task 2: Add CLI install actions to AppFeature 51 + 52 + **Files:** 53 + - Modify: `supacode/Features/App/Reducer/AppFeature.swift` (add actions and reducer cases) 54 + 55 + **Steps:** 56 + 1. Add new actions to AppFeature.Action: 57 + - `installCLI` 58 + - `uninstallCLI` 59 + - `cliInstallResult(Result<String, CLIInstallError>)` — result of install/uninstall with success message or error 60 + 2. Add `@Dependency(CLIInstallClient.self)` to AppFeature 61 + 3. Implement reducer cases: 62 + - `installCLI`: run `.install()` via client, send result action 63 + - `uninstallCLI`: run `.uninstall()` via client, send result action 64 + - `cliInstallResult`: show alert with success/failure message 65 + 4. Add `CLIInstallError` type for error reporting 66 + 67 + --- 68 + 69 + ## Task 3: Add CLI install section to Advanced Settings 70 + 71 + **Files:** 72 + - Modify: `supacode/Features/Settings/Views/AdvancedSettingsView.swift` (add CLI section) 73 + 74 + **Steps:** 75 + 1. Add a new `Section("Command Line Tool")` in `AdvancedSettingsView` 76 + 2. Show current installation status (use `CLIInstallClient` to check) 77 + 3. Show Install/Uninstall button based on status 78 + 4. Button sends action to the `AppFeature` store (the settings view already receives `StoreOf<SettingsFeature>`, but we need to access `AppFeature` actions — use a callback or add delegate actions) 79 + 80 + **Design decision:** Since AdvancedSettingsView only has `StoreOf<SettingsFeature>`, add delegate actions to SettingsFeature: 81 + - `SettingsFeature.Delegate.installCLIRequested` 82 + - `SettingsFeature.Delegate.uninstallCLIRequested` 83 + - Handle these in AppFeature's `.settings(.delegate(...))` case 84 + 85 + **Notes:** 86 + - Show the install path (`/usr/local/bin/prowl`) in the UI 87 + - Show a green checkmark or status text for installed state 88 + - The view should refresh status when the settings tab appears 89 + 90 + --- 91 + 92 + ## Task 4: Add menu item in Prowl menu 93 + 94 + **Files:** 95 + - Modify: `supacode/App/supacodeApp.swift` (add menu item in Prowl menu group) 96 + 97 + **Steps:** 98 + 1. Add a `CommandGroup(after: .appSettings)` or within the existing Prowl menu area 99 + 2. Add "Install Command Line Tool..." button 100 + 3. Button sends `store.send(.installCLI)` action 101 + 4. Add appropriate `.help()` text 102 + 103 + --- 104 + 105 + ## Task 5: Add Command Palette item 106 + 107 + **Files:** 108 + - Modify: `supacode/Features/CommandPalette/CommandPaletteItem.swift` (add Kind case) 109 + - Modify: `supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift` (add item, delegate, mapping) 110 + - Modify: `supacode/Features/App/Reducer/AppFeature.swift` (handle new delegate) 111 + 112 + **Steps:** 113 + 1. Add `case installCLI` to `CommandPaletteItem.Kind` 114 + 2. Update `isGlobal` and `isRootAction` to return `true` for `.installCLI` 115 + 3. Add `case installCLI` to `CommandPaletteFeature.Delegate` 116 + 4. Add `CommandPaletteItem` to `commandPaletteItems()` function 117 + 5. Add ID `globalInstallCLI` to `CommandPaletteItemID` 118 + 6. Add to `globalIDs` array 119 + 7. Update `delegateAction(for:)` mapping 120 + 8. Update `appShortcutCommandID` (return nil for installCLI) 121 + 9. Handle `.commandPalette(.delegate(.installCLI))` in AppFeature reducer 122 + 123 + --- 124 + 125 + ## Task 6: Makefile integration for CLI embedding 126 + 127 + **Files:** 128 + - Modify: `Makefile` (add target to build CLI for bundle) 129 + 130 + **Steps:** 131 + 1. Add `build-cli-release` target: `swift build -c release --product prowl` 132 + 2. Add `embed-cli` target: copies release binary to `Resources/prowl-cli/prowl` 133 + 3. Update `build-app` to depend on `embed-cli` (or document manual step) 134 + 4. Add `Resources/prowl-cli/` to Xcode "Copy Bundle Resources" if not auto-included 135 + 136 + **Notes:** 137 + - For development, `Resources/prowl-cli/prowl` can be a placeholder — the actual install will use the bundled path at runtime 138 + 139 + --- 140 + 141 + ## Task 7: Tests for CLIInstallClient 142 + 143 + **Files:** 144 + - Create: `supacodeTests/CLIInstallClientTests.swift` 145 + 146 + **Steps:** 147 + 1. Test `installationStatus` returns `.notInstalled` when no symlink exists 148 + 2. Test `installationStatus` returns `.installed` when valid symlink exists 149 + 3. Test `installationStatus` returns `.installedDifferentSource` when symlink points elsewhere 150 + 4. Test `install` creates symlink at expected path 151 + 5. Test `install` creates parent directory if needed 152 + 6. Test `uninstall` removes symlink 153 + 7. Test `uninstall` does not remove non-symlink files (safety) 154 + 155 + **Notes:** 156 + - Use temp directories for test isolation 157 + - Test with actual FileManager operations (not mocks) for the live client 158 + 159 + --- 160 + 161 + ## Task 8: Tests for AppFeature CLI install reducer 162 + 163 + **Files:** 164 + - Create: `supacodeTests/AppFeatureCLIInstallTests.swift` 165 + 166 + **Steps:** 167 + 1. Test `.installCLI` action triggers client install call 168 + 2. Test `.uninstallCLI` action triggers client uninstall call 169 + 3. Test success result shows appropriate alert 170 + 4. Test failure result shows error alert 171 + 5. Test Command Palette delegate `.installCLI` forwards to `.installCLI` action 172 + 6. Test Settings delegate `.installCLIRequested` forwards to `.installCLI` action 173 + 174 + --- 175 + 176 + ## Task 9: Build verification 177 + 178 + **Steps:** 179 + 1. Run `make build-app` — verify success 180 + 2. Run existing tests — verify no regressions 181 + 3. Run new tests — verify all pass 182 + 4. Run `make lint` — verify no lint errors
+4
supacode.xcodeproj/project.pbxproj
··· 15 15 D64162B02F23CAF100260CA3 /* ghostty in Resources */ = {isa = PBXBuildFile; fileRef = D64162AD2F23CAF100260CA3 /* ghostty */; }; 16 16 D64162B12F23CAF100260CA3 /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = D64162AE2F23CAF100260CA3 /* terminfo */; }; 17 17 D64162B22F23CAF100260CA3 /* git-wt in Resources */ = {isa = PBXBuildFile; fileRef = D64162AF2F23CAF100260CA3 /* git-wt */; }; 18 + D6CLI0012F99000100000001 /* prowl-cli in Resources */ = {isa = PBXBuildFile; fileRef = D6CLI0022F99000100000001 /* prowl-cli */; }; 18 19 D661760B2F250A60000FC27C /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = D661760A2F250A60000FC27C /* CasePaths */; }; 19 20 D6A1CB312F1F4ABF004FDABD /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6A1CB2F2F1F4A48004FDABD /* GhosttyKit.xcframework */; }; 20 21 D6A1CB362F1F4ACB004FDABD /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6A1CB342F1F4AC2004FDABD /* Carbon.framework */; }; ··· 36 37 D64162AD2F23CAF100260CA3 /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = Resources/ghostty; sourceTree = "<group>"; }; 37 38 D64162AE2F23CAF100260CA3 /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = Resources/terminfo; sourceTree = "<group>"; }; 38 39 D64162AF2F23CAF100260CA3 /* git-wt */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "git-wt"; path = "Resources/git-wt"; sourceTree = "<group>"; }; 40 + D6CLI0022F99000100000001 /* prowl-cli */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "prowl-cli"; path = "Resources/prowl-cli"; sourceTree = "<group>"; }; 39 41 D69CE04A2F1F378200584C57 /* Prowl.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Prowl.app; sourceTree = BUILT_PRODUCTS_DIR; }; 40 42 D6A1CB2F2F1F4A48004FDABD /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = "<group>"; }; 41 43 D6A1CB342F1F4AC2004FDABD /* Carbon.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Carbon.framework; path = System/Library/Frameworks/Carbon.framework; sourceTree = SDKROOT; }; ··· 101 103 children = ( 102 104 D64162AD2F23CAF100260CA3 /* ghostty */, 103 105 D64162AF2F23CAF100260CA3 /* git-wt */, 106 + D6CLI0022F99000100000001 /* prowl-cli */, 104 107 D64162AE2F23CAF100260CA3 /* terminfo */, 105 108 D69CE04C2F1F378200584C57 /* supacode */, 106 109 D6F821022F1FCBC1004B4174 /* supacodeTests */, ··· 240 243 D64162B02F23CAF100260CA3 /* ghostty in Resources */, 241 244 D64162B12F23CAF100260CA3 /* terminfo in Resources */, 242 245 D64162B22F23CAF100260CA3 /* git-wt in Resources */, 246 + D6CLI0012F99000100000001 /* prowl-cli in Resources */, 243 247 ); 244 248 runOnlyForDeploymentPostprocessing = 0; 245 249 };
+6
supacode/App/supacodeApp.swift
··· 571 571 ) 572 572 ) 573 573 } 574 + CommandGroup(after: .appSettings) { 575 + Button("Install Command Line Tool...") { 576 + store.send(.installCLI) 577 + } 578 + .help("Install the prowl command line tool to /usr/local/bin") 579 + } 574 580 CommandGroup(replacing: .appTermination) { 575 581 Button("Quit Prowl") { 576 582 store.send(.requestQuit)
+117
supacode/Clients/CLIInstall/CLIInstallClient.swift
··· 1 + import ComposableArchitecture 2 + import Foundation 3 + 4 + enum CLIInstallStatus: Equatable, Sendable { 5 + case notInstalled 6 + case installed(path: String) 7 + case installedDifferentSource(path: String) 8 + } 9 + 10 + struct CLIInstallError: Error, Equatable, Sendable, LocalizedError { 11 + let message: String 12 + 13 + var errorDescription: String? { message } 14 + } 15 + 16 + let cliDefaultInstallPath = URL(fileURLWithPath: "/usr/local/bin/prowl") 17 + 18 + struct CLIInstallClient: Sendable { 19 + var bundledCLIURL: @Sendable () -> URL? 20 + var installationStatus: @Sendable (_ installPath: URL) -> CLIInstallStatus 21 + var install: @Sendable (_ installPath: URL) async throws -> Void 22 + var uninstall: @Sendable (_ installPath: URL) async throws -> Void 23 + } 24 + 25 + extension CLIInstallClient: DependencyKey { 26 + // swiftlint:disable:next closure_body_length 27 + static let liveValue = CLIInstallClient( 28 + bundledCLIURL: { 29 + Bundle.main.resourceURL?.appendingPathComponent("prowl-cli/prowl") 30 + }, 31 + installationStatus: { installPath in 32 + let fileManager = FileManager.default 33 + let path = installPath.path(percentEncoded: false) 34 + guard fileManager.fileExists(atPath: path) else { 35 + return .notInstalled 36 + } 37 + guard let attrs = try? fileManager.attributesOfItem(atPath: path), 38 + attrs[.type] as? FileAttributeType == .typeSymbolicLink, 39 + let destination = try? fileManager.destinationOfSymbolicLink(atPath: path) 40 + else { 41 + return .installedDifferentSource(path: path) 42 + } 43 + let bundledURL = Bundle.main.resourceURL?.appendingPathComponent("prowl-cli/prowl") 44 + let bundledPath = bundledURL?.path(percentEncoded: false) ?? "" 45 + if destination == bundledPath { 46 + return .installed(path: path) 47 + } 48 + return .installedDifferentSource(path: path) 49 + }, 50 + install: { installPath in 51 + let fileManager = FileManager.default 52 + guard let bundledURL = Bundle.main.resourceURL?.appendingPathComponent("prowl-cli/prowl") else { 53 + throw CLIInstallError(message: "Could not locate bundled CLI binary.") 54 + } 55 + let bundledPath = bundledURL.path(percentEncoded: false) 56 + guard fileManager.fileExists(atPath: bundledPath) else { 57 + throw CLIInstallError(message: "Bundled CLI binary not found at \(bundledPath).") 58 + } 59 + let installDir = installPath.deletingLastPathComponent().path(percentEncoded: false) 60 + if !fileManager.fileExists(atPath: installDir) { 61 + do { 62 + try fileManager.createDirectory(atPath: installDir, withIntermediateDirectories: true) 63 + } catch { 64 + throw CLIInstallError(message: "Could not create directory \(installDir): \(error.localizedDescription)") 65 + } 66 + } 67 + let destination = installPath.path(percentEncoded: false) 68 + if fileManager.fileExists(atPath: destination) { 69 + do { 70 + try fileManager.removeItem(atPath: destination) 71 + } catch { 72 + throw CLIInstallError( 73 + message: "Could not remove existing file at \(destination): \(error.localizedDescription)" 74 + ) 75 + } 76 + } 77 + do { 78 + try fileManager.createSymbolicLink(atPath: destination, withDestinationPath: bundledPath) 79 + } catch { 80 + throw CLIInstallError( 81 + message: "Could not create symlink at \(destination): \(error.localizedDescription)" 82 + ) 83 + } 84 + }, 85 + uninstall: { installPath in 86 + let fileManager = FileManager.default 87 + let path = installPath.path(percentEncoded: false) 88 + guard fileManager.fileExists(atPath: path) else { 89 + throw CLIInstallError(message: "No CLI tool found at \(path).") 90 + } 91 + guard let attrs = try? fileManager.attributesOfItem(atPath: path), 92 + attrs[.type] as? FileAttributeType == .typeSymbolicLink 93 + else { 94 + throw CLIInstallError(message: "File at \(path) is not a symlink. Refusing to remove for safety.") 95 + } 96 + do { 97 + try fileManager.removeItem(atPath: path) 98 + } catch { 99 + throw CLIInstallError(message: "Could not remove \(path): \(error.localizedDescription)") 100 + } 101 + } 102 + ) 103 + 104 + static let testValue = CLIInstallClient( 105 + bundledCLIURL: { nil }, 106 + installationStatus: { _ in .notInstalled }, 107 + install: { _ in }, 108 + uninstall: { _ in } 109 + ) 110 + } 111 + 112 + extension DependencyValues { 113 + var cliInstallClient: CLIInstallClient { 114 + get { self[CLIInstallClient.self] } 115 + set { self[CLIInstallClient.self] = newValue } 116 + } 117 + }
+70
supacode/Features/App/Reducer/AppFeature.swift
··· 93 93 case navigateSearchNext 94 94 case navigateSearchPrevious 95 95 case endSearch 96 + case installCLI 97 + case uninstallCLI 98 + case cliInstallCompleted(Result<String, CLIInstallError>) 96 99 case systemNotificationsPermissionFailed(errorMessage: String?) 97 100 case alert(PresentationAction<Alert>) 98 101 case terminalEvent(TerminalClient.Event) ··· 112 115 @Dependency(TerminalClient.self) private var terminalClient 113 116 @Dependency(WorktreeInfoWatcherClient.self) private var worktreeInfoWatcher 114 117 @Dependency(CustomShortcutRegistryClient.self) private var customShortcutRegistryClient 118 + @Dependency(CLIInstallClient.self) private var cliInstallClient 115 119 116 120 private func resolvedKeybindings( 117 121 settings: SettingsFeature.State, ··· 472 476 case .settings(.delegate(.terminalFontSizeChanged)): 473 477 return .none 474 478 479 + case .settings(.delegate(.installCLIRequested)): 480 + return .send(.installCLI) 481 + 482 + case .settings(.delegate(.uninstallCLIRequested)): 483 + return .send(.uninstallCLI) 484 + 475 485 case .settings(.delegate(.terminalLayoutSnapshotCleared(let success))): 476 486 if success { 477 487 state.suppressLayoutSaveUntilRelaunch = true ··· 774 784 await customShortcutRegistryClient.setShortcuts(shortcuts) 775 785 } 776 786 787 + case .installCLI: 788 + let installPath = cliDefaultInstallPath 789 + return .run { [cliInstallClient] send in 790 + do { 791 + try await cliInstallClient.install(installPath) 792 + let path = installPath.path(percentEncoded: false) 793 + await send(.cliInstallCompleted(.success(path))) 794 + } catch let error as CLIInstallError { 795 + await send(.cliInstallCompleted(.failure(error))) 796 + } catch { 797 + await send(.cliInstallCompleted(.failure(CLIInstallError(message: error.localizedDescription)))) 798 + } 799 + } 800 + 801 + case .uninstallCLI: 802 + let installPath = cliDefaultInstallPath 803 + return .run { [cliInstallClient] send in 804 + do { 805 + try await cliInstallClient.uninstall(installPath) 806 + await send(.cliInstallCompleted(.success(""))) 807 + } catch let error as CLIInstallError { 808 + await send(.cliInstallCompleted(.failure(error))) 809 + } catch { 810 + await send(.cliInstallCompleted(.failure(CLIInstallError(message: error.localizedDescription)))) 811 + } 812 + } 813 + 814 + case .cliInstallCompleted(.success(let path)): 815 + if path.isEmpty { 816 + state.alert = AlertState { 817 + TextState("Command Line Tool Uninstalled") 818 + } actions: { 819 + ButtonState(action: .dismiss) { TextState("OK") } 820 + } message: { 821 + TextState("The prowl command line tool has been removed.") 822 + } 823 + } else { 824 + state.alert = AlertState { 825 + TextState("Command Line Tool Installed") 826 + } actions: { 827 + ButtonState(action: .dismiss) { TextState("OK") } 828 + } message: { 829 + TextState("The prowl command is now available at \(path).") 830 + } 831 + } 832 + return .send(.settings(.refreshCLIInstallStatus)) 833 + 834 + case .cliInstallCompleted(.failure(let error)): 835 + state.alert = AlertState { 836 + TextState("Command Line Tool Error") 837 + } actions: { 838 + ButtonState(action: .dismiss) { TextState("OK") } 839 + } message: { 840 + TextState(error.message) 841 + } 842 + return .send(.settings(.refreshCLIInstallStatus)) 843 + 777 844 case .systemNotificationsPermissionFailed(let errorMessage): 778 845 return .concatenate( 779 846 .send(.settings(.setSystemNotificationsEnabled(false))), ··· 831 898 832 899 case .commandPalette(.delegate(.refreshWorktrees)): 833 900 return .send(.repositories(.refreshWorktrees)) 901 + 902 + case .commandPalette(.delegate(.installCLI)): 903 + return .send(.installCLI) 834 904 835 905 case .commandPalette(.delegate(.ghosttyCommand(let action))): 836 906 guard let worktree = state.repositories.selectedTerminalWorktree else {
+4 -2
supacode/Features/CommandPalette/CommandPaletteItem.swift
··· 39 39 case copyCiFailureLogs(Worktree.ID) 40 40 case rerunFailedJobs(Worktree.ID) 41 41 case openFailingCheckDetails(Worktree.ID) 42 + case installCLI 42 43 #if DEBUG 43 44 case debugTestToast(RepositoriesFeature.StatusToast) 44 45 #endif ··· 46 47 47 48 var isGlobal: Bool { 48 49 switch kind { 49 - case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees: 50 + case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees, .installCLI: 50 51 return true 51 52 case .ghosttyCommand: 52 53 return false ··· 70 71 71 72 var isRootAction: Bool { 72 73 switch kind { 73 - case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees: 74 + case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees, .installCLI: 74 75 return true 75 76 case .ghosttyCommand: 76 77 return false ··· 115 116 .copyCiFailureLogs, 116 117 .rerunFailedJobs, 117 118 .openFailingCheckDetails, 119 + .installCLI, 118 120 .worktreeSelect, 119 121 .removeWorktree, 120 122 .archiveWorktree:
+14
supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift
··· 48 48 case copyCiFailureLogs(Worktree.ID) 49 49 case rerunFailedJobs(Worktree.ID) 50 50 case openFailingCheckDetails(Worktree.ID) 51 + case installCLI 51 52 #if DEBUG 52 53 case debugTestToast(RepositoriesFeature.StatusToast) 53 54 #endif ··· 205 206 kind: .refreshWorktrees 206 207 ) 207 208 ) 209 + items.append( 210 + CommandPaletteItem( 211 + id: CommandPaletteItemID.globalInstallCLI, 212 + title: "Install Command Line Tool", 213 + subtitle: nil, 214 + kind: .installCLI 215 + ) 216 + ) 208 217 if repositories.selectedTerminalWorktree != nil { 209 218 items.append(contentsOf: ghosttyCommandItems(ghosttyCommands)) 210 219 } ··· 429 438 static let globalOpenRepository = "global.open-repository" 430 439 static let globalNewWorktree = "global.new-worktree" 431 440 static let globalRefreshWorktrees = "global.refresh-worktrees" 441 + static let globalInstallCLI = "global.install-cli" 432 442 433 443 static var globalIDs: [CommandPaletteItem.ID] { 434 444 [ ··· 437 447 globalOpenRepository, 438 448 globalNewWorktree, 439 449 globalRefreshWorktrees, 450 + globalInstallCLI, 440 451 ] 441 452 } 442 453 ··· 544 555 return .archiveWorktree(worktreeID, repositoryID) 545 556 case .refreshWorktrees: 546 557 return .refreshWorktrees 558 + case .installCLI: 559 + return .installCLI 547 560 case .ghosttyCommand(let action): 548 561 return .ghosttyCommand(action) 549 562 case .openPullRequest, ··· 590 603 .removeWorktree, 591 604 .archiveWorktree, 592 605 .refreshWorktrees, 606 + .installCLI, 593 607 .ghosttyCommand: 594 608 return nil 595 609 #if DEBUG
+6 -2
supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift
··· 348 348 private var badge: String? { 349 349 switch row.kind { 350 350 case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees, 351 - .ghosttyCommand, 351 + .installCLI, .ghosttyCommand, 352 352 .openPullRequest, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, 353 353 .copyCiFailureLogs, 354 354 .rerunFailedJobs, .openFailingCheckDetails, .worktreeSelect: ··· 394 394 return "arrow.counterclockwise" 395 395 case .openFailingCheckDetails: 396 396 return "exclamationmark.triangle" 397 + case .installCLI: 398 + return "terminal" 397 399 case .worktreeSelect: 398 400 return nil 399 401 case .removeWorktree: ··· 410 412 private var emphasis: Bool { 411 413 switch row.kind { 412 414 case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees, 413 - .ghosttyCommand, 415 + .installCLI, .ghosttyCommand, 414 416 .openPullRequest, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, 415 417 .copyCiFailureLogs, 416 418 .rerunFailedJobs, .openFailingCheckDetails: ··· 525 527 base = "Re-run failed jobs" 526 528 case .openFailingCheckDetails: 527 529 base = "Open failing check details" 530 + case .installCLI: 531 + base = "Install Command Line Tool" 528 532 #if DEBUG 529 533 case .debugTestToast: 530 534 base = row.title
+17
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 27 27 var restoreTerminalLayoutOnLaunch: Bool 28 28 var terminalFontSize: Float32? 29 29 var keybindingUserOverrides: KeybindingUserOverrideStore 30 + var cliInstallStatus: CLIInstallStatus = .notInstalled 30 31 var selection: SettingsSection? = .general 31 32 var repositorySettings: RepositorySettingsFeature.State? 32 33 @Presents var alert: AlertState<Alert>? ··· 96 97 case setCommandFinishedNotificationThreshold(String) 97 98 case setTerminalFontSize(Float32?) 98 99 case clearTerminalLayoutSnapshotButtonTapped 100 + case installCLIButtonTapped 101 + case uninstallCLIButtonTapped 102 + case refreshCLIInstallStatus 99 103 case showNotificationPermissionAlert(errorMessage: String?) 100 104 case repositorySettings(RepositorySettingsFeature.Action) 101 105 case alert(PresentationAction<Alert>) ··· 113 117 case settingsChanged(GlobalSettings) 114 118 case terminalFontSizeChanged(Float32?) 115 119 case terminalLayoutSnapshotCleared(success: Bool) 120 + case installCLIRequested 121 + case uninstallCLIRequested 116 122 } 117 123 118 124 @Dependency(AnalyticsClient.self) private var analyticsClient 119 125 @Dependency(SystemNotificationClient.self) private var systemNotificationClient 120 126 @Dependency(TerminalLayoutPersistenceClient.self) private var terminalLayoutPersistence 127 + @Dependency(CLIInstallClient.self) private var cliInstallClient 121 128 122 129 var body: some Reducer<State, Action> { 123 130 BindingReducer() ··· 205 212 let success = await terminalLayoutPersistence.clearSnapshot() 206 213 await send(.delegate(.terminalLayoutSnapshotCleared(success: success))) 207 214 } 215 + 216 + case .installCLIButtonTapped: 217 + return .send(.delegate(.installCLIRequested)) 218 + 219 + case .uninstallCLIButtonTapped: 220 + return .send(.delegate(.uninstallCLIRequested)) 221 + 222 + case .refreshCLIInstallStatus: 223 + state.cliInstallStatus = cliInstallClient.installationStatus(cliDefaultInstallPath) 224 + return .none 208 225 209 226 case .showNotificationPermissionAlert(let errorMessage): 210 227 let message: String
+56
supacode/Features/Settings/Views/AdvancedSettingsView.swift
··· 7 7 var body: some View { 8 8 VStack(alignment: .leading) { 9 9 Form { 10 + Section("Command Line Tool") { 11 + VStack(alignment: .leading, spacing: 8) { 12 + HStack(spacing: 6) { 13 + switch store.cliInstallStatus { 14 + case .installed(let path): 15 + Image(systemName: "checkmark.circle.fill") 16 + .foregroundStyle(.green) 17 + .accessibilityLabel("Installed") 18 + Text("Installed at \(path)") 19 + case .installedDifferentSource(let path): 20 + Image(systemName: "exclamationmark.triangle.fill") 21 + .foregroundStyle(.yellow) 22 + .accessibilityLabel("Different version") 23 + Text("A different version exists at \(path)") 24 + case .notInstalled: 25 + Image(systemName: "xmark.circle") 26 + .foregroundStyle(.secondary) 27 + .accessibilityLabel("Not installed") 28 + Text("Not installed") 29 + } 30 + } 31 + .font(.callout) 32 + 33 + Text("Install the prowl command to control Prowl from the terminal.") 34 + .foregroundStyle(.secondary) 35 + .font(.callout) 36 + 37 + HStack(spacing: 8) { 38 + switch store.cliInstallStatus { 39 + case .notInstalled: 40 + Button("Install") { 41 + store.send(.installCLIButtonTapped) 42 + } 43 + .help("Install prowl command line tool to /usr/local/bin") 44 + .buttonStyle(.bordered) 45 + case .installed: 46 + Button("Uninstall") { 47 + store.send(.uninstallCLIButtonTapped) 48 + } 49 + .help("Remove prowl command line tool from /usr/local/bin") 50 + .buttonStyle(.bordered) 51 + case .installedDifferentSource: 52 + Button("Reinstall") { 53 + store.send(.installCLIButtonTapped) 54 + } 55 + .help("Replace the existing prowl command with the version bundled in this app") 56 + .buttonStyle(.bordered) 57 + } 58 + } 59 + } 60 + .frame(maxWidth: .infinity, alignment: .leading) 61 + .onAppear { 62 + store.send(.refreshCLIInstallStatus) 63 + } 64 + } 65 + 10 66 Section("Advanced") { 11 67 VStack(alignment: .leading) { 12 68 Toggle(
+183
supacodeTests/AppFeatureCLIInstallTests.swift
··· 1 + import ComposableArchitecture 2 + import DependenciesTestSupport 3 + import Foundation 4 + import Testing 5 + 6 + @testable import supacode 7 + 8 + @MainActor 9 + struct AppFeatureCLIInstallTests { 10 + @Test(.dependencies) func installCLICallsClientAndShowsSuccessAlert() async { 11 + let installed = LockIsolated(false) 12 + let store = TestStore( 13 + initialState: AppFeature.State(settings: SettingsFeature.State()) 14 + ) { 15 + AppFeature() 16 + } withDependencies: { 17 + $0.cliInstallClient.install = { _ in 18 + installed.setValue(true) 19 + } 20 + $0.cliInstallClient.installationStatus = { _ in .installed(path: "/usr/local/bin/prowl") } 21 + } 22 + 23 + await store.send(.installCLI) 24 + await store.receive(\.cliInstallCompleted.success) { 25 + $0.alert = AlertState { 26 + TextState("Command Line Tool Installed") 27 + } actions: { 28 + ButtonState(action: .dismiss) { TextState("OK") } 29 + } message: { 30 + TextState("The prowl command is now available at /usr/local/bin/prowl.") 31 + } 32 + } 33 + await store.receive(\.settings.refreshCLIInstallStatus) { 34 + $0.settings.cliInstallStatus = .installed(path: "/usr/local/bin/prowl") 35 + } 36 + 37 + #expect(installed.value == true) 38 + } 39 + 40 + @Test(.dependencies) func installCLIShowsErrorAlertOnFailure() async { 41 + let store = TestStore( 42 + initialState: AppFeature.State(settings: SettingsFeature.State()) 43 + ) { 44 + AppFeature() 45 + } withDependencies: { 46 + $0.cliInstallClient.install = { _ in 47 + throw CLIInstallError(message: "Permission denied") 48 + } 49 + $0.cliInstallClient.installationStatus = { _ in .notInstalled } 50 + } 51 + 52 + await store.send(.installCLI) 53 + await store.receive(\.cliInstallCompleted.failure) { 54 + $0.alert = AlertState { 55 + TextState("Command Line Tool Error") 56 + } actions: { 57 + ButtonState(action: .dismiss) { TextState("OK") } 58 + } message: { 59 + TextState("Permission denied") 60 + } 61 + } 62 + await store.receive(\.settings.refreshCLIInstallStatus) 63 + } 64 + 65 + @Test(.dependencies) func uninstallCLICallsClientAndShowsSuccessAlert() async { 66 + let uninstalled = LockIsolated(false) 67 + let store = TestStore( 68 + initialState: AppFeature.State(settings: SettingsFeature.State()) 69 + ) { 70 + AppFeature() 71 + } withDependencies: { 72 + $0.cliInstallClient.uninstall = { _ in 73 + uninstalled.setValue(true) 74 + } 75 + $0.cliInstallClient.installationStatus = { _ in .notInstalled } 76 + } 77 + 78 + await store.send(.uninstallCLI) 79 + await store.receive(\.cliInstallCompleted.success) { 80 + $0.alert = AlertState { 81 + TextState("Command Line Tool Uninstalled") 82 + } actions: { 83 + ButtonState(action: .dismiss) { TextState("OK") } 84 + } message: { 85 + TextState("The prowl command line tool has been removed.") 86 + } 87 + } 88 + await store.receive(\.settings.refreshCLIInstallStatus) 89 + 90 + #expect(uninstalled.value == true) 91 + } 92 + 93 + @Test(.dependencies) func commandPaletteInstallCLIDelegateForwardsToInstallCLI() async { 94 + let installed = LockIsolated(false) 95 + let store = TestStore( 96 + initialState: AppFeature.State(settings: SettingsFeature.State()) 97 + ) { 98 + AppFeature() 99 + } withDependencies: { 100 + $0.cliInstallClient.install = { _ in 101 + installed.setValue(true) 102 + } 103 + $0.cliInstallClient.installationStatus = { _ in .installed(path: "/usr/local/bin/prowl") } 104 + } 105 + 106 + await store.send(.commandPalette(.delegate(.installCLI))) 107 + await store.receive(\.installCLI) 108 + await store.receive(\.cliInstallCompleted.success) { 109 + $0.alert = AlertState { 110 + TextState("Command Line Tool Installed") 111 + } actions: { 112 + ButtonState(action: .dismiss) { TextState("OK") } 113 + } message: { 114 + TextState("The prowl command is now available at /usr/local/bin/prowl.") 115 + } 116 + } 117 + await store.receive(\.settings.refreshCLIInstallStatus) { 118 + $0.settings.cliInstallStatus = .installed(path: "/usr/local/bin/prowl") 119 + } 120 + 121 + #expect(installed.value == true) 122 + } 123 + 124 + @Test(.dependencies) func settingsInstallCLIDelegateForwardsToInstallCLI() async { 125 + let installed = LockIsolated(false) 126 + let store = TestStore( 127 + initialState: AppFeature.State(settings: SettingsFeature.State()) 128 + ) { 129 + AppFeature() 130 + } withDependencies: { 131 + $0.cliInstallClient.install = { _ in 132 + installed.setValue(true) 133 + } 134 + $0.cliInstallClient.installationStatus = { _ in .installed(path: "/usr/local/bin/prowl") } 135 + } 136 + 137 + await store.send(.settings(.delegate(.installCLIRequested))) 138 + await store.receive(\.installCLI) 139 + await store.receive(\.cliInstallCompleted.success) { 140 + $0.alert = AlertState { 141 + TextState("Command Line Tool Installed") 142 + } actions: { 143 + ButtonState(action: .dismiss) { TextState("OK") } 144 + } message: { 145 + TextState("The prowl command is now available at /usr/local/bin/prowl.") 146 + } 147 + } 148 + await store.receive(\.settings.refreshCLIInstallStatus) { 149 + $0.settings.cliInstallStatus = .installed(path: "/usr/local/bin/prowl") 150 + } 151 + 152 + #expect(installed.value == true) 153 + } 154 + 155 + @Test(.dependencies) func settingsUninstallCLIDelegateForwardsToUninstallCLI() async { 156 + let uninstalled = LockIsolated(false) 157 + let store = TestStore( 158 + initialState: AppFeature.State(settings: SettingsFeature.State()) 159 + ) { 160 + AppFeature() 161 + } withDependencies: { 162 + $0.cliInstallClient.uninstall = { _ in 163 + uninstalled.setValue(true) 164 + } 165 + $0.cliInstallClient.installationStatus = { _ in .notInstalled } 166 + } 167 + 168 + await store.send(.settings(.delegate(.uninstallCLIRequested))) 169 + await store.receive(\.uninstallCLI) 170 + await store.receive(\.cliInstallCompleted.success) { 171 + $0.alert = AlertState { 172 + TextState("Command Line Tool Uninstalled") 173 + } actions: { 174 + ButtonState(action: .dismiss) { TextState("OK") } 175 + } message: { 176 + TextState("The prowl command line tool has been removed.") 177 + } 178 + } 179 + await store.receive(\.settings.refreshCLIInstallStatus) 180 + 181 + #expect(uninstalled.value == true) 182 + } 183 + }
+230
supacodeTests/CLIInstallClientTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + struct CLIInstallClientTests { 7 + private func makeTempDir() throws -> URL { 8 + let tmp = FileManager.default.temporaryDirectory 9 + .appendingPathComponent("prowl-cli-test-\(UUID().uuidString)") 10 + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) 11 + return tmp 12 + } 13 + 14 + private func cleanup(_ url: URL) { 15 + try? FileManager.default.removeItem(at: url) 16 + } 17 + 18 + @Test func statusNotInstalledWhenNoFileExists() throws { 19 + let tmp = try makeTempDir() 20 + defer { cleanup(tmp) } 21 + let installPath = tmp.appendingPathComponent("prowl") 22 + 23 + let client = CLIInstallClient.liveValue 24 + let status = client.installationStatus(installPath) 25 + 26 + #expect(status == .notInstalled) 27 + } 28 + 29 + @Test func statusInstalledWhenSymlinkPointsToBundle() throws { 30 + let tmp = try makeTempDir() 31 + defer { cleanup(tmp) } 32 + 33 + let fakeBundledBinary = tmp.appendingPathComponent("bundled-prowl") 34 + FileManager.default.createFile(atPath: fakeBundledBinary.path, contents: nil) 35 + 36 + let installPath = tmp.appendingPathComponent("prowl") 37 + try FileManager.default.createSymbolicLink( 38 + atPath: installPath.path, 39 + withDestinationPath: fakeBundledBinary.path 40 + ) 41 + 42 + let client = CLIInstallClient( 43 + bundledCLIURL: { fakeBundledBinary }, 44 + installationStatus: { path in 45 + let fileManager = FileManager.default 46 + let filePath = path.path(percentEncoded: false) 47 + guard fileManager.fileExists(atPath: filePath) else { return .notInstalled } 48 + guard let attrs = try? fileManager.attributesOfItem(atPath: filePath), 49 + attrs[.type] as? FileAttributeType == .typeSymbolicLink, 50 + let destination = try? fileManager.destinationOfSymbolicLink(atPath: filePath) 51 + else { return .installedDifferentSource(path: filePath) } 52 + if destination == fakeBundledBinary.path(percentEncoded: false) { 53 + return .installed(path: filePath) 54 + } 55 + return .installedDifferentSource(path: filePath) 56 + }, 57 + install: { _ in }, 58 + uninstall: { _ in } 59 + ) 60 + 61 + let status = client.installationStatus(installPath) 62 + #expect(status == .installed(path: installPath.path)) 63 + } 64 + 65 + @Test func statusDifferentSourceWhenSymlinkPointsElsewhere() throws { 66 + let tmp = try makeTempDir() 67 + defer { cleanup(tmp) } 68 + 69 + let otherBinary = tmp.appendingPathComponent("other-prowl") 70 + FileManager.default.createFile(atPath: otherBinary.path, contents: nil) 71 + 72 + let installPath = tmp.appendingPathComponent("prowl") 73 + try FileManager.default.createSymbolicLink( 74 + atPath: installPath.path, 75 + withDestinationPath: otherBinary.path 76 + ) 77 + 78 + let client = CLIInstallClient.liveValue 79 + let status = client.installationStatus(installPath) 80 + 81 + #expect(status == .installedDifferentSource(path: installPath.path)) 82 + } 83 + 84 + @Test func statusDifferentSourceWhenRegularFileExists() throws { 85 + let tmp = try makeTempDir() 86 + defer { cleanup(tmp) } 87 + 88 + let installPath = tmp.appendingPathComponent("prowl") 89 + FileManager.default.createFile(atPath: installPath.path, contents: Data("binary".utf8)) 90 + 91 + let client = CLIInstallClient.liveValue 92 + let status = client.installationStatus(installPath) 93 + 94 + #expect(status == .installedDifferentSource(path: installPath.path)) 95 + } 96 + 97 + @Test func installCreatesSymlink() async throws { 98 + let tmp = try makeTempDir() 99 + defer { cleanup(tmp) } 100 + 101 + let fakeBundled = tmp.appendingPathComponent("source/prowl") 102 + try FileManager.default.createDirectory( 103 + at: fakeBundled.deletingLastPathComponent(), 104 + withIntermediateDirectories: true 105 + ) 106 + FileManager.default.createFile(atPath: fakeBundled.path, contents: Data("cli".utf8)) 107 + 108 + let installPath = tmp.appendingPathComponent("bin/prowl") 109 + let client = CLIInstallClient( 110 + bundledCLIURL: { fakeBundled }, 111 + installationStatus: CLIInstallClient.liveValue.installationStatus, 112 + install: { path in 113 + let fileManager = FileManager.default 114 + let bundledPath = fakeBundled.path(percentEncoded: false) 115 + let installDir = path.deletingLastPathComponent().path(percentEncoded: false) 116 + if !fileManager.fileExists(atPath: installDir) { 117 + try fileManager.createDirectory(atPath: installDir, withIntermediateDirectories: true) 118 + } 119 + let destination = path.path(percentEncoded: false) 120 + if fileManager.fileExists(atPath: destination) { 121 + try fileManager.removeItem(atPath: destination) 122 + } 123 + do { 124 + try fileManager.createSymbolicLink(atPath: destination, withDestinationPath: bundledPath) 125 + } catch { 126 + throw CLIInstallError(message: error.localizedDescription) 127 + } 128 + }, 129 + uninstall: CLIInstallClient.liveValue.uninstall 130 + ) 131 + 132 + try await client.install(installPath) 133 + 134 + let fileManager = FileManager.default 135 + let attrs = try fileManager.attributesOfItem(atPath: installPath.path) 136 + #expect(attrs[.type] as? FileAttributeType == .typeSymbolicLink) 137 + 138 + let target = try fileManager.destinationOfSymbolicLink(atPath: installPath.path) 139 + #expect(target == fakeBundled.path) 140 + } 141 + 142 + @Test func installCreatesParentDirectoryIfNeeded() async throws { 143 + let tmp = try makeTempDir() 144 + defer { cleanup(tmp) } 145 + 146 + let fakeBundled = tmp.appendingPathComponent("source/prowl") 147 + try FileManager.default.createDirectory( 148 + at: fakeBundled.deletingLastPathComponent(), 149 + withIntermediateDirectories: true 150 + ) 151 + FileManager.default.createFile(atPath: fakeBundled.path, contents: nil) 152 + 153 + let deepPath = tmp.appendingPathComponent("a/b/c/prowl") 154 + let client = CLIInstallClient( 155 + bundledCLIURL: { fakeBundled }, 156 + installationStatus: CLIInstallClient.liveValue.installationStatus, 157 + install: { path in 158 + let fileManager = FileManager.default 159 + let bundledPath = fakeBundled.path(percentEncoded: false) 160 + let installDir = path.deletingLastPathComponent().path(percentEncoded: false) 161 + if !fileManager.fileExists(atPath: installDir) { 162 + try fileManager.createDirectory(atPath: installDir, withIntermediateDirectories: true) 163 + } 164 + let destination = path.path(percentEncoded: false) 165 + do { 166 + try fileManager.createSymbolicLink(atPath: destination, withDestinationPath: bundledPath) 167 + } catch { 168 + throw CLIInstallError(message: error.localizedDescription) 169 + } 170 + }, 171 + uninstall: CLIInstallClient.liveValue.uninstall 172 + ) 173 + 174 + try await client.install(deepPath) 175 + 176 + #expect(FileManager.default.fileExists(atPath: deepPath.path)) 177 + } 178 + 179 + @Test func uninstallRemovesSymlink() async throws { 180 + let tmp = try makeTempDir() 181 + defer { cleanup(tmp) } 182 + 183 + let target = tmp.appendingPathComponent("target") 184 + FileManager.default.createFile(atPath: target.path, contents: nil) 185 + 186 + let installPath = tmp.appendingPathComponent("prowl") 187 + try FileManager.default.createSymbolicLink( 188 + atPath: installPath.path, 189 + withDestinationPath: target.path 190 + ) 191 + 192 + let client = CLIInstallClient.liveValue 193 + try await client.uninstall(installPath) 194 + 195 + #expect(!FileManager.default.fileExists(atPath: installPath.path)) 196 + } 197 + 198 + @Test func uninstallRefusesToRemoveRegularFile() async throws { 199 + let tmp = try makeTempDir() 200 + defer { cleanup(tmp) } 201 + 202 + let installPath = tmp.appendingPathComponent("prowl") 203 + FileManager.default.createFile(atPath: installPath.path, contents: Data("binary".utf8)) 204 + 205 + let client = CLIInstallClient.liveValue 206 + 207 + do { 208 + try await client.uninstall(installPath) 209 + Issue.record("Expected uninstall to throw for non-symlink file") 210 + } catch let error as CLIInstallError { 211 + #expect(error.message.contains("not a symlink")) 212 + } 213 + #expect(FileManager.default.fileExists(atPath: installPath.path)) 214 + } 215 + 216 + @Test func uninstallThrowsWhenNoFileExists() async throws { 217 + let tmp = try makeTempDir() 218 + defer { cleanup(tmp) } 219 + 220 + let installPath = tmp.appendingPathComponent("prowl") 221 + let client = CLIInstallClient.liveValue 222 + 223 + do { 224 + try await client.uninstall(installPath) 225 + Issue.record("Expected uninstall to throw when file does not exist") 226 + } catch let error as CLIInstallError { 227 + #expect(error.message.contains("No CLI tool found")) 228 + } 229 + } 230 + }
+1
supacodeTests/CommandPaletteFeatureTests.swift
··· 16 16 "global.open-repository", 17 17 "global.new-worktree", 18 18 "global.refresh-worktrees", 19 + "global.install-cli", 19 20 ] 20 21 #if DEBUG 21 22 expectedIDs.append(contentsOf: [