From 055a4286f7106c713b0a1812449b3cf973009ed0 Mon Sep 17 00:00:00 2001 From: Maxim Krouk Date: Thu, 13 Aug 2026 17:47:30 +0200 Subject: [PATCH] feat: Add integration for raycast extension --- Documentation/ExtensionAPI.md | 21 +- README.md | 30 +- Sources/fxcodex-cli/AppCommand.swift | 2 +- .../Integrations/IntegrationCommand.swift | 42 ++- .../Integrations+RaycastCommand.swift | 276 ++++++++++++++-- .../Helpers/ExternalURLOpenerClient.swift | 44 +++ .../Helpers/TerminalPromptsClient.swift | 2 +- .../RaycastCommandTests.swift | 304 ++++++++++++++++++ 8 files changed, 672 insertions(+), 49 deletions(-) create mode 100644 Sources/fxcodex-cli/Helpers/ExternalURLOpenerClient.swift diff --git a/Documentation/ExtensionAPI.md b/Documentation/ExtensionAPI.md index 6cdfeff..2dac550 100644 --- a/Documentation/ExtensionAPI.md +++ b/Documentation/ExtensionAPI.md @@ -95,9 +95,11 @@ name. `delete` and `erase` require at least one workspace name and `--yes`. `open` and `workspace paths` without a name use the current workspace. Every integration attribute command requires an explicit integration identifier, and `set` also requires an explicit value. `integrations raycast install` requires -an explicit `app` or `script-command` component. Script Command installation -also requires `--directory` unless an installed configuration already records -the directory. +an explicit `app`, `extension`, or `script-command` component. Script Command +installation also requires `--directory` unless an installed configuration +already records the directory; this component is deprecated in favor of the +extension. Extension installation requires `--yes` when Raycast is absent, or +`--open-in-browser` to bypass Raycast installation. ## Stable workspace identities @@ -117,7 +119,7 @@ fxcodex open \ ``` The ID and workspace name are mutually exclusive for `open`. Other workspace -mutation commands continue to accept names in 0.2.1. +mutation commands continue to accept names in 0.3.0. `workspace paths` also accepts either a workspace name or `--workspace-id`. Its response identifies the selected workspace and includes @@ -172,14 +174,17 @@ resulting component status: ```sh fxcodex integrations raycast install app --json +fxcodex integrations raycast install extension --open-in-browser --json fxcodex integrations raycast install script-command \ --directory "$HOME/.config/raycast/script-commands" \ --json ``` Application outcomes are `already-installed`, `installed`, or -`download-opened`. Script Command installation returns `installed` together -with `script_commands`. +`download-opened`. Extension installation returns `extension-opened` when its +Raycast deep link is opened or `store-page-opened` when the browser is used. +Script Command installation returns `installed` together with +`script_commands`. `rename` requests the `Codex.app` name and returns a `CodexApplicationRenameResult`. `rename --undo` requests `ChatGPT.app`. The @@ -214,13 +219,13 @@ Set a scoped environment variable to `-1` to exclude that section even when all sections are enabled. On the command line, use the corresponding `--no-list-...` flag. -The following commands are intentionally outside the 0.2.1 machine API: +The following commands are intentionally outside the 0.3.0 machine API: - `cli` and `exec` replace the fxcodex process with Codex CLI, whose session or output may be interactive. - The root `uninstall` command removes the current executable. - `integrations raycast sync` and `integrations raycast uninstall` are - terminal-only maintenance commands in 0.2.1. + terminal-only maintenance commands in 0.3.0. A frontend should use the supported workspace and status commands and refresh its state after mutations. Raycast Script Commands remain an optional diff --git a/README.md b/README.md index 41558a4..4fe9736 100644 --- a/README.md +++ b/README.md @@ -315,10 +315,22 @@ otherwise, `set` stores the input as a string. ## Raycast integration +Run either command without a trailing action to choose interactively: + +```sh +fxcodex integrations +fxcodex integrations raycast +``` + +The Raycast menu offers status and installation actions. Installation then +offers Raycast, Raycast Beta, the fxcodex extension, and the deprecated Script +Commands integration. + ### Script Commands -`fxcodex` can install and maintain Raycast Script Commands for quickly opening -workspaces. Start the guided setup with: +Raycast Script Commands are deprecated in favor of the extension, but `fxcodex` +continues to install and maintain them for quickly opening workspaces. Choose a +component interactively with: ```sh fxcodex integrations raycast install @@ -345,9 +357,17 @@ later-selected workspaces are not added automatically. Reinstall without ### Extension A companion Raycast extension provides workspace navigation, management, -custom icons, executable selection, and preferences. It is prepared separately -for [Raycast Store submission](https://github.com/raycast/extensions/pull/29538); the Script Commands -above are available without it. +custom icons, executable selection, and preferences. Install it with: + +```sh +fxcodex integrations raycast install extension +``` + +When both Raycast and Raycast Beta are installed, `fxcodex` asks which edition +should open the extension deep link. When neither is installed, the guided flow +recommends installing Raycast before offering the browser as a fallback. Use +`--open-in-browser` to open the [fxcodex Raycast Store page](https://www.raycast.com/maximkrouk/fxcodex) +directly. The Script Commands above remain available without the extension. ## Machine-readable output diff --git a/Sources/fxcodex-cli/AppCommand.swift b/Sources/fxcodex-cli/AppCommand.swift index 9e7f772..9c99e2c 100644 --- a/Sources/fxcodex-cli/AppCommand.swift +++ b/Sources/fxcodex-cli/AppCommand.swift @@ -6,7 +6,7 @@ import FXCodexClient @main internal struct AppCommand: AsyncParsableCommand { - internal static let version: String = "0.2.1" + internal static let version: String = "0.3.0" internal static let machineEncodingFailureResponse: String = """ { "api_version": 1, diff --git a/Sources/fxcodex-cli/Commands/Integrations/IntegrationCommand.swift b/Sources/fxcodex-cli/Commands/Integrations/IntegrationCommand.swift index 04deaa9..f2e44fc 100644 --- a/Sources/fxcodex-cli/Commands/Integrations/IntegrationCommand.swift +++ b/Sources/fxcodex-cli/Commands/Integrations/IntegrationCommand.swift @@ -28,16 +28,20 @@ extension AppCommand { "Choose an integration area:", [ .init(value: "attributes", label: "Attributes", hint: "Read or modify integration data"), - .init(value: "raycast", label: "Raycast", hint: "Show Raycast integration status"), + .init(value: "raycast", label: "Raycast", hint: "Manage Raycast integration components"), ] ) else { return } switch area { case "attributes": - try await Attributes().run() + let command: Attributes = try .parse([]) + try await command.run() + case "raycast": - try await Raycast.Status().run() + let command: Raycast = try .parse([]) + try await command.run() + default: throw ValidationError("Unsupported integration area.") } @@ -319,9 +323,39 @@ extension AppCommand.IntegrationsCommand { Sync.self, Uninstall.self, ], - defaultSubcommand: Status.self + defaultSubcommand: nil ) internal init() {} + + internal func run() async throws { + guard !globalMachineOutputRequested() else { + throw ValidationError("Choose status or install when using --json.") + } + + @Dependency(\._fxcodexTerminalPrompts) + var prompts: TerminalPromptsClient + + guard let action = try prompts.select( + "Choose a Raycast action:", + [ + .init(value: "status", label: "Status", hint: "Show installed components"), + .init(value: "install", label: "Install", hint: "Install a component"), + ] + ) else { return } + + switch action { + case "status": + let command: Status = try .parse([]) + try await command.run() + + case "install": + let command: Install = try .parse([]) + try await command.run() + + default: + throw ValidationError("Unsupported Raycast action.") + } + } } } diff --git a/Sources/fxcodex-cli/Commands/Integrations/Integrations+RaycastCommand.swift b/Sources/fxcodex-cli/Commands/Integrations/Integrations+RaycastCommand.swift index 90d9853..befa6de 100644 --- a/Sources/fxcodex-cli/Commands/Integrations/Integrations+RaycastCommand.swift +++ b/Sources/fxcodex-cli/Commands/Integrations/Integrations+RaycastCommand.swift @@ -5,9 +5,14 @@ import Foundation import FXCodexClient extension AppCommand.IntegrationsCommand.Raycast { - internal enum Component: String, ExpressibleByArgument { + internal enum Component: String, CaseIterable, ExpressibleByArgument { case app case scriptCommand = "script-command" + case raycastExtension = "extension" + } + + internal enum MaintenanceComponent: String, CaseIterable, ExpressibleByArgument { + case scriptCommand = "script-command" } internal struct Status: AsyncParsableCommand { @@ -68,10 +73,10 @@ extension AppCommand.IntegrationsCommand.Raycast { extension AppCommand.IntegrationsCommand.Raycast { internal struct Install: AsyncParsableCommand { internal static let configuration: CommandConfiguration = .init( - abstract: "Install Raycast or its fxcodex Script Commands." + abstract: "Install Raycast or an fxcodex integration component." ) - @Argument(help: "Component to install. Omit to start guided setup.") + @Argument(help: "Component to install; script-command is deprecated. Omit to choose interactively.") internal var component: Component? @Flag(help: "Target Raycast Beta instead of stable Raycast.") @@ -83,6 +88,9 @@ extension AppCommand.IntegrationsCommand.Raycast { @Flag(help: "Generate only the command for the workspace that is currently selected.") internal var currentOnly: Bool = false + @Flag(help: "Open the fxcodex extension page in the default browser.") + internal var openInBrowser: Bool = false + @Flag(name: .shortAndLong, help: "Accept confirmation prompts.") internal var yes: Bool = false @@ -99,11 +107,20 @@ extension AppCommand.IntegrationsCommand.Raycast { @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\._fxcodexExternalURLOpener) + var externalURLOpener: ExternalURLOpenerClient + + @Dependency(\._fxcodexTerminalPrompts) + var prompts: TerminalPromptsClient + let json = machineOutputRequested(self.json) guard !json || self.component != nil else { throw ValidationError("An integration component is required when using --json.") } + guard !self.openInBrowser || self.component == .raycastExtension else { + throw ValidationError("--open-in-browser is only available for the extension component.") + } let reporter: TerminalReporter = await .init(assumeYes: self.yes) let edition: RaycastEdition = self.beta ? .beta : .stable @@ -126,6 +143,24 @@ extension AppCommand.IntegrationsCommand.Raycast { )) } + case .raycastExtension: + guard let result = try await self.installExtension( + client: client, + externalURLOpener: externalURLOpener, + prompts: prompts, + reporter: json ? nil : reporter, + json: json + ) else { return } + + if json { + try printMachineResponse(Output( + component: Component.raycastExtension.rawValue, + outcome: result.outcome, + application: result.application, + scriptCommands: nil + )) + } + case .scriptCommand: let status = try await self.installScriptCommands( client: client, @@ -143,27 +178,65 @@ extension AppCommand.IntegrationsCommand.Raycast { } case nil: - let applicationStatus: RaycastApplicationStatus = try await client.integrations.raycast.applicationStatus( - edition - ) - - if applicationStatus.applicationURL == nil { - guard await reporter.confirm("Install \(edition.displayName)?") else { return } + guard let rawComponent = try prompts.select( + "Choose a component to install:", + [ + .init( + value: InteractiveComponent.app.rawValue, + label: "Raycast", + ), + .init( + value: InteractiveComponent.betaApp.rawValue, + label: "Raycast Beta", + ), + .init( + value: InteractiveComponent.scriptCommand.rawValue, + label: "Script Commands (deprecated)", + hint: "Use the extension when possible" + ), + .init( + value: InteractiveComponent.raycastExtension.rawValue, + label: "Extension", + hint: "Install the fxcodex Raycast extension" + ), + ] + ) else { return } + + guard let component = InteractiveComponent(rawValue: rawComponent) + else { throw ValidationError("Unsupported Raycast installation component.") } + + switch component { + case .app: + _ = try await self.installApplication( + edition: .stable, + client: client, + reporter: reporter, + suppressOutput: false + ) + case .betaApp: _ = try await self.installApplication( - edition: edition, + edition: .beta, client: client, reporter: reporter, suppressOutput: false ) - } - if await reporter.confirm("Install fxcodex Script Commands for Raycast?") { + case .scriptCommand: _ = try await self.installScriptCommands( client: client, reporter: reporter, json: false ) + + case .raycastExtension: + _ = try await self.installExtension( + client: client, + externalURLOpener: externalURLOpener, + prompts: prompts, + reporter: reporter, + json: false + ) } } } @@ -177,7 +250,7 @@ extension AppCommand.IntegrationsCommand.Raycast { ) @Argument(help: "Component to synchronize. Omit to choose interactively.") - internal var component: Component? + internal var component: MaintenanceComponent? internal init() {} @@ -187,14 +260,11 @@ extension AppCommand.IntegrationsCommand.Raycast { @Dependency(\._fxcodexTerminalPrompts) var prompts: TerminalPromptsClient - guard let component = try AppCommand.IntegrationsCommand.Raycast.resolveMaintenanceComponent( + guard try AppCommand.IntegrationsCommand.Raycast.resolveMaintenanceComponent( self.component, action: "synchronize", prompts: prompts - ) else { return } - - guard component == .scriptCommand - else { throw ValidationError("The app component cannot be synchronized by fxcodex.") } + ) != nil else { return } @Dependency(\.fxCodexClient) var client: FXCodexClient @@ -214,7 +284,7 @@ extension AppCommand.IntegrationsCommand.Raycast { ) @Argument(help: "Component to uninstall. Omit to choose interactively.") - internal var component: Component? + internal var component: MaintenanceComponent? @Flag(name: .shortAndLong, help: "Confirm without prompting.") internal var yes: Bool = false @@ -227,15 +297,11 @@ extension AppCommand.IntegrationsCommand.Raycast { @Dependency(\._fxcodexTerminalPrompts) var prompts: TerminalPromptsClient - guard let component = try AppCommand.IntegrationsCommand.Raycast.resolveMaintenanceComponent( + guard try AppCommand.IntegrationsCommand.Raycast.resolveMaintenanceComponent( self.component, action: "uninstall", prompts: prompts - ) else { return } - - guard component == .scriptCommand else { - throw ValidationError("fxcodex does not uninstall the Raycast application.") - } + ) != nil else { return } let reporter: TerminalReporter = await .init(assumeYes: self.yes) @@ -267,10 +333,19 @@ extension AppCommand.IntegrationsCommand.Raycast.Status { } extension AppCommand.IntegrationsCommand.Raycast.Install { + private enum InteractiveComponent: String { + case app + case betaApp = "app-beta" + case scriptCommand = "script-command" + case raycastExtension = "extension" + } + private enum Outcome: String, Encodable { case alreadyInstalled = "already-installed" - case installed case downloadOpened = "download-opened" + case extensionOpened = "extension-opened" + case installed + case storePageOpened = "store-page-opened" } private struct Output: Encodable { @@ -280,6 +355,19 @@ extension AppCommand.IntegrationsCommand.Raycast.Install { let scriptCommands: RaycastScriptCommandStatus? } + private struct ExtensionInstallationResult { + let outcome: Outcome + let application: RaycastApplicationStatus? + + init( + outcome: Outcome, + application: RaycastApplicationStatus? + ) { + self.outcome = outcome + self.application = application + } + } + @MainActor private func installApplication( edition: RaycastEdition, @@ -323,6 +411,134 @@ extension AppCommand.IntegrationsCommand.Raycast.Install { ) } + @MainActor + private func installExtension( + client: FXCodexClient, + externalURLOpener: ExternalURLOpenerClient, + prompts: TerminalPromptsClient, + reporter: TerminalReporter?, + json: Bool + ) async throws -> ExtensionInstallationResult? { + guard + let deepLink = URL(string: "raycast://extensions/maximkrouk/fxcodex"), + let storePageURL = URL(string: "https://www.raycast.com/maximkrouk/fxcodex") + else { throw CocoaError(.fileNoSuchFile) } + + if self.openInBrowser { + try externalURLOpener.openInBrowser(storePageURL) + reporter?.success("Opened the fxcodex extension page in your browser.") + return .init(outcome: .storePageOpened, application: nil) + } + + var installedApplications: [RaycastApplicationStatus] = [] + + for edition in RaycastEdition.allCases { + let status: RaycastApplicationStatus = try await client.integrations.raycast.applicationStatus( + edition + ) + if status.applicationURL != nil { + installedApplications.append(status) + } + } + + let selectedApplication: RaycastApplicationStatus + + if self.beta { + guard let beta = installedApplications.first(where: { $0.edition == .beta }) else { + throw ValidationError( + "Raycast Beta is not installed. Install it first or use --open-in-browser." + ) + } + selectedApplication = beta + + } else if installedApplications.count == 1, let application = installedApplications.first { + selectedApplication = application + + } else if installedApplications.count > 1 { + if json { + guard let stable = installedApplications.first(where: { $0.edition == .stable }) + else { throw ValidationError("Raycast is not installed.") } + selectedApplication = stable + + } else { + let options: [TerminalPromptOption] = installedApplications.map { application in + .init( + value: application.edition.rawValue, + label: application.edition.displayName, + hint: application.version + ) + } + + guard let selection = try prompts.select( + "Open the fxcodex extension with:", + options + ) else { return nil } + guard + let edition = RaycastEdition(rawValue: selection), + let application = installedApplications.first(where: { $0.edition == edition }) + else { throw ValidationError("Unsupported Raycast edition.") } + + selectedApplication = application + } + + } else { + if !self.yes { + guard !json else { + throw ValidationError( + "Raycast is not installed. Pass --yes to install it or use --open-in-browser." + ) + } + + guard let selection = try prompts.select( + "Raycast is not installed:", + [ + .init( + value: "install", + label: "Install Raycast", + hint: "Recommended ยท then open the fxcodex extension" + ), + .init( + value: "browser", + label: "Open in Browser", + hint: "View the extension without installing Raycast" + ), + ] + ) else { return nil } + + if selection == "browser" { + try externalURLOpener.openInBrowser(storePageURL) + reporter?.success("Opened the fxcodex extension page in your browser.") + return .init(outcome: .storePageOpened, application: nil) + } + + guard selection == "install" + else { throw ValidationError("Unsupported extension installation choice.") } + } + + let installation = try await self.installApplication( + edition: .stable, + client: client, + reporter: reporter, + suppressOutput: json + ) + guard installation.status.applicationURL != nil else { + throw ValidationError("Raycast must finish installing before the extension can be opened.") + } + selectedApplication = installation.status + } + + guard let applicationURL = selectedApplication.applicationURL + else { throw ValidationError("The selected Raycast application could not be found.") } + + try await externalURLOpener.openInApplication(deepLink, applicationURL) + reporter?.success("Opened the fxcodex extension in \(selectedApplication.edition.displayName).") + + return .init( + outcome: .extensionOpened, + application: selectedApplication + ) + } + @MainActor private func installScriptCommands( client: FXCodexClient, @@ -361,24 +577,24 @@ extension AppCommand.IntegrationsCommand.Raycast.Install { extension AppCommand.IntegrationsCommand.Raycast { fileprivate static func resolveMaintenanceComponent( - _ provided: Component?, + _ provided: MaintenanceComponent?, action: String, prompts: TerminalPromptsClient - ) throws -> Component? { + ) throws -> MaintenanceComponent? { if let provided { return provided } guard let selected = try prompts.select( "Select a component to \(action):", [ .init( - value: Component.scriptCommand.rawValue, + value: MaintenanceComponent.scriptCommand.rawValue, label: "Script Commands", hint: nil ), ] ) else { return nil } - guard let component = Component(rawValue: selected) else { + guard let component = MaintenanceComponent(rawValue: selected) else { throw ValidationError("Unsupported Raycast integration component.") } diff --git a/Sources/fxcodex-cli/Helpers/ExternalURLOpenerClient.swift b/Sources/fxcodex-cli/Helpers/ExternalURLOpenerClient.swift new file mode 100644 index 0000000..07197ea --- /dev/null +++ b/Sources/fxcodex-cli/Helpers/ExternalURLOpenerClient.swift @@ -0,0 +1,44 @@ +import AppKit +import Dependencies +import Foundation + +internal struct ExternalURLOpenerClient: Sendable { + internal var openInApplication: @MainActor @Sendable (URL, URL) async throws -> Void + internal var openInBrowser: @MainActor @Sendable (URL) throws -> Void + + internal init( + openInApplication: @escaping @MainActor @Sendable (URL, URL) async throws -> Void, + openInBrowser: @escaping @MainActor @Sendable (URL) throws -> Void + ) { + self.openInApplication = openInApplication + self.openInBrowser = openInBrowser + } +} + +extension DependencyValues { + private enum ExternalURLOpenerClientKey: DependencyKey { + static var liveValue: ExternalURLOpenerClient { + .init( + openInApplication: { url, applicationURL in + let configuration: NSWorkspace.OpenConfiguration = .init() + configuration.activates = true + + _ = try await NSWorkspace.shared.open( + [url], + withApplicationAt: applicationURL, + configuration: configuration + ) + }, + openInBrowser: { url in + guard NSWorkspace.shared.open(url) + else { throw CocoaError(.fileNoSuchFile) } + } + ) + } + } + + internal var _fxcodexExternalURLOpener: ExternalURLOpenerClient { + get { self[ExternalURLOpenerClientKey.self] } + set { self[ExternalURLOpenerClientKey.self] = newValue } + } +} diff --git a/Sources/fxcodex-cli/Helpers/TerminalPromptsClient.swift b/Sources/fxcodex-cli/Helpers/TerminalPromptsClient.swift index 223118a..ee91309 100644 --- a/Sources/fxcodex-cli/Helpers/TerminalPromptsClient.swift +++ b/Sources/fxcodex-cli/Helpers/TerminalPromptsClient.swift @@ -12,7 +12,7 @@ internal struct TerminalPromptOption: Equatable, Sendable { internal init( value: String, label: String, - hint: String? + hint: String? = nil ) { self.value = value self.label = label diff --git a/Tests/fxcodex-cli-tests/RaycastCommandTests.swift b/Tests/fxcodex-cli-tests/RaycastCommandTests.swift index 529a7d1..1559231 100644 --- a/Tests/fxcodex-cli-tests/RaycastCommandTests.swift +++ b/Tests/fxcodex-cli-tests/RaycastCommandTests.swift @@ -35,6 +35,310 @@ struct RaycastCommandTests { #expect(command.json == true) } + @Test("Install parses the extension browser override") + func extensionBrowserOverride() throws { + let command: AppCommand.IntegrationsCommand.Raycast.Install = try .parse([ + "extension", + "--open-in-browser", + ]) + #expect(command.component == .raycastExtension) + #expect(command.openInBrowser) + } + + @Test("Component command help lists accepted values") + func componentHelp() { + let installHelp: String = AppCommand.IntegrationsCommand.Raycast.Install.helpMessage() + let syncHelp: String = AppCommand.IntegrationsCommand.Raycast.Sync.helpMessage() + let uninstallHelp: String = AppCommand.IntegrationsCommand.Raycast.Uninstall.helpMessage() + + #expect(installHelp.contains("(values: app,")) + #expect(installHelp.contains("script-command, extension)")) + #expect(installHelp.contains("script-command is deprecated")) + #expect(syncHelp.contains("values: script-command")) + #expect(uninstallHelp.contains("values: script-command")) + } + + @Test("Integrations can enter the interactive Raycast status action") + func interactiveIntegrationsRaycastStatus() async throws { + let offeredValues = LockIsolated<[[String]]>([]) + let answers = LockIsolated(["raycast", "status"]) + let statusRequested = LockIsolated(false) + var raycast = Integrations.Raycast() + raycast.applicationStatus = { edition in + statusRequested.setValue(true) + return .init(edition: edition, applicationURL: nil, version: nil) + } + raycast.scriptCommandStatus = { + .init(directoryURL: nil, managedCommandCount: 0) + } + + try await withDependencies { + $0.fxCodexClient = .init() + $0._fxcodexIntegrations = .init(raycast: raycast, attributes: .init()) + $0._fxcodexTerminalPrompts = .init( + select: { _, options in + offeredValues.withValue { $0.append(options.map(\.value)) } + return answers.withValue { $0.removeFirst() } + }, + multiselect: { _, _ in nil }, + confirm: { _ in nil } + ) + } operation: { + let command: AppCommand.IntegrationsCommand = try .parse([]) + try await command.run() + } + + #expect(offeredValues.value == [ + ["attributes", "raycast"], + ["status", "install"], + ]) + #expect(statusRequested.value) + } + + @Test("Bare Raycast command offers actions") + func interactiveRaycastActions() async throws { + let offeredValues = LockIsolated<[String]>([]) + var raycast = Integrations.Raycast() + raycast.applicationStatus = { edition in + .init(edition: edition, applicationURL: nil, version: nil) + } + raycast.scriptCommandStatus = { + .init(directoryURL: nil, managedCommandCount: 0) + } + + try await withDependencies { + $0.fxCodexClient = .init() + $0._fxcodexIntegrations = .init(raycast: raycast, attributes: .init()) + $0._fxcodexTerminalPrompts = .init( + select: { _, options in + offeredValues.setValue(options.map(\.value)) + return "status" + }, + multiselect: { _, _ in nil }, + confirm: { _ in nil } + ) + } operation: { + let parsed: any ParsableCommand = try AppCommand.parseAsRoot([ + "integrations", + "raycast", + ]) + let command = try #require(parsed as? AppCommand.IntegrationsCommand.Raycast) + try await command.run() + } + + #expect(offeredValues.value == ["status", "install"]) + } + + @Test("Interactive installation offers every component") + func interactiveInstallComponents() async throws { + let applicationURL = URL(fileURLWithPath: "/Applications/Raycast.app") + let offeredOptions = LockIsolated<[TerminalPromptOption]>([]) + var raycast = Integrations.Raycast() + raycast.applicationInstallation = { edition in + #expect(edition == .stable) + return .alreadyInstalled(applicationURL) + } + raycast.applicationStatus = { edition in + .init( + edition: edition, + applicationURL: edition == .stable ? applicationURL : nil, + version: nil + ) + } + + try await withDependencies { + $0.fxCodexClient = .init() + $0._fxcodexIntegrations = .init(raycast: raycast, attributes: .init()) + $0._fxcodexTerminalPrompts = .init( + select: { _, options in + offeredOptions.setValue(options) + return "app" + }, + multiselect: { _, _ in nil }, + confirm: { _ in nil } + ) + } operation: { + let command: AppCommand.IntegrationsCommand.Raycast.Install = try .parse([]) + try await command.run() + } + + #expect(offeredOptions.value.map(\.value) == [ + "app", + "app-beta", + "script-command", + "extension", + ]) + #expect(offeredOptions.value.map(\.label) == [ + "Raycast", + "Raycast Beta", + "Script Commands (deprecated)", + "Extension", + ]) + } + + @Test("Extension browser override opens the Raycast Store page directly") + func openExtensionInBrowser() async throws { + let openedURL = LockIsolated(nil) + + try await withDependencies { + $0.fxCodexClient = .init() + $0._fxcodexExternalURLOpener = .init( + openInApplication: { _, _ in }, + openInBrowser: { openedURL.setValue($0) } + ) + $0._fxcodexTerminalPrompts = .init( + select: { _, _ in nil }, + multiselect: { _, _ in nil }, + confirm: { _ in nil } + ) + } operation: { + let command: AppCommand.IntegrationsCommand.Raycast.Install = try .parse([ + "extension", + "--open-in-browser", + ]) + try await command.run() + } + + #expect(openedURL.value?.absoluteString == "https://www.raycast.com/maximkrouk/fxcodex") + } + + @Test("Extension installation selects an installed Raycast edition") + func selectInstalledApplicationForExtension() async throws { + let betaURL = URL(fileURLWithPath: "/Applications/Raycast Beta.app") + let openedDeepLink = LockIsolated(nil) + let openedApplicationURL = LockIsolated(nil) + let offeredEditions = LockIsolated<[String]>([]) + var raycast = Integrations.Raycast() + raycast.applicationStatus = { edition in + .init( + edition: edition, + applicationURL: URL(fileURLWithPath: edition == .stable + ? "/Applications/Raycast.app" + : betaURL.path), + version: edition == .stable ? "1.2.3" : "1.2.4-beta" + ) + } + + try await withDependencies { + $0.fxCodexClient = .init() + $0._fxcodexIntegrations = .init(raycast: raycast, attributes: .init()) + $0._fxcodexExternalURLOpener = .init( + openInApplication: { deepLink, applicationURL in + openedDeepLink.setValue(deepLink) + openedApplicationURL.setValue(applicationURL) + }, + openInBrowser: { _ in } + ) + $0._fxcodexTerminalPrompts = .init( + select: { _, options in + offeredEditions.setValue(options.map(\.value)) + return RaycastEdition.beta.rawValue + }, + multiselect: { _, _ in nil }, + confirm: { _ in nil } + ) + } operation: { + let command: AppCommand.IntegrationsCommand.Raycast.Install = try .parse([ + "extension", + ]) + try await command.run() + } + + #expect(offeredEditions.value == ["stable", "beta"]) + #expect(openedDeepLink.value?.absoluteString == "raycast://extensions/maximkrouk/fxcodex") + #expect(openedApplicationURL.value == betaURL) + } + + @Test("Extension installation recommends Raycast with a browser fallback") + func recommendRaycastForExtension() async throws { + let offeredChoices = LockIsolated<[String]>([]) + let openedURL = LockIsolated(nil) + var raycast = Integrations.Raycast() + raycast.applicationStatus = { edition in + .init(edition: edition, applicationURL: nil, version: nil) + } + + try await withDependencies { + $0.fxCodexClient = .init() + $0._fxcodexIntegrations = .init(raycast: raycast, attributes: .init()) + $0._fxcodexExternalURLOpener = .init( + openInApplication: { _, _ in }, + openInBrowser: { openedURL.setValue($0) } + ) + $0._fxcodexTerminalPrompts = .init( + select: { _, options in + offeredChoices.setValue(options.map(\.value)) + return "browser" + }, + multiselect: { _, _ in nil }, + confirm: { _ in nil } + ) + } operation: { + let command: AppCommand.IntegrationsCommand.Raycast.Install = try .parse([ + "extension", + ]) + try await command.run() + } + + #expect(offeredChoices.value == ["install", "browser"]) + #expect(openedURL.value?.absoluteString == "https://www.raycast.com/maximkrouk/fxcodex") + } + + @Test("Extension installation can install Raycast before opening") + func installRaycastForExtension() async throws { + let applicationURL = URL(fileURLWithPath: "/Applications/Raycast.app") + let didInstall = LockIsolated(false) + let didPrompt = LockIsolated(false) + let openedDeepLink = LockIsolated(nil) + let openedApplicationURL = LockIsolated(nil) + var raycast = Integrations.Raycast() + raycast.applicationStatus = { edition in + .init( + edition: edition, + applicationURL: edition == .stable && didInstall.value + ? applicationURL + : nil, + version: nil + ) + } + raycast.applicationInstallation = { edition in + #expect(edition == .stable) + didInstall.setValue(true) + return .alreadyInstalled(applicationURL) + } + + try await withDependencies { + $0.fxCodexClient = .init() + $0._fxcodexIntegrations = .init(raycast: raycast, attributes: .init()) + $0._fxcodexExternalURLOpener = .init( + openInApplication: { deepLink, applicationURL in + openedDeepLink.setValue(deepLink) + openedApplicationURL.setValue(applicationURL) + }, + openInBrowser: { _ in } + ) + $0._fxcodexTerminalPrompts = .init( + select: { _, _ in + didPrompt.setValue(true) + return nil + }, + multiselect: { _, _ in nil }, + confirm: { _ in nil } + ) + } operation: { + let command: AppCommand.IntegrationsCommand.Raycast.Install = try .parse([ + "extension", + "--yes", + ]) + try await command.run() + } + + #expect(didInstall.value) + #expect(!didPrompt.value) + #expect(openedDeepLink.value?.absoluteString == "raycast://extensions/maximkrouk/fxcodex") + #expect(openedApplicationURL.value == applicationURL) + } + @Test("Sync and uninstall accept explicit or interactive Script Command maintenance") func maintenanceCommands() async throws { let sync: AppCommand.IntegrationsCommand.Raycast.Sync = try .parse([